JVM 运行时数据区

190 阅读30分钟

每个程序在运行时都需要内存,JVM 作为运行在操作系统之上的,用于执行Java字节码的软件也是个程序,它也需要内存来存储其运行过程中所需的数据、指令、变量等内容。

但是 JVM 与普通程序不同的是,JVM 除了运行自身外,还需要为每个Java应用程序提供一个执行环境。因此,JVM 的内存管理涉及两个方面:操作系统的内存管理JVM 自身的内存管理

⨳ JVM 本身是一个运行在操作系统上的程序,它启动时由操作系统分配内存资源。操作系统将物理内存和虚拟内存分配给JVM,并提供内存保护机制来防止不同程序间内存访问冲突。

⨳ JVM 将操作系统提供的内存进一步划分为不同的区域,并负责管理这些区域的内存,以提供给Java程序使用。

当然,JVM 使用的内存并不是启动时就向操作系统,一次申请到位的,JVM 也可以通过操作系统的内存分配接口来申请内存(如使用mallocmmap等系统调用)。

下面就看看,JVM是怎么分配、管理这些内存的,从而可以运行Java程序的。

参见JVM规范

运行时数据区 Runtime Data Area

为了更好地执行Java程序时,JVM 会模仿操作系统将内存划分为多个不同用途的区域。JVM的内存划分通常分为三大部分:方法区,以及每个线程有自己独立的

内存区域主要功能存储内容是否线程共享
方法区存储类的结构信息、常量池、静态变量等类的元数据、常量池、方法字节码等共享
存储所有对象及数组对象实例、数组共享
存储每个线程的局部变量、方法调用和返回值局部变量、操作数栈、方法调用和返回地址等每个线程独立
程序计数器记录当前线程执行字节码指令的地址当前执行指令的字节码地址每个线程独立
本地方法栈存储本地方法的相关信息(如C/C++代码)本地方法的调用和执行每个线程独立
直接内存存储直接内存,用于Java与操作系统的直接交互操作系统级别分配的内存(如内存映射文件、大数据流等)共享

JVM 划分的这些内存区域,在执行Java程序时,就是所谓的JVM的运行时数据区(Runtime Data Area),支撑着Java程序的执行。

The Java Virtual Machine defines various run-time data areas that are used during execution of a program.

注意,JVM内存划分是逻辑上的,主要是从内存使用的角度进行划分,而不直接对应操作系统或硬件的物理内存布局。

堆 Heap

堆(Heap)是存放 Java 程序中所有的对象(包括实例化的类对象、数组对象等)的地方。

The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated.

Java 是面向对象编程的,用对象管理数据,万物皆对象,对象是很多的,所以堆内存也是 JVM 所管理的内存中最大的一块。

堆的划分

堆内存根据功能可以划分为年轻代老年代年轻代存放生命周期较短的对象,老年代存放生命周期长的对象。

为什么这么设计呢?这是对象生命周期差异造成:

短生命周期对象:在 Java 程序中,大多数对象(大概90%以上)的生命周期是短暂的,可能只在方法调用中存在,或者仅作为临时对象使用,由于大多数对象很快就变成垃圾(不再被引用),所以需要对这些对象进行回收,增加内存使用率。

长生命周期对象:只有很少的一部分可能在整个应用的运行过程中持续存在,比如一些全局缓存或单例对象,所以它们基本上不能被回收。

如果堆不划分年轻代老年代,那么生命周期短和对象和生命周期长的对象混杂在一起,如果对象创建过多,以至于堆空间满了之后,垃圾回收(GC)发生时,会发生什么呢?

GC 停顿时间会增加,JVM 需要遍历整个堆来标记不再存活的对象(即被引用的对象和未被引用的对象),然后清理无用对象,遍历整个堆会导致较长的停顿时间。

内存碎片增加,当不再存活(不再被引用)的对象被回收,可能会留下大量的小块未使用内存。当内存碎片过多时,即使堆还有足够的空闲内存,也是无法分配足够大的一块连续内存来满足对象的创建需求。

那 JVM 具体怎么划分年轻代老年代的呢?

年轻代(Young Generation) :存放刚创建的对象,又可以细分为两个部分:

  • Eden区:这是对象最初分配的地方。大部分新创建的对象会在 Eden区分配内存。

  • Survivor区:用于存放经过垃圾回收后还存活的对象。

Survivor 又区包含两个部分,S0(Survivor 0区)S1(Survivor 1区),这两个Survivor区会在GC的时候交替使用。

老年代(Old Generation):存放经过多次年轻代GC后依然存活的对象。

image.png

堆各个区域所占比例也可以通过非标准参数选项调节:

-XX:NewRatio:设置 新生代老年代之间的比例。默认值为 2,表示新生代的大小是老年代的大小的 1/2(Young:Old=1:2)。如果你设置为 -XX:NewRatio=3,则新生代大小会是老年代的 1/3(Young:Old=1:3)。

-XX:SurvivorRatio:设置 Eden 区每个 Survivor 区 之间的比例。默认值是 8,也就是说,Eden 区的大小是每个 Survivor 区的 8 倍(Eden:S0:S1 = 8:1:1)。如果设置为 -XX:SurvivorRatio=4,则每个 Survivor 区的大小将是 Eden 区的 1/4(Eden:S0:S1 = 4:1:1)。

这里需要注意,并没有设置 S0 与 S1 之间比例的参数选项,这是因为 S0 和 S1 比例是 1:1,也就是说 S0 与 S1 占用的大小是一样的,至于为啥这样,这是GC的需要。

垃圾回收

对堆的划分是为了更好地垃圾回收,提高垃圾回收效率,那下面就看看对象创建与回收时,这些区域是怎么存放对象的:

  1. 分配内存:当一个对象被创建时,JVM 会在堆的Eden区为该对象分配内存。

image.png

  1. Eden 区满:继续创建对象,直至Eden区满。

image.png

  1. Minor GC:当 Eden区的空间已填满,程序又需要创建对象时,JVM 会触发一次 Minor GC。
  • Minor GC 会回收 Eden 区,再加载新的对象放到 Eden 区。
  • 将 Eden 区中的存活的对象移动到 S0 区,被移动到S0区的对象上有一个年龄计数器,值设置为1。

image.png

如图所示,Minor GC 先回收 Eden 区,先将不再被引用的对象(ObjectB、ObjectD、ObjectE,ObjectF)清理,再创建 ObjectG 到 Eden 区,最后将 Eden 区所有存活的对象移动到S0,被移动到S0区的对象值设置为1,表示被移动一次。

  1. 多次 Minor GC:当 Eden 区的空间又满了,JVM 再触发一次 Minor GC,每次Minor GC 都会回收 Eden 区 和 一个 Survivor 区 的对象,在将存活的对象移动到另一个 Survivor 区,对应的年龄 +1。

image.png

如图所示, Eden 区被再一次 ObjectH ~ ObjectM 填满,触发 Minor GC 回收 Eden 区和 S0 区,先将不再被引用的对象清理,再创建到 ObjectN 到 Eden 区,最后将 Eden 区和 S0 所有存活的对象移动到S1(其中 ObjectA 是 S0 存活的对象,ObjectL 是 Eden 区存活的对象,ObjectN 是新创建的对象),被移动到S1区的对象年龄+1。

可以看到每次 Minor GC 前与每次 Minor G 后,都要有一个 Survivor 区是空的,这也是为什么S0区、S1区占用的大小是一样的,这两个区还有一个名字,叫 From区To区 ,意为每次 Minor GC 都会将 Eden 区和一个 Survivor 区(From区)中存活的对象移动到另一个空的 Survivor 区(To区)。

  1. 移动到老年代:经过若干次 Minor GC后,存活的对象年龄越来越大,直至年龄达到了设定的最大值(通常是 15) ,则再次 Minor GC后,该对象如果仍然未被回收,则从 Survivor 区 移动到 老年代。

  2. Full GC:当老年代内存不足时,触发 Full GC,进行新生代与老年代全堆的内存清理,回收不再使用的对象。

  3. OOM:若老年代执行了 Full GC 之后发现依然无法进行新对象的创建与保存,就会产生 OOM(OutOfMemoryError) 异常,表示堆内存已用尽了。

看到这里,应该理解将堆划分为年轻代老年代的好处了吧。

  1. 缩短 GC 停顿时间:新生代中的 Minor GC 是局部的,通常只需要回收 Eden 区和 Survivor 区,执行速度较快,不会对程序性能造成太大影响。

  2. 减少 内存碎片:新生代的 Survivor 区 通过交替使用 From 区To 区 来存放存活对象,这样存活对象会被集中存放在一块连续的内存区域中,避免了内存碎片的产生。

堆的大小

一个 JVM 实例只有一个堆内存,是各个线程共享的,堆在 JVM 启动的时候被创建,其大小可以由非标准参数选项调节:

-Xms设置初始堆大小,比如 -Xms512m 表示JVM启动时将堆内存的初始大小设置为 512 MB。

-Xms 中的 ms 可以按照 "memory size" 记忆。等效于 非稳定参数选项 -XX:InitialHeapSize=<size>

一般情况下,默认的初始堆内存为计算机内存的 1/64(例如,1GB的内存可能默认会分配 16MB 的堆内存)。

-Xmx设置最大堆大小,比如 -Xmx1024m 表示JVM的堆内存最大可分配为 1024 MB(即1GB)。

-Xmx 中的 mx 可以按照 "memory max" 记忆。等效于 非稳定参数选项 -XX:MaxHeapSize=<size>

一般来说,最大堆内存的默认值通常为物理内存的 1/4。

由于老年代的 Full GC 涉及整个堆回收,相对较慢,可以根据应用的对象生命周期特征,调整 新生代老年代 的大小比例减低Full GC发生的概率。

方法区 Method Area

方法区 存储每个类的结构,如运行时常量池、字段和方法数据,以及方法和构造函数的字节码,包括类和接口初始化(<clinit>)以及实例初始化(<init>)中使用的特殊方法。

It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods used in class and interface initialization and in instance initialization.

是不是感觉整个 .class 文件都被加载、解析到方法区了?

这么理解也没啥毛病。

.class 文件中的常量池加载到方法区后变成了运行时常量池(Runtime Constant Pool)。

二者最大的区别就是.class 文件常量池中的类引用、字段引用、方法引用等都是符号引用,并不指向具体的内存地址;而对于运行时常量池来说,这些符合引用都需要被解析成直接引用,无论是在类加载过程中进行解析,还是在第一次使用时进行动态解析。

.class 文件中的关于类的描述信息(全限定名、访问标志、接口、父类...),结构信息(字段、方法、构造方法...)等也会被加载到方法区。

当 JVM 加载一个类时,会在堆中创建一个 Class 对象,这个 Class 对象就像是一个桥梁,通过它可以访问方法区中存储的类的各种信息。

⨳ 当然别忘了,静态变量(类变量)是属于类的变量,所以静态变量的值放到方法区存储也没啥问题。

如果静态变量是引用类型,引用的对象当然还是在堆里,只是对象的地址在方法区。

需要注意的是,方法区只是JVM的规范,在 JDK8 之前,方法区由 永久代(PermGen) 实现,在在 JDK8 及之后,永久代被 元空间(Metaspace) 取代。

永久代

永久代(Permanent Generation)是 HotSpot 虚拟机在 JDK 7 及以前版本中对方法区的一种具体实现。

永久代其实可以算作堆的一部分,年轻代、老年代、永久代嘛,所以方法区还有一个别名叫作 Non-Heap(非堆),这样做的好处就是因为有现成的堆垃圾回收机制,可以复用这部分代码来处理永久代的类卸载和常量池回收。

既然永久代被元空间替代了,那肯定有不足的地方:

回收逻辑不同

方法区的内存管理需求与堆不同,堆中的对象生命周期较短,适合分代收集,而方法区的类信息通常存活时间长,卸载条件严格(比如类加载器被回收、没有实例等),回收逻辑不一致,强行统一管理会不仅会增加垃圾回收算法复杂度,也会给后续优化回收算法也带来难度。

大小有限

永久代的大小难以预估,回收条件严格,而且还有一些像反射这种动态生成类的情况,设置过小容易溢出,设置过大又浪费内存。难呀难。

-XX:MaxHeapSize=<size> 设置永久代的初始内存大小。

-XX:MaxPermSize=<size> 设置永久代的最大内存大小。

因此,在 JDK8 中,HotSpot 取消了永久代,改用元空间来实现方法区。

元空间

元空间(Metaspace)这个名字就很有讲究,顾名思义就是存储类的元数据的内存空间,类的字段、方法、字节码、常量池这些不都是类的元数据嘛。

元空间和永久代最大的不同就是不再是作为堆的逻辑部分,而是使用本地内存(Native Memory)存储类的元数据信息。

本地内存又是何方神圣,为啥比堆更适合存储类的元数据。

简单来说,本地内存是 JVM 通过本地方法(如malloc)直接向操作系统申请的内存,是由操作系统直接管理的内存。

使用元空间实现方法区比永久代的好处就是:

回收逻辑独立

将元数据从永久代剥离出来放到元空间中,简化了Full GC,并且可以在GC不暂停的情况下并发地释放元数据。

大小不设限

默认情况下,元空间不设上限,按需动态扩展,可用系统内存有多大,元空间就可以多大,如果虚拟机耗了所有的可用系统内存,那就 OOM,Game Over 了。

-XX:MetaspaceSize=<size> 设置元空间的初始大小。

-XX:MaxMetaspaceSize=<size> 设置元空间的最大内存大小。默认 -1,表示没有限制。

字符串常量池

我们已经知道JVM为每个已加载的类型(类或接口)都维护一个运行时常量池,作为类文件中的常量池在运行时的表示形式。按理来说,除了符号引用外,常量池中的字面量项,如整数、浮点数、字符串这些常量值也应该被加载到运行时常量池中,使得在方法调用时能快速访问这些常量和直接引用。

这就有一个问题了,无论是基本类型的字面量还是直接引用,用几个字节就能表示了(int 类型 4 个字节,double 类型 8 个字节,引用地址最多也 8 个字节),占用空间少,放到方法区没啥问题,但字符串不同,它是一个对象。

对象就应该在堆里面,运行时常量池仅仅保存一个对该字符串的引用即可。

到此为止,没啥问题吧。

更进一步,符号引用解析出来的直接引用,无论是描述字段、方法,还是类的直接引用,是不是都是指向方法区中相关类的元信息,不同类的相同的符号引用都能定位唯一的地方,而字符串作为在堆里面的对象,假设不同类的有相同的字符串字面量,如果加载一个类就创建一个对象,是不是太失败了。

举个例子:

class ClassA {
    public static final String HELLO = "hello";
}

class ClassB {
    public static final String HELLO = "hello";
}

ClassA 和 ClassB 中对 java.lang.String 类的符号引用都会被解析为直接引用,这个直接引用指向同一个方法区中 String 类的元信息。

同样的,ClassA 和 ClassB 都有相同的字符串字面量 "hello",那怎么保证它们的运行时常量池中的对"hello"的引用指向的是堆中同一个字符串对象呢。

字符串常量池就是为了字符串的复用而存在的。

字符串常量池(String Constant Pool)是一个全局的、专门用于存储字符串字面量的内存区域。

当代码中出现字符串字面量时,JVM 首先会检查字符串常量池是否已有相同内容的字符串对象。若存在,运行时常量池就保存对这个已有对象的引用;若不存在,会在堆中创建一个新的字符串对象,并将其放入字符串常量池,同时运行时常量池保存对这个新对象的引用。

因为字符串常量池中的字符串是所有类共享的,所以就需要字符串是不可变的。如果字符串是可变的,那么当一个字符串在被多个类的运行时常量池中引用时,一个地方对字符串的修改就会影响到其他所有引用该字符串的地方,这会导致数据的不一致性。

public final class String
    implements java.io.Serializable, Comparable<String>,CharSequence,Constable, ConstantDesc {
    /**
     * The value is used for character storage.
     */
    @Stable
    private final byte[] value;

还有一点需要注意,字符串常量池本质上是一个固定大小的哈希表(StringTable),用于存储唯一的字符串对象引用,而且默认保存是字符串字面量对象的引用。

如:

String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2); // true

String s3 = new String("abc");
System.out.println(s1 == s3);  // false

String s4 = new String("abc");
System.out.println(s3 == s4);  // false

"abc" 作为字面量字符串,会在堆中创建一遍,其引用会保存在字符串常量池, s1s2 都是字面量字符串对象的引用, 所以它两是相等的。

  • String s1 = "abc";:JVM 检查字符串常量池中是否存在内容为 "abc" 的 String 对象;如果不存在,则在堆中创建 String 对象(如 String@123),并将其引用存入常量池;变量 a 指向 String@123

  • String s2 = "abc";:JVM 再次检查字符串常量池中是否存在内容为 "abc" 的 String 对象;由于 "abc" 已存在于常量池中(由 a 创建),直接返回 String@123 的引用;变量 b 也指向常量池中的 String@123

  • String s3 = new String("abc"):使用 new 关键字创建字符串时,无论常量池是否存在 "abc"new 关键字都会强制在堆中创建一个新的 String 对象;若池中已有 "abc",将其字符数组复制到新对象的 value 字段;若池中无 "abc",先在堆中创建 "abc" 对象并将其引用存入到池中,再复制字符数组。

那有什么方法可以让new String("abc")和字面量"abc"共享字符串对象在堆中的引用呢?

这就需要字符串驻留方法 intern()了。他可以将字符串对象显式地驻留到字符串常量池中,确保相同内容的字符串共享同一个对象。

  • 如果常量池中已存在内容相同的字符串,intern() 返回常量池中的引用。
  • 如果常量池中不存在内容相同的字符串,intern() 将当前字符串添加到常量池中,并返回其引用。
String s1 = "abc";
String s2 = new String("abc").intern();
System.out.println(s1 == s2); // true(s2 返回常量池中的引用)

这个方法很有用,在某些场景下,需要在不同的模块或线程之间共享字符串对象,使用 intern() 方法可以确保这些模块或线程使用的是同一个字符串对象。但也要注意的是StringTable大小有限,JDK 7+ 默认大小是 60013,如果驻留的字符串数量过多,可能导致哈希冲突增加,降低性能。

-XX:StringTableSize=<size> // -   哈希表的大小,必须是 **2 的幂**(如 1024、2048、4096 等)。

垃圾回收

前面说过方法区的回收条件严格,即便在 JDK 8 及之后版本使用的元空间的大小不设上限(受操作系统可用内存限制),但不需要的类信息该清理还是要清理的。

那满足什么条件会进行元空间的垃圾回收呢?类的回收条件比对象的回收条件苛刻得多,需要同时满足以下三个条件:

(1) 该类所有的实例都已经被回收,如果一个类的所有实例都被回收,且没有子类实例存在,则满足此条件。

(2) 加载该类的类加载器已经被回收,在大多数场景中,类加载器(如系统类加载器)的生命周期与 JVM 进程一致,很难被回收。

(3) 该类对应的 java.lang.Class 对象没有被引用,如果一个类的 Class 对象没有被静态变量、反射调用或其他地方引用,则满足此条件。

对象的回收是强制的(如果没有引用,就会被回收),而类的回收是 “被允许” 的,JVM 可以选择是否回收。也就是说即使满足上述三个条件,JVM 也不一定会立即回收该类,类的回收取决于具体的垃圾回收器和 JVM 实现。

JVM 栈 Stack

JVM 栈是 线程私有的,每个线程都会拥有独立的栈空间。JVM 栈的主要作用是存储每个线程执行过程中方法的局部变量、操作数栈和方法的返回地址。

Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread. A Java Virtual Machine stack stores frames . A Java Virtual Machine stack is analogous to the stack of a conventional language such as C: it holds local variables and partial results, and plays a part in method invocation and return.

与数据结构上的栈有着类似的含义,JVM 栈是一块先进后出的数据结构,只支持出栈和入栈两种操作。Java 虚拟机堆栈存储帧(Stack Frame),每个方法调用都会对应一个栈帧。

JVM 栈是与 Code指令 执行息息相关的,比如从局部变量表获取操作数、将计算结果压入操作数栈,下面先聊一聊栈帧的静态结构,后续《字节码指令集篇》将详细说明字节码执行的动态过程。

每个栈帧中包含以下内容:

局部变量表:存储方法的参数和局部变量。

操作数栈:用于存储操作数和计算结果。

当前类常量池引用:指向当前类在运行时常量池的引用。

返回地址:方法调用后返回的位置。

局部变量表

局部变量表(Local Variable Table),被视为一个线性数组,用于存储方法的参数局部变量

线性数组:数组的索引从 0 开始,每个元素是一个槽(Slot),一个槽占 4 字节,用于存储一个局部变量的值。

局部变量的类型:局部变量可以是8中基本类型(如 int, long, float, double)或引用类型(如对象的引用)。其中对于 longdouble 类型(64 位)的局部变量,要占用两个槽。

回忆《字节码文件结构》提到的方法表中 Code 属性中的局部变量表例子:

locals=3
LocalVariableTable:
    Start  Length  Slot  Name   Signature
        0       4     0  this   Lcom/cangoking/bytecodes/CodeDemo;
        0       4     1     a   I
        0       4     2     b   I

运行中栈帧的局部变量表就是根据编译期的 局部变量表 创建的,而且局部变量表的容量大小、每个Slot 该放什么类型,在编译期就确定了,且在方法调用过程中不会发生改变。

下面举个例子加深一下印象:

public void example(int a, double b) {
    int x = 5;
    double y = 10.0;
}

上述方法,有两个参数,两个局部变量,那的对应的局部变量表是不是会有 4 个 Slot 呢?

locals=7
LocalVariableTable:
Start  Length  Slot  Name   Signature
    0       9     0  this   Lcom/cangoking/bytecodes/LocalVariableTableDemo;
    0       9     1     a   I
    0       9     2     b   D
    3       6     4     x   I
    8       1     5     y   D

编译后发现有 7 个 Slot,其中 double 类型的 b 和 y 占用两个 Slot,还有一个隐性参数 this 占一个 Slot(静态方法则没有 this)。

正常来说,方法有几个参数,有几个局部变量就会根据类型开辟几个对应的 Slot,在某些情况下,Slot 也会复用的,比如在局部变量超出作用域后。

public void example() {
    int a = 10;    // a 使用槽 0
    {
        int b = 20;  // b 使用槽 1
    }
    int c = 30;    // c 复用槽 1
}

局部变量表(Local Variable Table) 还有几个特点,这里稍微提一下,应该都很好理解:

线程安全:每个线程在执行方法时,都会为该方法分配一个 独立的栈帧。栈帧中的局部变量表仅对当前线程可见并且不可被其他线程访问,因此并不会发生竞争条件或数据冲突。(但对于引用类型,虽然它们是线程私有的,但指向的对象或资源可能是共享的。)

生命周期:局部变量的生命周期仅限于方法的执行期间。在方法执行开始时,局部变量表被创建并初始化,而在方法执行完毕后,局部变量表会被销毁。对于基本类型的局部变量,它们的值会在方法结束时丢失,而引用类型的局部变量在满足回收条件时会被垃圾回收器处理。

垃圾回收:垃圾回收器主要回收的是 堆内存中的对象,而栈内存中的局部变量在方法结束时自动销毁,不需要垃圾回收。需要注意的是 引用类型局部变量 存储的是对堆中对象的引用,因此,局部变量本身在栈上销毁,但引用的对象可能依然存在于堆中,直到被垃圾回收器回收。

操作数栈

操作数栈(Operand Stack),也是一个先入后出的栈,用于存储执行字节码指令时的临时操作数或计算结果。在 JVM 中,几乎所有的指令都依赖于操作数栈进行计算和数据传输。

栈结构:操作数栈作为一个栈,也是 LIFO(Last In, First Out) 结构,对应两种操作,压栈(push)与出栈(pop),最后压入栈的数据会最先弹出。

栈中数据的类型:和局部变量表一样,它也存储着 基本数据类型引用类型 的值,而且因为 longdouble 占用两个槽,因此在压栈时,它们也会分别占用连续的两个个槽。

和局部变量表在编译期确定固定大小不同,在编译期只能确定栈的最大深度(max_stack),操作数栈在栈帧创建时为空,会在方法执行过程中动态调整其大小。

都说 Java 字节码的执行过程是基于栈的,这个栈就是 操作数栈 所有的字节码指令操作都是通过操作数栈进行的,如果说 JVM 栈是方法栈,那操作数栈就是指令栈,栈上有栈。

对于操作数栈的示例,放到《字节码指令集》中再介绍。这里先介绍一个与操作数栈关联的知识点——栈顶缓存。

栈顶缓存(Top-of-Stack Caching, ToS)的核心思想就是 将栈顶或栈顶附近的元素缓存到CPU寄存器 中,而不是每次都从内存中读取。这种技术的目标是减少从内存中读取数据的频率,从而加速计算过程。

如果栈顶或栈顶附近的元素需要被频繁访问,将这个元素放到寄存器就是一个不错的选择,比如 for 循环中(for (int i = 0; i < n; i++)),循环体的变量(in) 被频繁访问,可能将其缓存到寄存器会好一点。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证

当前类常量池引用

JVM 栈是与 Code指令 执行息息相关的,但字节码指令中关于类或方法的描述都是符号引用,为了找到这些符号引用代表的具体内存地址,就要在该类所属的运行时常量池中寻找对应的直接引用。

当前类常量池引用(Current Class Constant Pool Reference)就是字面意思,指向当前方法所属类在运行时常量池的引用。

事实上,运行时常量池中的符合引用并不完全是在类加载时就解析成直接引用了,有时会进行懒解析,就是用到的时候再解析,这也就是所谓的动态链接(Dynamic Linking)。

静态链接:静态链接也称为早期绑定,是指在编译期或者类加载阶段就能够确定要调用的方法、访问的字段或使用的类,并将对应的符号引用转换为直接引用。在这种情况下,被调用的目标在编译时就已经明确,运行期不会发生改变。

比如,对于 final 方法、static 方法、private 方法等,由于它们在编译期就可以确定调用的目标,因此采用静态链接。

动态链接:动态链接也叫晚期绑定,是指在编译期无法确定要调用的方法、访问的字段或使用的类,必须在程序运行时才能将符号引用转换为直接引用。这种引用转换过程具有动态性,会根据对象的实际类型来确定具体的调用目标。

当使用父类引用指向子类对象,调用子类重写的方法时,就会采用动态链接。

注意区分链接绑定,链接是将符号引用解析成直接引用,而绑定是根据对象的实际类型来确定要调用的方法,后续《字节码指令集篇》再进行静态绑定、动态绑定、虚方法、和非虚方法的介绍。

返回地址

要了解“返回地址”,必须要知道程序计数器

Each Java Virtual Machine thread has its own pc (program counter) register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method for that thread. If that method is not native, the pc register contains the address of the Java Virtual Machine instruction currently being executed.

简单来说,每个线程都有一个私有的程序计数器,记录当前线程正在执行的字节码指令的地址。

  • 当执行本地方法(native)时,程序计数器的值为空(Undefined)。
  • 对于 Java 方法,程序计数器会记录下一条要执行的字节码指令的地址,以此来控制程序的执行流程。

每个栈帧中都包含一个方法返回地址,它存储的是调用该方法的程序计数器的值。当一个方法被调用时,调用者的程序计数器的值会被保存到被调用方法栈帧的方法返回地址中。这样,当被调用方法执行完毕后,就可以根据这个返回地址返回到调用者方法中继续执行后续的指令。

栈的生命周期

栈帧在方法调用时被压入栈中,当方法执行完毕后,栈帧会被弹出,销毁方法调用时的相关数据。

每次方法被调用时,JVM 都会为该方法创建一个栈帧,并将栈帧压入当前线程的栈中。栈帧中保存了方法的局部变量、操作数栈、返回地址等数据,方法执行完毕后,栈帧会被销毁。

public class StackExample {
    public static void main(String[] args) {
        int a = 5;
        int b = 10;
        int result = add(a, b);
        System.out.println(result);
    }

    public static int add(int x, int y) {
        return x + y;
    }
}

  • 程序执行 main 方法时,JVM 会为 main 方法创建一个栈帧,栈帧中包含 abresult 等局部变量。

  • 当调用 add(a, b) 方法时,JVM 会为 add 方法创建另一个栈帧,栈帧中保存 xy 这两个参数。

  • add 方法执行完后,栈帧会被弹出,控制返回到 main 方法,继续执行后续的代码。 栈的大小会影响程序的执行性能,默认情况下,JVM 会为每个线程分配一定的栈空间。如果线程需要的栈空间超过了分配的空间,就会导致栈溢出错误(StackOverflowError

-Xss<size> 设置每个线程的栈空间大小,等效于 非稳定参数选项 -XX:ThreadStackSize=<size>

总结

到此为止,关于堆、方法区、栈的大致结构和作用都介绍完了,当然运行时数据区还包括用于执行本地方法(Native Method)的本地方法栈(Native Method Stack),这里就不介绍了。

简单总结一下,方法区用于存储类的元数据,堆是对象创建的地方,JVM 栈是执行方法用的。

特性方法区(Method Area)堆(Heap)栈(Stack)
存储内容类元数据(类名、方法字节码、运行时常量池、静态变量等)。对象实例、数组。局部变量、操作数栈、当前类常量池引用、方法返回地址等。
线程共享性所有线程共享。所有线程共享。线程私有,每个线程独立。
内存分配方式JVM 启动时分配,可通过参数调整大小(如 -XX:MaxMetaspaceSize)。动态分配,通过 -Xmx 和 -Xms 控制大小。固定大小(通过 -Xss 设置),每个栈帧按需分配。
生命周期类加载时分配,类卸载时回收(条件苛刻)。对象创建时分配,垃圾回收时回收。线程创建时分配,线程结束时回收;栈帧随方法调用创建和销毁。
内存溢出异常OutOfMemoryError: Metaspace(JDK8+)。OutOfMemoryError: Java heap spaceStackOverflowError(栈溢出)或 OutOfMemoryError(无法扩展栈)。
垃圾回收类卸载时回收元数据(条件苛刻)。由垃圾回收器(GC)自动管理。无垃圾回收,栈帧随方法结束自动释放。