1. 概述
JVM 把内存进行了划分,不同的内存区域有不同的功能。有的内存区域是线程私有的,比如 Java 虚拟机栈、本地方法栈和程序计数器,每一条线程都有自己独立的空间。有的内存区域是线程共享的,比如方法区和堆。
所以不同内存区域的功能、作用域和生命周期是不同的。本文做一个详细的分析。
根据 JVM 虚拟机规范,内存结构如下:
JVM 虚拟机规范属于概念模型,具体的实现各个厂商的会有所差异。比如方法区的设计,hotspot 在 1.7 之前使用永久代,1.7 后使用元空间。
本文主要分析 HotSpot 虚拟机的实现。
2. 程序计数器
JVM 支持多线程,采用时间片轮转的方式实现多线程并发。一个内核每一刻只能有一个线程执行,多线程下需要线程上下文切换。为了确保切换过程中,不同的线程指令和数据不会发生混乱,需要单独开辟内存空间给每个线程,进行线程隔离。这些区域包含了程序计数器、虚拟机栈、本地方法栈。这些都是线程私有内存,生命周期和线程一致。
如果执行的不是本地方法,程序计数器记录当前线程执行的指令地址,字节码解释器通过改变该计数器的值,来决定选取下一个要执行的指令。如果执行的是本地方法,值为空(undefined)。
程序计数器的内存空间非常小,是 JVM 规定的唯一不会发生内存溢出(Out Of Memory)的区域。
3. Java 虚拟机栈
Java 虚拟机栈由栈帧组成,Java 虚拟机栈和其他常规语言的栈类似,存储本地变量或部分计算结果,处理方法的调用和返回。虚拟机栈内容不能进行直接操作,只能用来进行栈帧的入栈和出栈。方法的调用到执行完成对应的就是栈帧的入栈和出栈过程。
Java 虚拟机栈的生命周期和线程对应,在线程创建的同时创建,和程序计数器一样都是线程私有内存区域。
Java 虚拟机规范对虚拟机栈大小有这样的描述:
- 可以使用固定大小或者动态扩展和收缩。如果是固定大小,空间大小在栈创建的时候就会确定下来。
- 可以配置 Java 虚拟机栈的初始大小。
- 如果栈空间可以动态扩展或者收缩,可以配置栈的最大值和最小值。
HotSpot 虚拟机栈的配置:
- -Xss,设置虚拟机栈大小,JDK1.5 之后默认为 1M。栈深度受到这个堆栈大小的约束。在固定物理内存下减小 Java 虚拟机栈大小可以产生更多线程,但是一个进程的线程数量有约束,不能无限增加。
Java 虚拟机栈可能会发生的异常有:
- 如果线程请求需要的栈深度大于 JVM 限定的,会发生
StackOverflowError
异常。 - 如果 JVM 大小可以动态扩展,在扩展的时候内存不足,或者在创建新线程时内存不够创建虚拟机栈,均会发生
OutOfMemoryError
异常。
3.1. 栈深度
方法的从调用到执行完成,对应了虚拟机栈的入栈到出栈的过程。
在编译期就可以确认局部变量表的大小和操作数栈的深度,并且写入到方法表的 code 属性中,运行期间不会发生改变。所以在编译器每个栈帧的需要大小就可以确定了。栈深度由运行期决定。
具体的栈深度受虚拟机栈大小和栈帧大小的影响,要看使用了多少栈帧,栈帧大小多少。每个栈帧的大小不一定一样,取决于各栈帧对应方法的局部变量表和操作数栈大小等。
假设我们的虚拟机栈大小固定,栈帧数量达到最大值,也就是达到最大深度,深度大小和栈帧大小的示意图如下:
上面的示意图可以看出,在 Java 虚拟机栈大小固定的情况下,如果每个栈帧都很大,最大可用深度就会变小。
上面只是一个示意图,实际上虚拟机栈深度没这么小。默认情况下 Java 虚拟机栈有 1M,平时开发时的栈帧也不会很大。
当线程请求的栈深度大于虚拟机的所允许的栈深度会发生 StackOverflowError
异常。毕竟如果一个线程不断地往虚拟机栈中加入栈帧,会消耗掉大量的内存,影响到其他线程的执行。
比如写了一个递归方法,没有设置退出条件,当要超过该线程的虚拟机栈达到最大深度会发生异常。
3.2. 栈帧
栈帧用来存储方法执行需要用到的数据。同时还可以执行动态链接,返回值给方法,分发异常。所以一个栈帧一般会划分成以下几个区域:局部变量表、操作数栈、动态链接、方法出口。
栈帧的生命周期和方法对应,在方法调用的时候就会创建新的栈帧,当方法执行结束时栈帧销毁栈帧。即使是因为未捕获异常退出方法,栈帧也会被销毁。栈帧的内存由 JVM 虚拟机栈分配。每个栈帧有自己独立的局部变量表、操作数栈、指向运行时常量池的引用。
栈帧的内容可扩展,比如加入调试信息。
在编译期就可以根据栈帧对应的方法代码,确定局部变量表和操作数栈的大小。栈帧的具体大小依赖于 JVM 虚拟机的实现。编译期决定了大小,方法被调用时分配内存。
线程在同一时刻只会处理一个栈帧,被称为当前帧,位于 Java 虚拟栈的栈顶。该帧对应的方法被称为当前方法,定义该方法的类被称为当前类。方法的执行会操作当前帧的局部变量表和操作数栈。
调用新方法时,当前帧暂停,新的栈帧加入到虚拟机栈的栈顶并成为新的当前帧,开始处理新方法。当方法结束调用,当前帧出栈,返回处理结果,回到上一个栈帧,上一个栈帧成为当前帧,继续操作局部变量表和操作数栈。
栈帧属于当前线程私有,不会被其他线程引用到。
3.2.1. 局部变量表
每一个栈帧都会有一个局部变量表,大小在编译期就决定,用来记录方法执行需要用到的请求参数、局部变量,如果不是静态方法的话,还会存储 this
指针来表示当前对象实例。
局部变量的存储基本单位为 变量槽(Variable Slot)。单个 Slot 可以存储 boolean,byte,char,short,int,float,reference 或者 returnAddress。两个 Slot 可以存储 long 和 double。虚拟机规范没有对 Slot 的物理内存大小做出明确规定,可以随着处理器、操作系统和虚拟机的不同而变化。但因为 int、float 等都可以用 32 位的物理内存存放,所以一个 Slot 的物理内存必须大于 32 位。
局部变量表采用 索引 进行寻址。第一个局部变量的索引为 0。在实例方法中,始终使用局部变量 0 用来表示当前对象实例,在 Java 中就是 this 指针。所以实例方法的局部变量的索引总是从 1 开始。
long 和 double 比较特殊,需要使用两个连续的 Slot 存储。这样会占用两个索引,取值小的那个。比如一个 double 存入局部变量表,它的索引值是 n,其实占用了 n 和 n+1 两个索引,而 n+1索引是无法加载的。下一个局部变量的索引为 n+2。虚拟机规范并没有要求 n 一定是偶数,所以在在局部变量表中 long 和 double 并不一定是要 64 位对齐的。不同 JVM 的实现,可以选择合适的方式实现两个局部变量存储 long 和 double。
这里做个实验,创建一个空方法,请求参数包含所有基础数据类型和一个 String 引用类型,方法内有一个 String 局部变量。
public void show(boolean a, byte b, char c, short d, int e, long f, float h, double i, String j) {
String str = "str";
}
使用 javap -v
查看 show
方法在 class 文件中的局部变量表。
public void show(boolean, byte, char, short, int, long, float, double, java.lang.String);
descriptor: (ZBCSIJFDLjava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=1, locals=13, args_size=10
0: ldc #2 // String str
2: astore 12
4: return
LineNumberTable:
line 14: 0
line 15: 4
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Loblee/demo/jvm/stack/SimpleObject;
0 5 1 a Z
0 5 2 b B
0 5 3 c C
0 5 4 d S
0 5 5 e I
0 5 6 f J
0 5 8 h F
0 5 9 i D
0 5 11 j Ljava/lang/String;
4 1 12 str Ljava/lang/String;
这个方法的为局部变量表 LocalVariableTable
,类加载后会作为方法的元数据存储到方法区,然后方法被调用的时候载入到新创建的栈帧中。
可以看到编译期已经确认了表中每个局部变量的索引和大小。局部变量表的大小已经写入到 Code
属性: locals=13
。
这 13 个基本单位是如何计算出来的?我们上面的案例,所有方法参数一共需要的基本单位数 1 + 1 + 1 + 1 + 1 + 2 + 1 + 2 + 1 = 11
,一个局部变量 str 占用 1 个 Slot,有 12 个基本单位了。还有一个 Slot 呢?
这个是实例方法,加入了 this 指针用来表示当前对象实例的引用,在 Slot 0 中:
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Loblee/demo/jvm/stack/SimpleObject;
this 指针占用 1 个 Slot,所以局部变量表总体大小为 13 个 Slot。
因为 this 指针是通过参数默认传递给方法的,应该归到方法参数中,所以实际该方法有 10 个参数,也写入到了 code 属性:args_size=10
。
从反编译的局部变量表还可以看到索引的设计,show
中参数 f 为 long 类型,索引到 Slot 6,因为占用两个 Slot,下一个变量 h 索引到 Slot 8。
JVM 对局部变量表进行了优化,变量槽 Slot 是可以复用的。
如果是静态方法的话就不存在 this 引用了。比如我们创建一个静态方法 staticShow
:
public static void staticShow(boolean a, byte b, char c) {
String str = "str";
}
使用 javap -v
查看局部变量表如下:
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 a Z
0 8 1 b B
0 8 2 c C
3 5 3 str1 Ljava/lang/String;
7 1 4 str2 Ljava/lang/String;
3.2.2. 操作数栈
每一个栈帧都有一个后进先出(LIFO)的操作数栈。操作数栈应用于字节码执行引擎中,JVM 描述字节码执行引擎是基于 “栈” 的,指的就是操作数栈。
操作数栈的每个条目可以保存 JVM 任何类型的值,long 和 double 占据深度的两个单位,其他类型占据一个单位。操作数栈的最大深度由编译期通过方法要执行的字节码计算出来,并记录在 Code 属性中。
栈帧刚创建时,操作数栈为空。JVM 提供了一系列字节码指令,将数据从局部变量表加载到操作数栈中。还有一些指令,从操作数栈中读取操作数,进行处理,然后把结果入栈。操作数栈还可以用来准备参数传递给方法,或者接收方法返回结果。比如,指令 iadd
用来对两个 int 值进行相加。之前的指令已经将两个 int 值压入到操作数栈中了,iadd
将两个 int 值出栈,相加后将和入栈。
操作数栈中的数据,必须用合适的类型的字节码指令进行操作。比如入栈两个 int 值,不能当做 long 处理。入栈 float 不能使用 iadd
指令进行相加。有少量的 JVM 指令不关心值的类型,这些指令无法修改值。在类加载流程中,类文件的校验阶段,会强制实施。
设计了一个 calculate
方法来做一些加减法计算:
public int calculate(int a, int b) {
int c = a + b;
int d = a - b;
int e = c + d;
return e;
}
反编译得到:
public int calculate(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=6, args_size=3
0: iload_1
1: iload_2
2: iadd
3: istore_3
4: iload_1
5: iload_2
6: isub
7: istore 4
9: iload_3
10: iload 4
12: iadd
13: istore 5
15: iload 5
17: ireturn
可以看到操作数栈深度最大为 2,本地变量表大小 6 个 Slot(索引 0 - 5)。这些字节码的解读如下:
0: iload_1 加载 Slot 1(从局部变量表加载,1 表示索引)。实际为从局部变量表加载 a。
1: iload_2 加载 Slot 2。实际为从局部变量表加载 a。
2: iadd 执行加法。实际为 a + b。
3: istore_3 存储计算结果到 Slot 3。实际为存储 c 到局部变量表。
4: iload_1 加载 Slot 1。实际为从局部变量表加载 a。
5: iload_2 加载 Slot 2。实际为从局部变量表加载 b。
6: isub 执行减法。实际为 a - b。
7: istore 4 存储计算结果到 Slot 4。实际为存储 d 到局部变量表。
9: iload_3 加载 Slot 3。实际为从局部变量表加载 c。
10: iload 4 加载 Slot 4。实际为从局部变量表加载 d。
12: iadd 执行加法。实际为 c + d。
13: istore 5 存储计算结果到 Slot 5。实际为存储 e 到局部变量表。
15: iload 5 加载 Slot 5 的数据。实际为从局部变量表加载 e。
17: ireturn 返回计算结果
我们传入 a = 1, b = 2
进行计算 calculate(1, 2)
,第一个加法操作操作数栈的变化如下:
这里的代码是可以优化的,因为局部变量 e 没有做其他计算,可以直接返回。如果直接返回结果会有什么效果?代码如下:
public int calculate(int a, int b) {
int c = a + b;
int d = a - b;
return c + d;
}
查看字节码如下:
public int calculate(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=5, args_size=3
0: iload_1
1: iload_2
2: iadd
3: istore_3
4: iload_1
5: iload_2
6: isub
7: istore 4
9: iload_3
10: iload 4
12: iadd
13: ireturn
局部变量表少了一个 Slot,也就是原本 e 的存储空间。要执行的字节码指令也少了 3 条。所以平时开发过程中要注意优化,可以提高性能。
3.2.3. 动态链接
每一个帧都包含了一个指向运行时常量池的引用,用来实现字节码中的 动态链接(Dynamic Linking)。类文件中包含了一些字段和方法的符号引用。动态链接会将这些符号引用转换成直接引用,比如在内存中的具体偏移地址。
如果对应的类还没有被加载,会触发该类的加载流程。
符号引用记录在类常量池中,是一个由字面量组成的字符串,和具体地址无关。比如所有对象的类构造方法的符号引用为 java/lang/Object."<init>":()V
。编译并不知道运行时的地址,所以用符号引用代替。
动态链接又称动态绑定。除了该方式,还有种发生在类文件加载过程中,这个这个阶段就把符号引用转换为直接引用,这样的方式为饥饿方式或者静态绑定。
静态绑定和动态绑定都可以归为是类加载机制中的 解析(Resolution) 的一部分。
可以看出类加载机制中的环节是有可能交叉进行的。比如解析可能发生在准备阶段后,静态绑定。也可能延迟到初始化后,在栈帧创建后进行动态绑定。
绑定只发生一次,绑定后不再更改。
3.2.4. 方法正常结束
方法调用结束,没有发生异常。这里指直接返回结果或者是显式调用 throw 抛出异常。
被调用方法的结果需要传递给调用者方法。被调用的方法会执行和方法返回相关的指令,这些指令和返回值的类型对应。
当前栈会被复原为调用者方法的执行状态,包括局部变量表和操作数栈的数据,程序计数器会跳过刚刚调用方法的指令指向下一条。被调用方法的返回值被加入到操作数栈中,程序继续运行。
3.2.5. 方法异常结束
方法内部发生了异常,而且没有被捕获,方法会被终止,并且没有返回值给调用者。
4. 堆
堆由 JVM 所有的线程共享,一般情况下是 JVM 内存区域中最大的一块。按照 JVM 虚拟机规范,堆是一个用来存储类对象实例或者数组的运行时数据区。
在 HopSpot 上,类对象实例不一定就是放在堆中,应用了 JIT(Just-In-Time) 技术,进行逃逸分析(Escape Analysis)和标量替换(Scalar Replacement)。符合条件的对象实例会在栈上分配。
JVM 启动的时候堆就会创建。堆内对象实例不会显式释放,由自动内存管理系统,也就是垃圾收集器进行回收,是垃圾收集器主要管理区域。JVM 规范没有说明垃圾收集器应该是怎样的,具体由实现由 JVM 厂商来提供。
比如 HotSpot 虚拟机中,垃圾回收器采用分代回收算法,会将堆进行进一步细分,分为新生代和老生代。新生代还可细分为 Eden 、From Survivor 和 To Survivor。这实际上是为了能够更好地服务于垃圾回收。HotSpot 在 JDK 1.7 中堆还有一个永久代,其实是 JVM 规范中方法区的实现,在 JDK1.8 移除。
HotSpot 的 JDK 1.7 堆图示:
HopSpot 的 JDK 1.8 堆图示,永久代(PermGen)被移除,使用元空间(Metaspace)存储类信息。
新生代和老年代的内存分配流程:
- 优先 Eden 分配,Eden 空间不足会触发 Minor GC。
- Minor GC 后,Eden + S0 还存活的对象移动到 S1 中,清空 S0。
- S1 放不下,存活次数达到要求的对象移动到老年代。
- 大对象直接分配到老年代。
- 老年代内存不足会发生 Major GC
- 进行垃圾回收后,Eden 仍然没有足够的空间,抛出
OutOfMemory
异常。
Java 虚拟机规范对堆大小有这样的描述:
- 可以是固定大小,也可以动态的扩展和收缩。
- 堆的内存不一定要连续。(逻辑上连续)
- 可以配置本地方法栈初始大小,如果可动态扩展和收缩,可配置最大值和最小值。
主流虚拟机都是采用可动态扩展和收缩的方式实现的。堆内存物理上可以不连续,但是逻辑上需要连续。
HotPot 虚拟机的堆内存配置:
-Xms,初始大小,默认物理内存的 1/64。
-Xmx,最大内存,默认物理内存的 1/4。
-Xmn,新生代大小,因为持久代的大小一般默认为 64M,在整个堆固定的情况下,增大新生代会相应地减少老年代的大小。官方推荐
-XX:NewSize,新生代最小空间大小。
-XX:MaxNewSize,新生代最大空间大小。
-XX:NewRatio,新生代和老年代的比例,新生代和老年代的默认比例为 1:2。
-XX:SurvivorRatio,Eden 和 Survivor 的比例,默认为 Eden:S0:S1 = 8:1:1,即 survivor = 1/10 新生代大小。
HotSpot 采用的就是动态扩展和收缩的方式,根据堆的空闲情况,当空闲大于 70%,会减少至 -Xms;空闲小于 40%,会增大到 -Xmx。所以服务器如果配置 -Xms = -Xmx,可以避免堆自动扩展。
堆会发生的异常:
- 如果程序请求的堆内存大于 JVM 内存管理系统能提供的最大值,会抛出
OutOfMemoryError
异常。
5. 方法区
方法区由 JVM 所有线程共享。方法区类似一个用来存储编译后的代码的区域。主要用来存储加载的类信息,运行时常量池,类和方法的数据,即时编译后的代码等。
JVM 启动的时候方法区就会创建。
根据 JVM 虚拟机规范,方法区逻辑上是堆的一部分,实现上可以选择不进行垃圾回收,并且没有要求方法区的位置等。所以在方法区的具体实现各个虚拟机又不同的方式。虽然 JVM 虚拟机规范把方法区逻辑上划给了堆,为了和实际堆进行了区分,方法区还叫做 “非堆”。
Java 虚拟机规范对方法区大小的描述:
- 可以是固定大小,也可以动态的扩展和收缩。
- 方法区的内存不一定要连续。
- 用户或者开发者能够配置方法区初始大小,如果方法区可以动态扩展或收缩,需要提供方法区的最大值和最小值。
HotSpot 在 JDK1.7 中方法区内存大小配置:
-XX:PermSize,最小可分配空间,初始分配空间。
-XX:MaxPermSize,最大可分配空间,默认大小为 64M(64 位 JVM 默认为 85M)
在 JDK1.8 使用了元空间后,方法区的大小配置:
- -XX:MetaspaceSize,初始空间大小。
- -XX:MaxMetaspaceSize,最大空间大小,默认是没有限制的。
方法区可能发生的异常:
- 如果方法区请求的内存无法被满足,抛出
OutOfMemoryError
异常。
5.1. 去永久代过程
HotSpot 虚拟机在 JDK1.7 采用永久代,在堆中分配内存。在 JDK1.8 后使用元空间,使用本地内存。
从 JDK1.7 开始 “去永久代”,JDK 1.7 将静态变量、字符串常量池移动到堆内存中,JDK1.8 去掉永久代,将类信息、即时编译后的代码等移动到了元空间。
之所以要进行去永久代,主要还是该方案存在很多问题,留下很多 bug。主要有:
- 字符串存在永久代,容易发生内存溢出。
- 类信息比较难确定大小,永久代的大小难以指定,太小永久代容易 OOM,太大老年代容易 OOM。
- 永久代 GC 回收复杂,效率低。
6. 运行时常量池
运行时常量池是 class 文件的常量池在运行时的表示。主要有字面量和符号引用。
要理解运行时常量池,我们得先了解 class 的常量池。
创建类 ObjectA 和 Object B,其中 ObjectA 如下:
public class ObjectA {
private ObjectB b;
public void setB(ObjectB b) {
this.b = b;
}
public ObjectB getB() {
return b;
}
}
编译后使用 javap -v
查看 class 文件中的常量池如下。
运行时,在进行类加载时,类常量池会被载入到 JVM 方法区。
JVM 虚拟机规范没有约束运行时常量池只能放编译期的常量,虚拟机的实现可以自行支持。比如 HotSpot 虚拟机, Java 调用 String.intern()
方法,可以在运行期把常量加入池中。
在 HotSpot JDK 1.7 之后,对常量池进行了优化:字符串常量池被放在了 JVM 堆中,运行时常量池的字面量也存在 JVM 堆中,而符号引用被移动到了本地内存。
以下的异常可能会发生:
- 当创建一个 class 或者 interface 时,如果运行时常量池构造需要的内存超过 JVM 所能提供的,抛出
OutOfMemoryError
异常。
7. 本地方法栈
JVM 的实现可能需要使用 "C 栈" 去支持本地方法调用。有可能使用 C 之类的语言,实现 JVM 指令的解释器,也会使用到本地方法栈。本地方法栈和 Java 虚拟机栈类似,只是这里提供的是本地方法服务。虚拟机规范没有明确指出本地方法栈使用什么语言、数据结构等,不同厂商的虚拟机又不同的实现。比如 HotSpot 虚拟机把本地方法栈和 Java 虚拟机栈合并了。
本地方法栈的生命周期线程对应,线程创建的时候创建。如果 JVM 不需要调用本地方法,可以不需要本地方法栈。
JVM 规范对本地方法栈大小的描述
- 可以使用固定大小,或者动态扩展和收缩。如果是固定大小,当栈被创建的时候能够独立选择。
- 可以配置本地方法栈初始大小,如果可动态扩展和收缩,可配置最大值和最小值。
以下异常可能发生:
- 如果线程请求的栈深度大于系统规定的,报
StackOverflowError
。 - 如果本地方法栈可以动态扩展,没有足够的内存扩展。或者创建新的线程没有足够的内存创建本地方法栈,抛出
OutOfMemoryError
异常。
8. 参考资料
- Java Language and Virtual Machine Specifications
- 深入理解 Java 虚拟机(周志明)