阐述Do Java primitives go on the Stack or the Heap?-
假设你有一个函数foo():
void foo() {
int a = 5;
system.out.println(a);
}
然后,当编译器编译该函数时,它会创建字节码指令,在调用该函数时在堆栈上留下 4 个字节的空间。名字'a'只对你有用——对编译器来说,它只是为它创建一个点,记住那个点的位置,以及它想要使用'a'值的任何地方,它会插入对内存位置的引用它为那个值保留。
如果您不确定堆栈是如何工作的,它的工作原理是这样的:每个程序至少有一个线程,每个线程都只有一个堆栈。堆栈是一个连续的内存块(如果需要也可以增长)。最初堆栈是空的,直到调用程序中的第一个函数。然后,当你的函数被调用时,你的函数会在堆栈上为它自己、它的所有局部变量、它的返回类型等分配空间。
当您的函数 main 调用另一个函数 foo 时,这是可能发生的一个示例(这里有几个简化的善意谎言):
-
main 想将参数传递给foo。它将这些值推送到堆栈的顶部,这样foo 将准确地知道它们将被放置在哪里(main 和 foo 将以一致的方式传递参数)。
-
main 推送foo 完成后程序执行应该返回的地址。这会增加堆栈指针。
-
main 致电foo。
-
foo启动时,发现栈当前在地址X
-
foo 想在栈上分配 3 个 int 变量,所以需要 12 个字节。
-
foo 将 X + 0 用于第一个 int, X + 4 用于第二个 int, X + 8 用于第三个。
- 编译器可以在编译时计算这个值,并且编译器可以依赖堆栈指针寄存器的值(x86 系统上的 ESP),因此它写出的汇编代码会执行类似“在地址 ESP 中存储 0”的操作+ 0”、“将 1 存储到地址 ESP + 4”等。
-
main 在调用foo 之前压入堆栈的参数也可以通过计算堆栈指针的偏移量由foo 访问。
-
foo 知道它需要多少个参数(比如 3),所以它知道,比如 X - 8 是第一个,X - 12 是第二个,X - 16 是第三个。
- 所以现在
foo 在堆栈上有空间来完成它的工作,它会这样做并完成
- 就在
main 调用foo 之前,main 在堆栈指针递增之前将其返回地址写入堆栈。
-
foo 查找要返回的地址 - 假设地址存储在 ESP - 4 - foo 查找堆栈上的那个位置,在那里找到返回地址,然后跳转到返回地址。
- 现在
main 中的其余代码继续运行,我们已经完成了一次完整的往返。
请注意,每次调用函数时,它都可以对当前堆栈指针所指向的内存及其之后的所有内容进行任何操作。每次一个函数在堆栈上为自己腾出空间时,它都会在调用其他函数之前递增堆栈指针,以确保每个人都知道他们可以在哪里为自己使用堆栈。
我知道这个解释有点模糊了 x86 和 java 之间的界限,但我希望它有助于说明硬件的实际工作原理。
现在,这仅涵盖“堆栈”。堆栈存在于程序中的每个线程,并捕获在该线程上运行的每个函数之间的函数调用链的状态。但是,一个程序可以有多个线程,因此每个线程都有自己独立的堆栈。
当两个函数调用想要处理同一块内存时会发生什么,无论它们在哪个线程上或它们在堆栈中的什么位置?
这就是堆的用武之地。通常(但不总是)一个程序只有一个堆。堆之所以称为堆,是因为它只是一个很大的内存堆。
要使用堆中的内存,您必须调用分配例程 - 找到未使用空间并将其提供给您的例程,以及让您返回已分配但不再使用的空间的例程。内存分配器从操作系统获取大内存页,然后将单独的小比特分配给任何需要它的东西。它跟踪操作系统给它的东西,以及它给程序其余部分的东西。当程序请求堆内存时,它会寻找它可用的满足需要的最小内存块,将该块标记为已分配,并将其交还给程序的其余部分。如果它没有更多的空闲块,它可以向操作系统请求更多的内存页并从那里分配(直到某个限制)。
在像 C 这样的语言中,我提到的那些内存分配例程通常称为malloc() 来请求内存,free() 来返回它。
另一方面,Java 不像 C 那样有显式的内存管理,而是有一个垃圾收集器——你可以分配任何你想要的内存,然后当你完成后,你就停止使用它。 Java 运行时环境将跟踪您已分配的内存,并扫描您的程序以确定您是否不再使用所有分配的内存,并自动释放这些块。
既然我们知道内存是在堆上或栈上分配的,那么当我在类中创建私有变量时会发生什么?
public class Test {
private int balance;
...
}
那段记忆从何而来?答案是堆。您有一些代码创建了一个新的Test 对象 - Test myTest = new Test()。调用 java new 运算符会导致在堆上分配 Test 的新实例。您的变量myTest 存储该分配的地址。 balance 只是与该地址的一些偏移量 - 实际上可能为 0。
最底层的答案就是……会计。
...
我所说的善意的谎言?让我们解决其中的一些问题。
Java 首先是一种计算机模型 - 当您将程序编译为字节码时,您正在编译为一个完全组成的计算机体系结构,它不像任何其他常见 CPU - Java 那样具有寄存器或汇编指令, .Net 和其他一些使用基于堆栈的处理器虚拟机,而不是基于寄存器的机器(如 x86 处理器)。原因是基于堆栈的处理器更容易推理,因此更容易构建操作该代码的工具,这对于构建将该代码编译为可在通用处理器上实际运行的机器代码的工具尤其重要。
至少在大多数 x86 计算机上,给定线程的堆栈指针通常从某个非常高的地址开始,然后向下而不是向上增长。也就是说,由于这是一个机器细节,实际上并不是 Java 需要担心的问题(Java 有自己的机器模型需要担心,它的即时编译器的工作就是担心将其转换为您的实际 CPU)。
我简单地提到了参数是如何在函数之间传递的,比如“参数 A 存储在 ESP - 8,参数 B 存储在 ESP - 12”等。这通常称为“调用约定”,以及其中不止几个。在 x86-32 上,寄存器是稀疏的,因此许多调用约定传递堆栈上的所有参数。这有一些权衡,特别是访问这些参数可能意味着访问 ram(尽管缓存可能会减轻这种情况)。 x86-64 有更多的命名寄存器,这意味着最常见的调用约定在寄存器中传递前几个参数,这可能会提高速度。此外,由于 Java JIT 是唯一为整个过程(本地调用除外)生成机器代码的人,因此它可以选择使用它想要的任何约定来传递参数。
我提到了当你在某个函数中声明一个变量时,该变量的内存来自堆栈 - 这并不总是正确的,它真的取决于环境运行时的突发奇想来决定从哪里获取它记忆来自。在 C#/DotNet 的情况下,如果变量用作闭包的一部分,则该变量的内存可能来自堆 - 这称为“堆提升”。大多数语言通过创建隐藏类来处理闭包。所以经常发生的情况是闭包中涉及的方法本地成员被重写为某个隐藏类的成员,当调用该方法时,而是在堆上分配该类的新实例并将其地址存储在堆栈中;现在所有对该原始局部变量的引用都通过该堆引用发生。