深入探索Java虚拟机运行时数据区域

‘Java虚拟机规范’把Java虚拟机的运行时数据区域划分为线程隔离的程序计数器, 虚拟机栈, 本地方法栈和线程共享的方法区, . 然而, ‘Java虚拟机规范’所描述的是Java虚拟机的概念模型(代表所有Java虚拟机的统一外观), 并未规定上述5个运行时数据区域的具体实现细节, 因此各款具体的Java虚拟机可能会用各种平台相关的, 更高效的方式进行等价的实现. Java虚拟机需要实现自动内存管理(主要是堆和方法区的内存管理), 势必引入垃圾收集器, 而内存的布局与管理又与所选用的垃圾收集器息息相关, 所以即使是同一种虚拟机, 使用不同垃圾收集器时其运行时数据区域(主要指堆和方法区)的实现都可能存在差别. 本文着重讲述Java虚拟机运行时数据区域的概念模型, 在一些重点部分加入了对HotSpot虚拟机具体实现的探讨, 特别总结了HotSpot虚拟机所实现的各个垃圾收集器的原理及其对应的自动内存管理方法.

除讲述Java虚拟机各个运行时数据区域的功能, 原理及实现细节外, 更重要的是, 本文将深入思考诸如, 为什么要设计这些区域? 为什么这个区域要这么实现? 是不是必须这么实现? 等问题. 例如, 我们都知道在’Java虚拟机规范中’规定程序计数器是线程私有的, 那么为什么要规定它是线程私有的? 它是不是必须是线程私有的? 有没有可能将其实现为线程共享的?

线程隔离区域

‘Java虚拟机规范’ 中所规定的线程隔离的运行时数据区域包括程序计数器, 虚拟机栈和本地方法栈三部分. 然而, 在HotSpot虚拟机中虚拟机栈和本地方法栈被合二为一, 因此本文也将这两个部分合并讲述.

程序计数器

程序计数器是Java虚拟机中最简单的部分, 实际上是一块较小的内存空间, 用于存储当前线程正在执行的字节码指令地址. 字节码解释器通过改变程序计数器的值来指示线程所需要执行的字节码指令. 程序的分支, 循环, 跳转, 异常处理, 线程恢复等基础功能都依赖程序计数器来完成.

这里的程序计数器是在抽象的JVM层上的字节码程序计数器, 而不是CPU上的程序计数器. 若线程正在执行的是一个Java方法, 那么程序计数器记录的是正在执行的虚拟机字节码指令地址, 这个字节码指令地址可能(‘可能’是相对于概念模型而言的, 每款虚拟机的实现可能不同)是对应字节码在方法中的偏移量, 也可能是该字节码指令在虚拟内存中的位置. 若线程正在执行的是一个本地方法, 那么程序计数器为空, 这是由于本地方法由操作系统直接执行, 并不需要JVM层面的程序计数器来指示需要执行的指令, 而CPU层面的程序计数器会进行相应的工作.

值的注意的是, 当线程执行已经由JIT编译器编译的Java方法时, 由于字节码指令已经被编译为机器指令, 因此程序计数器同执行本地方法时一样为空.

在 ‘Java虚拟机规范’ 中, 程序计数器是唯一一个没有规定内存溢出异常的运行时数据区域, 在HotSpot虚拟机中此内存区域也不存在任何内存溢出异常. 这是由于字节码指令地址的大小是固定的, 在JVM启动时即可分配足够的内存空间, 保证其不会溢出.

尽管’Java虚拟机规范’中把程序计数器规定为线程私有的, 但这是否必须取决于Java虚拟机对Java线程调度的实现. 如果由Java虚拟机负责Java线程的调度, 那么程序计数器并不一定需要线程私有, 也可以仅使用一个线程共享的全局程序计数器, 在线程切换时Java虚拟机势必通过一定的数据结构保存当前线程的运行状态, 若同时保存全局程序计数器的值, 即可在线程再次运行时恢复程序计数器的值. 如果Java虚拟机将Java线程的调度交由操作系统, 那么程序计数器必须线程私有, 这是由于操作系统在线程调度时并不会记录当前Java线程所运行的虚拟机字节码指令地址, 因此必须由线程自身记录其指令地址. HotSpot虚拟机在主流平台上的实现都由操作系统调度Java线程, 因此对于在这些平台上运行的HotSpot虚拟机, 程序计数器必须是线程私有的.

虚拟机栈和本地方法栈

‘Java虚拟机规范’所描述的虚拟机栈和本地方法栈的功能和原理十分相似, 主要的区别是虚拟机栈为虚拟机执行Java方法服务, 而本地方法栈为虚拟机执行本地方法服务. 因此, 在HotSpot虚拟机中, 虚拟机栈和本地方法栈被合二为一. 本文也将一并讲述虚拟机栈和本地方法栈, 如无特殊说明后文说到的虚拟机栈包括’Java虚拟机规范’中所描述的虚拟机栈和本地方法栈.

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元, 每一个方法从被调用到执行完毕的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程. 在线程执行时, 每遇到一次方法调用, 虚拟机都会创建一个与调用方法所对应的栈帧, 并将其压入虚拟机栈. 虚拟机栈栈顶的栈帧对应着当前线程正在执行的方法. 栈帧是虚拟机栈的基本元素, 因此有必要进行深入了解.

每一个栈帧都包含局部变量表, 操作数栈, 动态连接, 方法返回地址附加信息. 其中动态连接, 方法返回地址, 附加信息一般是静态的属性, 称为栈帧信息. 栈帧中各个部分的具体作用及原理如下.

  • 局部变量表: 局部变量表是一组值的存储空间, 用于存放方法参数和方法内部定义的局部变量. 局部变量表的容量以变量槽为最小单位, ‘Java虚拟机规范’并没有明确规定一个变量槽实际占用的内存空间大小, 只是说明了每个变量槽都应当能够存放一个boolean, byte, char, short, int, float, reference或returnAddress类型的数据. 在64位的HotSpot虚拟机中long和double类型占用两个变量槽, 而reference类型只占用一个变量槽.
  • 操作数栈: 操作数栈的元素可以是任意类型, 32位数据类型占用的栈容量为1, 64位数据类型占用的栈容量为2. 在一个方法正式开始执行之前其对应的操作数栈是空的, 在方法执行过程中会有各种字节码指令往操作数栈中写入或提取内容, 即入栈和出栈操作. 以整数加法为例, 字节码指令iadd将提取操作数栈栈顶的两个元素进行相加, 并将结果重新压入操作数栈.
  • 动态连接: 每个栈帧都包含一个指向运行时常量池(见方法区)中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接.
  • 方法返回地址: 程序计数器中记录了当前方法正在运行的字节码指令地址, 当正在运行的方法(称主调方法)调用了另一个方法(称被调方法)时, 程序计数器将转而记录被调方法所正在执行的字节码指令地址, 而主调方法所执行到的字节码指令地址将被覆盖. 因此, 主调方法必须将当前程序计数器的值记录到栈帧中, 以便当被调方法成功返回后继续执行主调方法的剩余指令. 这个被记录的程序计数器的值即为方法返回地址.
  • 附加信息: ‘Java虚拟机规范’允许虚拟机实现增加额外的描述信息, 例如与调试, 性能收集相关的信息. 各款虚拟机的附加信息各有不同.

实际上, 局部变量表和操作数栈的最大容量在使用javac编译时就已经确定, 结果会写入所生成的.class文件中. 以如下代码为例(除特殊说明外, 本文使用JDK 8进行实验).

1
2
3
4
5
public class Test {
public int test(int a, long b) {
return a + 1;
}
}

上述代码定义了一个简单的Test类, 该类包含一个实例方法test. 根据上述原理可以推断, 在64位的HotSpot虚拟机中test方法的局部变量表有4个变量槽(每个实例方法都包含一个隐形的参数this, test方法共有3个参数, 其中参数thisa占一个变量槽, 参数b占两个变量槽, 共占4个变量槽), 其操作数栈的最大栈容量为2(执行a + 1操作时需要将变量a1压入操作数栈, 所需栈容量为2). 我们可以用javap -verbose Test.class命令进行验证, 执行javap命令后可以看到如下信息(仅截取了与test方法相关的部分).

1
2
3
4
5
6
7
8
9
10
11
public int test(int, long);
descriptor: (IJ)I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=3
0: iload_1
1: iconst_1
2: iadd
3: ireturn
LineNumberTable:
line 5: 0

其中, Code属性内的stack表示操作数栈的最大容量, locals表示局部变量表所占用的变量槽个数, args_size表示方法的参数个数.

内存溢出异常

‘Java虚拟机规范’对虚拟机栈和本地方法栈定义了两类异常: 如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出StackOverflowError异常; 如果Java虚拟机栈容量可以动态扩展, 当栈扩展无法申请到足够的内存时将抛出OutOfMemoryError异常. ‘Java虚拟机规范’明确允许虚拟机自行选择是否支持栈的动态扩展, 而HotSpot虚拟机并不支持栈扩展, 因此HotSpot虚拟机中的虚拟机栈在大部分情况下只会出现StackOverflowError异常.

在HotSpot虚拟机中, 虚拟机栈的大小可用-Xss设定, 在64位Linux操作系统下其默认值为1M, 可设定的最小值为228k. 我们可用如下代码简单探究HotSpot虚拟机的虚拟机栈实现.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class JavaVMStackSOF {
private int stackLength = 1;

public void stackLeak() {
// long number;
stackLength++;
stackLeak();
}

public static void main(String[] args) {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length: " + oom.stackLength);
throw e;
}
}
}

读者可直接运行上述代码, 重复几次后会发现一个有趣的现象: 每次打印的栈深度都不同. 这是由于随着stackLeak函数被反复调用, JIT编译器会将该函数编译为本地字节码, 当被函数调用10,000(也可通过-XX:CompileThreshold参数设定)次之后, 服务端模式下的虚拟机会通过JIT编译器将该函数编译为本地字节码. 编译完成所需的时间视运行条件而定, 每次运行可能都会存在一定差别, 也正是这个编译完成时间的细微差别, 导致了每次打印的栈深度不同, 编译完成后若再次调用此函数则虚拟机会自动调用经JIT编译器编译过的版本(也可采用-XX:-BackgroundCompilation参数静止后台编译, 这样一旦触发JIT编译, 一切对该方法的调用都将被阻塞, 直到编译完成). 被编译为本地字节码后stackLeak函数对应的栈帧会变小. 读者可在运行时增加JVM参数-Djava.compiler=NONE-Xint明确指示虚拟机关闭JIT编译器. 这样无论运行几次上述代码, 所打印的栈深度都是10780, 这个值一定比不使用-Djava.compiler=NONE参数时小, 这也印证了JIT编译后的栈帧会变小.

为了验证HotSpot虚拟机默认虚拟机栈的大小, 可在运行上述代码时添加JVM参数-Xss1M -Djava.compiler=NONE, 可以看到打印的栈深度也为10780(作者的运行环境是64位Linux OracleJDK 8). 如果取消第5代码的注释再次运行, 可以看到打印的栈深度减小, 这是由于方法中增加了一个局部变量的定义, 从而增加了局部变量表的占用空间, 使得方法对应的栈帧变大, 在相同栈内存的情况下, 能够递归调用的次数就少了.

线程共享区域

‘Java虚拟机规范’中规定的线程共享的运行时数据区域包括方法区和堆, Java虚拟机对这部分内存区域的自动化管理是使得Java语言区别于C/C++等需要手动管理内存的语言的重要特征之一.

方法区

方法区用于存储已被虚拟机加载的类型信息, 常量, 静态变量, JIT编译器编译后的代码缓存等数据. 虚拟机对方法区的实现存在很大不同和变化, 在JDK 8之前, HotSpot虚拟机采用永久代实现方法区, 从JDK 8开始, 永久代已经被完全废弃, 取而代之的是由本地内存实现的元空间.

方法区中包含运行时常量池, 用于存储由javac编译生成的Class文件中的常量池表, 不过除了保存Class文件中描述的符号引用外, 一般也会把由符号引用翻译过来的直接引用存储在运行时常量池中(视不同虚拟机的具体实现而定, ‘Java虚拟机规范’并未对运行时常量池的实现细节做任何规定).

注意运行时常量池与Class文件中常量池的区别. Class文件中的常量池在编译器已经确定不会更改, 而运行时常量池可能随着程序的运行而变化. 在类加载阶段, 虚拟机会将Class文件中的二进制字节流存储到方法区中, 但’Java虚拟机规范’并为规定方法区的具体数据结构, 一般来说Class文件中的常量池将被加载到运行时常量池中.

根据’Java虚拟机规范’的规定, 如果方法区无法满足新的内存分配需求时, 将抛出OutOfMemoryError异常. 事实上, 在JDK 8之后由于方法区是基于本地内存的元空间实现的, 因此在默认情况下除非本地内存溢出, 否则方法区不会抛出OutOfMemoryError异常. 不过HotSpot虚拟机还是提供了一些参数用于控制方法区:

  • -XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即只受限于本地内存大小. 该值不能设置得过小, 一般在10M以上, 设置得太小虚拟机将拒绝启动, 并提示MaxMetaspaceSize is too small.
  • -XX:MetaSpaceSize: 指定元空间的初始空间大小, 以字节为单位, 达到该值就会触发垃圾收集进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下, 适当提高该值.
  • -XX:MinMetaspaceFreeRatio: 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比, 可减少因为元空间不足导致的垃圾收集的频率. 类似的还有-XX:MaxMetaspaceFreeRatio, 用于控制最大的元空间剩余容量的百分比.

运行时常量池中, 与我们最密切相关的当属字符串常量池, 由于字符串常量池的存在会出现一些有趣的现象, 我们都知道Java语言中不能通过==来判断两个字符串是否相等, 对于字符串而言==判断的是两个字符串对象引用的地址是否相等. 但是通过如下代码将会发现b1, b2的值为true, 而b3的值为false.

1
2
3
4
5
6
7
8
9
10
11
12
public class StringConstantPool {
public static void main(String[] args) {
String str1 = "str";
String str2 = "str";
String str3 = "s" + "t" + "r";
String str4 = new String("str");

boolean b1 = str1 == str2;
boolean b2 = str1 == str3;
boolean b3 = str1 == str4;
}
}

这其中比较奇怪的现象可能是str1, str2str3为什么会相等, 而却与str4不相等. 这个现象就与字符串常量池密切相关了: 在Java语言中定义的字面量字符串或其运算都会进入字符串常量池, 当后续对象定义了相同的字符串字面量值时会直接引用字符串常量池中存在的值, 即str1, str2str3所引用的其实是字符串常量池中的同一个对象. 但是new关键字创建的对象在堆上产生, 不会进入字符串常量池. 此外调用String::intern()也会使字符串进入字符串常量池.

堆在虚拟机启动时创建, ‘几乎’所有对象实例及数组元素(为方便描述之后将省略数组元素, 但读者应当明确数组元素也是在堆上进行分配的)都在堆上分配内存. 堆是Java虚拟机运行时数据区域中最为复杂的部分, 堆上的内存空间由垃圾收集器进行管理, 堆内存的分配与回收策略与所选用的垃圾收集器密切相关.

从实现角度来看, 所有对象实例都在堆上分配并非那么绝对, 随着逃逸分析技术的日渐强大, 栈上分配, 标量替换等优化手段已经导致一些对象实例并非分配在堆内存上, 而可能分配在栈中. 出于严谨性考虑, 这里使用了’几乎’.

由于堆内存需要垃圾收集器进行自动管理, 因此我们首先讲述与垃圾收集相关的理论, 包括对象存活判定方法和垃圾收集算法, 最后讲述HotSpot虚拟机中的各款垃圾收集器具体如何运用上述垃圾收集理论管理堆内存.

对象存活判定

堆中存放着Java对象实例, 垃圾收集器所收集的实际上是已经’死去’的对象, 即在Java程序中不会再被引用的对象. 因此, 在进行垃圾收集之前, 首先要解决的是如何判定对象实例是否需要被回收. 常用的对象存活判定方法有两种: 引用计数算法和可达性分析算法.

这里说到对象实例一般指对象的实际内容, 存储在堆内存上. 而对象引用指Java程序中的引用变量, 存储在虚拟机栈的局部变量表中. 对象实例占用的堆内存需要在对象’死亡’后, 由垃圾收集器进行回收. 而对象引用会随着栈帧在虚拟机栈中入栈和出栈而出生和消亡, 不需要垃圾收集器的介入.

引用计数算法在对象中添加一个引用计数器, 每当程序中引用该对象时, 计数器的值就加一; 当引用失效时, 计数器的值就减一; 任何时刻计数器为零的对象就是不可能再被使用的对象, 需要垃圾收集器回收该对象实例所占用的堆内存空间. 引用计数算法原理简单, 判定效率高, 在多数情况下是一个不错的算法, 当下十分流行的Python语言就是用引用计数算法来管理内存. 然而, 在Java领域主流的Java虚拟机并未使用引用计数算法进行内存管理. 这是由于单纯的引用计数算法在某些特殊情况下难以正确工作, 必须配合大量的额外工作处理这些特殊情况. 例如, 单纯的引用计数算法无法解决循环引用问题, 如下代码即是一个循环引用的例子.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}

上述代码中, 尽管在11行之后, objAobjB所引用的对象实例已经不可能再被访问, 应当由垃圾收集器回收其所占用的内存. 但若采用引用计数算法判定对象是否’存活’, 由于objA所指对象实例中保存着对objB所指对象实例的引用, 反之亦然. 因此两个对象实例中的引用计数器不为零, 相应的垃圾收集器也就无法回收其占用的内存. 而Java虚拟机却可以成功回收objAobjB所引用对象实例的内存, 如下是在JDK 8中的运行结果(启动时需添加JVM参数-XX:+PrintGCDetails以打印垃圾回收日志). 从中可以看到确实发生了垃圾收集, 这也在一定程度上说明了Java虚拟机并非使用引用计数算法判定对象是否’存活’.

1
2
3
4
5
6
7
8
9
10
11
[GC (System.gc()) [PSYoungGen: 9344K->840K(76288K)] 9344K->848K(251392K), 0.0014773 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 840K->0K(76288K)] [ParOldGen: 8K->619K(175104K)] 848K->619K(251392K), [Metaspace: 3124K->3124K(1056768K)], 0.0045232 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 76288K, used 1968K [0x000000076b500000, 0x0000000770a00000, 0x00000007c0000000)
eden space 65536K, 3% used [0x000000076b500000,0x000000076b6ec1d8,0x000000076f500000)
from space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
to space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
ParOldGen total 175104K, used 619K [0x00000006c1e00000, 0x00000006cc900000, 0x000000076b500000)
object space 175104K, 0% used [0x00000006c1e00000,0x00000006c1e9ad78,0x00000006cc900000)
Metaspace used 3131K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 342K, capacity 388K, committed 512K, reserved 1048576K

事实上, Java虚拟机是采用可达性分析算法来判定对象是否’存活’的. 可达性分析算法的基本思路是通过一系列称为’GC Roots’的根对象作为起始节点集, 从这些节点开始, 根据引用关系向下搜索, 搜索过程所走过的路径称为’引用链’, 如果某个对象到GC Roots间没有任何引用链相连, 则证明此对象是不可能再被使用的. 如下图所示, 对象Object 5, Object 6, Object 7之间虽然存在引用, 但是它们到GC Root Set是不可达的, 因此将被判定为可回收对象.

垃圾收集算法

根据上节所述的对象存活判定方法, 可将垃圾搜集算法分为两大类:

  • 引用计数式垃圾收集(直接垃圾收集);
  • 追踪式垃圾收集(间接垃圾收集).

由上节可知, 当前主流的Java虚拟机均采用可达性分析算法判定对象是否’存活’, 这就意味着需要使用追踪式的垃圾收集算法, 因此本文介绍的垃圾收集算法均属于追踪式垃圾收集范畴. 由于当前主流Java虚拟机的垃圾收集器都基于分代理论进行设计, 因此在正式介绍垃圾收集算法之前首先讲述分代收集理论, 后面所介绍的垃圾收集算法适用于不同的分代.

分代收集理论实际上是一套符合大多数程序运行实际情况的经验法则, 它建立在两个分代假说之上:

  1. 弱分代假说: 绝大多数对象都是朝生夕灭的;
  2. 强分代假说: 熬过越多次垃圾收集过程的对象就越难消亡.

这两个分代假说共同奠定了多款常用垃圾收集器的一致设计原则: 收集器应该将Java堆划分出不同的区域, 然后将回收对象依据其年龄(年龄即熬过垃圾收集过程的次数)分配到不同的区域之中存储. 一般的Java虚拟机至少会把堆划分为新生代和老年代两个区域. 在新生代中每次垃圾收集后都有大量的对象实例被回收, 而每次回收后存活的对象将逐步晋升到老年代中. 在G1收集器出现之前, HotSpot虚拟机一般把整块堆内存按比例划分为新生代和老年代, 两个不同的分代区域由各自的垃圾收集器负责管理.

追踪式垃圾收集算法主要有以下三种.

  • 标记-清除算法: 首先标记出所有需要回收的对象, 在标记完成后, 统一回收所有被标记的对象, 也可以反过来, 标记存活的对象, 统一回收所有未被标记的对象. 该算法有两个明显的缺点: 执行效率低下和内存空间碎片化.

  • 标记-复制算法: 将可用内存划分为大小相等的两块, 每次只使用其中的一块, 当需要进行垃圾收集时将其中一块内存上的存活对象复制到另一块内存上, 再把已使用过的内存一次清理掉. G1收集器之前的垃圾收集器大多采用了这种方法进行新生代收集, 不过由于新生代对象大多都是”朝生夕灭”的, 因此虚拟机并未直接把新生代区域一分为二, 只使用其中一份. 而是将新生代分为一个Eden空间和两个Survivor空间(一个Eden空间和一个Survivor空间默认的大小比值为8:1), 首先使用Eden空间和一个Survivor空间, 需要垃圾收集时再将存活对象复制到另一个Survivor空间, 这样就只有10%的空间浪费.

  • 标记-整理算法: 首先标记存活的对象, 在标记完成后, 将存活的对象都向内存空间一端移动, 最后直接清理掉边界以外的内存.

堆内存空间划分

从上节关于垃圾收集算法的讲述中可以得知, HotSpot虚拟机中的垃圾收集器大多基于分代收集理论进行设计, 因此使用这些垃圾收集器时就必须对Java堆进行逻辑上的分代划分. 一般来说, Java堆会被划分为新生代(包括Eden空间, Survivor空间)和老年代等多个逻辑区域. 其划分方式随着G1收集器(见Garbage First收集器)的出现, 发生了重要改变. Java堆的大小可用以下虚拟机参数设定:

  • -Xms-XX:InitialHeapSize: Java堆的初始大小, 及Java虚拟机进程启动时堆的大小.
  • -Xmx-XX:MaxHeapSize: Java堆的最大值.

读者可使用java -XX:+PrintCommandLineFlags -version命令查看Java堆的初始大小和最大值.

在G1收集器出现以前, 也即JDK 8及之前虚拟机在服务端模式下的默认状态下, 整个Java堆被划分为新生代和老年代. 其中, 新生代又被划分为Eden空间和Survivor空间. 在JDK 8中, 默认情况下新生代和老年代占用的堆内存大小比值为1:2, Eden空间和Survivor空间大小的比值为8:2, 其中Survivor空间被平均分成两个区域, 称为From Survivor和To Survivor. 分代的大小可用如下虚拟机参数设定:

  • -XX:NewSize: 新生代的大小, 在JDK 8中默认为整个Java堆大小的1/3.
  • -XX:MaxNewSize: 新生代大小的最大值.
  • -Xmn: 相当于把-XX:NewSize-XX:MaxNewSize设定为同一值.
  • -XX:Newratio: 新生代和老年代大小的比值, 在JDK 8中默认为值2, 即新生代和老年代大小的比值为1:2.
  • -XX:Surviorratio: 新生代中Eden空间和一个Survivor空间大小的比值, 在JDK 8中默认为8, 即Eden空间和一个Survivor空间大小的比值为8:1.

在HotSpot虚拟机中, 新生代大多采用标记-复制算法, 开始时新生代中真正能使用的内存空间只占整个新生代的90%(即Eden空间和From Survivor空间), 在进行Minor GC时, 存活的对象会被复制到To Survivor空间, 然后清理整个Eden空间和From Survivor空间, 并将From Survivor和To Survivor空间的角色互换. 若To Survivor的空间不足以容纳一次Minor GC之后存活的对象, 就需要依赖其他内存区域(大多数是老年代)进行分配担保.

垃圾收集器

垃圾收集器是Java虚拟机内存管理子系统的重要组成部分, 用于自动回收已经不会再被程序使用的堆内存. HotSpot虚拟机中的垃圾收集器可以G1收集器为分界, 在G1收集器之前每款垃圾收集器都只负责新生代或老年代的垃圾收集, 对于整个堆的垃圾收集, 必须要有两款垃圾收集器配合工作. G1收集器是第一款全功能的垃圾收集器, 负责整个Java堆的垃圾回收.

随着垃圾收集器技术的不断发展, 目前已经出现了两款更高性能的垃圾收集器: Shenandoah收集器和ZGC收集器. 这两款垃圾收集器与G1收集器一样都是基于Region内存布局的, 可见从G1收集器开始, 全功能的, 基于Region内存布局的垃圾收集器渐渐成为主流. 由于这两款收集器尚在实验状态, 几乎不会在生产环境中使用, 因此本文并不会详细讲述这两款垃圾收集器, 而只讲述到G1收集器为止曾经在HotSpot虚拟机中出现过的垃圾收集器.

Serial收集器和Serial Old收集器

Serial收集器通常搭配Serial Old收集器使用(当然这并非唯一的搭配方式), Serial收集器用于新生代收集, Serial Old收集器用于老年代收集. 这两款收集器在进行垃圾收集时都必须暂停所有用户线程, 且仅使用一个垃圾收集线程. 这看起来似乎十分低效, 然而正因如此, 这两款收集器所占用的额外内存消耗是最小的, 因此一般用于客户端模式, 适用于具有内存限制的桌面端或微服务场景.

32位的Java虚拟机有两种运行模式: 客户端模式(Client VM)和服务器模式(Server VM), 客户端模式启动的是更为轻量级的虚拟机进程, 具有更快的启动速度, 适用于交互式应用, 而服务器模式相对重量级, 适用于分析型应用. 在64位虚拟机中已经弃用了客户端模式.

在HotSpot虚拟机中, 启动时增加-XX:+UseSerialGC参数可以使用Serial + Serial Old收集器组合进行内存回收, 这也是客户端模式下的默认垃圾收集器组合.

ParNew收集器和CMS收集器

ParNew收集器经常搭配CMS(Concurrent Mark Sweep)收集器使用, 在JDK 9之前, ParNew收集器尚可搭配Serial Old收集器, CMS收集器尚可搭配Serial收集器, 然而从JDK 9开始这两种搭配方式均被废弃了, 这也意味着从JDK 9开始ParNew收集器只能搭配CMS收集器使用. 在HotSpot虚拟机中, ParNew收集器是激活CMS收集器(使用-XX:+UseConcMarkSweepGC参数)后的默认新生代收集器, 当然也可使用-XX:+/-UseParNewGC选项来强制启用或禁用它.

ParNew收集器用于新生代收集, 是Serial收集器的多线程版本, 除了支持多线程并行收集外, 其他与Serial收集器相比并没有太多创新, 在实现上这两种收集器也公用了很多代码. ParNew收集器在单核处理器上绝对不会比Serial收集器有更好的效果, 它默认开启的线程数与处理器核心数相同, 在处理器核心很多的情况下可使用-XX:ParallelGCThreads参数来指定垃圾收集线程的数量.

CMS收集器用于老年代收集, 是第一款真正意义上的并发收集器(这里的并发是指垃圾收集线程和用户线程可同时运行), 以获取最短停顿时间为目标. CMS收集器是基于标记-清除算法实现的, 回收过程分为以下四个步骤:

  1. 初始标记: 需要暂停用户线程, 仅仅标记GC Roots能直接关联的对象, 速度很快;
  2. 并发标记: 可与用户线程并行, 从GC Roots的直接关联对象开始遍历整个对象图, 耗时较长;
  3. 重新标记: 需要暂停用户线程, 为修正并发标记期间, 因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录, 停顿时间比初始标记稍长, 但远比并发标记短;
  4. 并发清除: 可与用户线程并行, 清理上述三个标记阶段判断已经死亡的对象所占用的堆内存空间.

由上述过程可以看出, CMS回收过程中只有两个耗时较短的过程(初始标记和重新标记)需要暂停用户线程, 而耗时较长的并发标记和并发清除阶段则可与用户线程并行, 这大大减少了用户线程的停顿时间. CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试, 但仍有以下缺点:

  • CMS收集器对处理器资源非常敏感, 在并发阶段由于占用处理器资源可能使用户线程运行变慢.
  • CMS收集器无法处理浮动垃圾, 可能出现Concurrent Mode Failure, 而启用Serial Old收集器重新回收老年代.
  • CMS收集器基于标记-清除算法, 收集结束后可能会产生大量空间碎片, 导致老年代即使有足够的内存空间, 也无法找到足够大的连续空间来分配大对象, 从而提前触发一次Full GC.
Parallel Scavenge和Parallel Old

Parallel Scavenge收集器和Parallel Old收集器都是吞吐量优先的收集器, 这两款收集器的搭配常用于需要在后台运算而不需要太多交互的分析任务. 可通过-XX:+UseParallelOldGC参数启用这两款收集器的搭配组合. 需要注意的是, JDK 9之前Parallel Scavenge收集器是服务端模式下的默认新生代收集器, 但老年代收集器却是一个称为PS MarkSweep的收集器, 这款收集器的实现与Serial Old几乎一摸一样, 不过它从属于Parallel Scavenge架构, 无法单独使用, 通过-XX:+UseParallelGC参数可启用Parallel Scavenge收集器加PS MarkSweep收集器的组合.

Parallel Scavenge是一款新生代收集器, 与ParNew收集器的重要区别是, ParNew收集器关注用户线程的停顿时间, 而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量, 即处理器用于运行用户代码的时间与处理器总消耗时间的比值:
$$ 吞吐量 = \frac{运行用户代码时间}{运行用户代码时间 + 运行垃圾收集时间} $$
Parallel Scavenge收集器的一个重要特性是具有自适应调节策略, 可通过-XX:UseAdaptiveSizePolicy参数启用, 当这个参数激活后就不需要人工指定新生代的大小(-Xmn), Eden空间与Survivor空间的比例(-XX:SurvivorRatio), 晋升到老年代对象大小这些参数了, 虚拟机会根据当前系统的运行状况自动调节这些参数. 此外, Parallel Scavenge收集器还有两个参数用于精度控制吞吐量:

  • -XX:GCTimeRatio: 一个大于0小于100的整数, 表示垃圾收集时间占总时间的比率, 即吞吐量的倒数, 默认值为99, 即允许最大1%(1 / (1 + 99))的垃圾收集时间;
  • -XX:MaxGCPauseMillis: 一个大于0的毫秒数, 收集器将尽力保证内存回收花费的时间不超过此值. 需要注意的是, 减小此值并不能使垃圾收集的速度更快, 它只能使每次垃圾收集的停顿时间变短, 这是牺牲吞吐量换来的, 为了降低每次垃圾收集的停顿时间, 只能尽可能将新生代设置地更小, 假设原来500M的新生代每隔10秒进行一次垃圾收集, 每次停顿100毫秒, 现在为了降低停顿时间把新生代设置为300M, 每隔5秒收集一次, 每次停顿时间70毫秒, 显然每次垃圾收集的停顿时间变短了, 但是吞吐量却也下降了.

Parallel Old收集器是Parallel Scavenge收集器的老年代版本, 与Parallel Scavenge一样也是一款关注吞吐量的收集器, 直到JDK 6才开始提供.

Garbage First收集器

Garbage First (G1)收集器是垃圾收集器技术发展史上具有划时代意义的成果, 在G1收集器之前, 垃圾收集的目标要么是整个新生代(Minor GC), 要么是整个老年代(Major GC), 再要么就是整个Java堆(Full GC), 而G1收集器开创了收集器面向局部收集的设计思路和基于Region的内存布局形式. 从JDK 9开始, G1收集器已经成为HotSpot虚拟机在服务端模式下的默认垃圾收集器.

G1不再以固定大小及数量的分代区域划分Java堆, 而是把连续的Java堆划分为多个大小相等的独立区域(Region), 每个Region可以根据需要, 扮演新生代的Eden空间, Survivor空间, 老年代空间, 或用于存储大对象的Humongous区域. G1收集器能对扮演不同角色的Region采用不同的策略去处理, 这样使得每个区域的收集都能达到良好的效果.

G1收集器的回收过程如下:

  • 初始标记: 仅标记GC Roots能直接关联的对象. 这一阶段需要暂停用户线程, 但是停顿时间很短, 而且是借用进行Minor GC的时候同步完成的, 所以G1收集器在这一阶段实际上并没有额外的停顿.
  • 并发标记: 从GC Roots开始对堆中的对象进行可达性分析, 递归扫描整个堆的对象图, 找出要回收的对象. 这一阶段耗时较长, 但可与用户线程并发执行.
  • 最终标记: 对用户线程做一个短暂的暂停, 用于处理并发标记阶段用户线程继续运行而改变的对象引用.
  • 筛选回收: 负责更新Region的统计数据, 对各个Region的回收价值和成本进行排序, 根据用户所期望的停顿时间来指定回收计划, 可以自由选择任意多个Region构成回收集, 然后把决定回收的那一部分Region的存活对象复制到空的Region中(标记-复制算法), 再清理掉整个旧Region的全部空间. 这里的操作涉及存活对象的移动, 是必须暂停用户线程, 由多条线程并行完成的.

总结

这篇博文深入探讨了Java虚拟机的5个运行时数据区域, 重点讲述了Java堆内存区域及与其密切相关的几款垃圾收集器. 相信在认真研读本文后, 就能对Java虚拟机的内存管理子系统有深入的理解. 本文对每个运行时数据区域的描述基本遵循以下几个原则:

  • 这一区域的作用是什么.
  • 这一区域的工作原理.
  • 这一区域可能出现的异常.
  • 虚拟机对这一区域提供了什么样的控制和优化参数.

如果在学习Java虚拟机运行时数据区域时, 能够遵循上述原则, 不断思考和实践, 定能更加深入地理解Java虚拟机的内存管理子系统.

Flink SQL最佳实践 - HBase SQL Connector应用 HBase最佳实践 - Bulk Loading原理与Spark实现

本博客所有文章除特别声明外, 均采用CC BY-NC-SA 3.0 CN许可协议. 转载请注明出处!



关注笔者微信公众号获得最新文章推送

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×