JVM 垃圾回收
1. 对象已死?
在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还存活着,哪些已经死去了(这里的死去,指的是不可能再被任何途径使用的对象)。
1.1 引用计数算法
很多教科书判断对象是否存活的算法是这样说的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
可观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。不过,至少主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存。主要原因,因为引用计数算法有一个缺陷:不能解决对象之间循环引用的问题。
下面举一个代码示例:
package com.bestwu.algorithm.gc;
/**
* <br>
*
* @author Best Wu
* @date 2021/3/13 21:29 <br>
*/
public class ReferenceCountingGC {
public Object instance = null;
private final static int _1MB = 1024 * 1024;
/**
* 这个成员属性的唯一意义就是占点内存,
* 以便能在 GC 日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
/**
* -XX:+PrintGCDetails
*/
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生 GC,objA 和 objB 是否能被回收?
System.gc();
}
}
运行结果:
[GC (System.gc()) [PSYoungGen: 9339K->703K(76288K)] 9339K->711K(251392K), 0.0009593 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 703K->0K(76288K)] [ParOldGen: 8K->655K(175104K)] 711K->655K(251392K), [Metaspace: 3061K->3061K(1056768K)], 0.0061619 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 76288K, used 1966K [0x000000076b200000, 0x0000000770700000, 0x00000007c0000000)
eden space 65536K, 3% used [0x000000076b200000,0x000000076b3eb9e0,0x000000076f200000)
from space 10752K, 0% used [0x000000076f200000,0x000000076f200000,0x000000076fc80000)
to space 10752K, 0% used [0x000000076fc80000,0x000000076fc80000,0x0000000770700000)
ParOldGen total 175104K, used 655K [0x00000006c1600000, 0x00000006cc100000, 0x000000076b200000)
object space 175104K, 0% used [0x00000006c1600000,0x00000006c16a3c80,0x00000006cc100000)
Metaspace used 3067K, capacity 4556K, committed 4864K, reserved 1056768K
class space used 324K, capacity 392K, committed 512K, reserved 1048576K
从运行结果来看,回收了新生代,也从侧面反映了,JVM 没有通过引用计数算法来实现垃圾回收。
1.2 可达性分析算法
当前主流的商用程序语言(Java,C#,上溯至前面提到的 Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径为 “引用链” (Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
如图所示:
对象 object5、object6、object7 虽然互有关联,但是它们到 GC Roots 是不可达的,因此它们将会被判定为可回收的对象。
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointerException、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象 “临时性” 地加入,共同构成完整 GC Roots 集合。譬如后续将会提到的分代收集和局部回收(Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。
目前最新的几款垃圾收集器(如 OpenJDK 中的 G1、Shenandoah、ZGC 以及 Azul 的 PGC、C4 这些收集器)无一例外都具备了局部回收的特征,为了避免 GC Roots 包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。
1.3 再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和 “引用” 离不开关系。在 JDK 1.2 版之前,Java 里面的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有 “被引用” 或者 “未被引用” 两种状态,对于描述一些 “食之无味,弃之可惜” 的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的引用场景。
在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。
- 强引用是最传统的 “引用” 的定义,是指在程序代码之中普遍存在的引用赋值,即类似
Object obj = new Object()
这种引用关系了,无论任何情况下,只要强引用关系还在,垃圾收集器就永远不会回收掉被引用的对象。 - 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。
- 虚引用也称为 “幽灵引用” 或者 “幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。
1.4 生存还是死亡?
即使在可达性分析算法中判定为不可达的对象,也不是 “非死不可” 的,这时候它们暂时还处于 “缓刑” 阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()
方法。假如对象没有重写 finalize()
方法,或者 finalize()
方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为 “没有必要执行”。
如果这个对象被判定为确有必要执行 finalize()
方法,那么该对象将会被放置在一个名为 F-Queue
的队列中,并在稍后由一条虚拟机自动建立的、低调度优先级的 Finalizer
线程去执行它们的 finalize()
方法。这里所说的 “执行” 是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize()
方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue
队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()
方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue
中的对象进行第二次小规模的标记,如果对象要在 finalize()
中成功拯救自己——只要重新与引用链上的任何一个对象建立关系即可,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移出 “即将回收” 的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。如下代码,展示了一次对象自我拯救的例子:
package com.bestwu.algorithm.gc;
import java.lang.ref.PhantomReference;
import java.lang.ref.WeakReference;
import java.util.concurrent.TimeUnit;
/**
* 代码演示两点 <br>
* 1. 对象可以在被 GC 时自我拯救 <br>
* 2. 这种自救的机会只有一次,因为一个对象的 finalize 方法最多只会被系统自动调用一次 <br>
*
* @author Best Wu
* @date 2021/3/16 21:29 <br>
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
// 对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为 Finalizer 方法优先级很低,暂停 0.5s,以等待它
TimeUnit.MILLISECONDS.sleep(500);
if (null != SAVE_HOOK) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
TimeUnit.MILLISECONDS.sleep(500);
if (null != SAVE_HOOK) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
执行结果:
finalize method executed!
yes, i am still alive :)
no, i am dead :(
在上述代码中我们可以看到一个对象的 finalize()
方法被执行,但是它仍然可以存活。
从上面的代码运行结果可以看到,SAVE_HOOK
对象的 finalize()
方法确实被垃圾收集器触发过,并且在被收集前成功逃脱了。
另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。这是因为任何一个对象的 finalize()
方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize()
方法不会再被执行,因此第二段代码的自救行动失败了。
1.5 回收方法区
有些人认为方法区(如 HotSpot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java 虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK 11 时期的 ZGC 收集器就不支持类卸载),方法区垃圾收集的 “性价比” 通常也是比较低的:在 Java 堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70% 到 99% 的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收 Java 堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串 “Java” 曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是 “Java”,换句话说,已经没有任何字符串对象引用常量池中的 “Java” 常量,且虚拟机中也没有其它的方法引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个 “Java” 常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否 “废弃” 还是相对简单,而要判定一个类型是否属于 “不再被使用的类” 的条件就比较苛刻了,需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里的仅仅是 “被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc
参数进行控制,还可以使用 -verbose:class
以及 -XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息,其中 -verbose:class
和 -XX:+TraceClassLoading
可以在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading
参数需要 FastDebug 版的虚拟机支持。
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 询价具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
2. 垃圾收集算法
从如何判定对象消亡的角度出发,垃圾收集算法可以划分为 “引用计数式垃圾收集”(Reference Counting GC)和 “追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作 “直接垃圾收集” 和 “间接垃圾收集”。由第一章节中介绍主流的 Java 虚拟机并没有使用引用计数算法来实现垃圾回收,故下面提到的垃圾收集算法均属于追踪式垃圾收集的范畴。
2.1 分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了 “分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难灭亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机就可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
在 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了 “Minor GC”、“Major GC”、“Full GC” 这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了 “标记—复制算法”、“标记—清除算法”、“标记—整理算法” 等针对性的垃圾收集算法。
把分代收集理论具体放到现在的商用 Java 虚拟机里,设计者一般至少会把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域(这里提到了新生代与老年代,新生代和老年代是 HotSpot 虚拟机,也是现在业界主流的命名方式,在 IBM J9 虚拟机中对应称为婴儿区 [Nursery] 和长存区 [Tenured],名字不同但其含义是一样的)。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。**思考:对象不是孤立的,对象之间会存在跨代引用。**所以说分代收集并不是简简单单划分一下内存区域,至少存在刚刚提到的思考问题。
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来(通常能单独发生收集行为的只是新生代,所以这里 “反过来” 的情况只是理论上允许,实际上除了 CMS 收集器,其他都不存在只针对对老年代的收集)也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
依据这条假说,我们就不应该为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为 “记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
ps:针对于收集的一些名词解释
- 部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有 CMS 收集器会单独收集老年代的行为。另外需要注意 “Major GC” 这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。
- 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。
2.2 标记 — 清除算法
最早出现也是最基础的垃圾收集算法是 “标记—清除”(Mark-Sweep)算法,在 1960 年由 Lisp 之父 John McCarthy 所提出。如它的名字一样,算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记—清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:
- 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量的增长而降低。
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记—清除算法的执行过程如下图所示:
2.3 标记—复制算法
标记—复制算法常被简称为复制算法。为了解决标记—清除算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出了一种称为 “半区复制”(Semispace Copying)的垃圾收集算法,**它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。**这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点,标记—复制算法的执行过程如下图所示:
现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收新生代,IBM 公司曾有一项专门研究对新生代 “朝生夕灭” 的特点做了更量化的诠释——新生代中的对象有 98% 熬不过第一轮收集。因此并不需要按照 1:1 的比例来划分新生代的内存空间。
在 1989 年,Andrew Appel 针对具备 “朝生夕灭” 特点的对象,提出了一种更优化的半区复制分代策略,现在称为 “Appel 式回收”。HotSpot 虚拟机的 Serial、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。**HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也即每次新生代中可用内存空间为整个新生代容量的 90%(Eden 的 80% 加上一个 Survivor 的 10%),只有一个 Survivor 空间,即 10% 的新生代是会被 “浪费” 的。**当然,98% 的对象可被回收仅仅是 “普通场景” 下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于 10% 的对象存活,因此 Appel 式回收还有一个充当罕见情况的 “逃生门” 的安全设计,当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其它内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。
内存的分配担保好比我们去银行借款,如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。
2.4 标记—整理算法
标记—复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保、以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的 “标记—整理”(Mark-Compact)算法,其中的标记过程仍然与 “标记—清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记—整理” 算法的执行过程如下图所示:
标记—清除算法与标记—整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(最新的 ZGC 和 Shenandoah 收集器使用读屏障 Read Barrier 技术实现了整理过程与用户线程的并发执行),这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为 “Stop The World”(通常标记—清除算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间相对而言要来的短而已)。
但如果跟标记—清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过 “分区空闲分配链表” 来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。此语境中吞吐量的实质是赋值器(Mutator,可以理解为使用垃圾收集的用户程序)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因为内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot 虚拟机里面关注吞吐量的 Parallel Scavenge 收集器是基于标记—整理算法的,而关注延迟的 CMS 收集器则是基于标记—清除算法的,这也从侧面印证这点。
另外,还有一种 “和稀泥” 解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记—清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记—整理算法收集一次,以获得规整的内存空间。前面提到的基于标记—清除算法的 CMS 收集器面临空间碎片化过多时采用的就是这种处理办法。
3. HotSpot 的算法细节实现
3.1 根节点枚举
我们以可达性分析算法中从 GC Roots 集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在 Java 引用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类,常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。
迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的 “Stop The World” 的困扰。 现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根结点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里 “一致性” 的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMS、G1、ZGC 等收集器,枚举根节点时也必须要停顿的。
由于目前主流 Java 虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在 HotSpot 的解决方案里,是使用一组称为 OopMap 的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。
3.2 安全点
在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。
实际上 HotSpot 也的确没有为每条指令都生成 OopMap,前面已经提到,只是在 “特定的位置” 记录了这些信息,这些位置被称为安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能暂停。 因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以 “是否具有让程序长时间执行的特征” 为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行” 的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直接跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。
而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot 使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。这条汇编指令是 test
指令,由 HotSpot 生成的轮询指令,当需要暂停用户线程时,虚拟机把当前内存页设置为不可读,那么线程执行到 test
指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。
3.3 安全区域
使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序 “不执行” 的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于 sleep
状态或者 blocked
状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其它需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
3.4 记忆集和卡集
上面谈到分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如 G1、ZGC 和 Shenandoah 收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。 如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构(以对象指针来实现记忆集的伪代码如下):
class RememberedSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围之外的)的记录精度:
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
其中,第三种(卡精度)所指的是用一种称为 “卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。前面定义中提到记忆集其实是一种抽象的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
卡表最简单的形式可以只是一个字节数组,而 HotSpot 虚拟机确实也是这样做的。以下这行代码是 HotSpot 默认的卡表标记逻辑:
CARD_TABLE[this.address >> 9] = 0;
字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作 “卡页”(Card Page)。一般来说,卡页大小都是以 2 的 N 次幂的字节数,通过上面的代码可以看出 HotSpot 中使用的卡页是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于地址除以 512)。那如果卡表标识内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x00000x01FF、0x02000x03FF、0x0400~0x05FF 的卡页内存块,如下图所示:
3.5 写屏障
我们已经解决了如何使用记忆集来缩减 GC Roots 扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。
卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态。写屏障可以看作在虚拟机层面对 “引用类型字段赋值” 这个动作的 AOP 切面。在引用对象赋值时会产生一个环形通知(Around Advice),供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot 虚拟机的许多收集器中都有使用到写屏障,但直至 G1收集器出现之前,其它收集器都只用到了写后屏障。下面这段代码是一段更新卡表状态的简化逻辑:
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的。
除了写屏障的开销外,卡表在高并发场景下还面临着 “伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
假设处理器的缓存行大小为 64 字节,由于一个卡表元素占 1 个字节,64 个卡表元素将共享一个缓存行。这 64 个卡表元素对应的卡页总的内存为 32KB(64 * 512 字节),也就是说如果不同线程更新的对象正好处于这 32KB 的内存区域内,就会倒置更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:
if(CARD_TABLE[this.address >> 9] != 0) {
CARD_TABLE[this.address >> 9] = 0;
}
在 JDK 7 之后,HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark
,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。
3.6 并发可达性分析
当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于 GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如 OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从 GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。
要知道包含 “标记” 阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。
想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了能解释清楚这个问题,我们引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照 “是否访问过” 这个条件标记成以下三种颜色:
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其它对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象至少存在一个引用还没有被扫描过。
关于可达性分析,不妨想象一下,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。
Wilson 于 1994 年在理论上证明了,当且仅当以下两个条件同时满足时,会产生 “对象消失” 的问题,即原本应该是黑色的对象被误标为白色:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
因此,我们要解决并发扫描时的对象消失问题,**只需要破坏这两个条件其中之一即可。**由此分别产生了两种解决方案:
- 增量更新(Incremental Update)
- 原始快照(Snapshot At The Beginning, SATB)
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。
4. 运行时数据区
回顾 Java 内存运行时数据区的各个部分:
-
程序计数器(Program Counter Register)
一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在 Java 虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法(Native Method),这个计数器值则应为空(Undefined)。
remark:该内存区域是唯一一个在《Java 虚拟机规范》中没有规定任何 OOM 情况的区域。
-
Java 虚拟机栈(Java Virtual Machine Stack)
与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
不要笼统的把 Java 内存区域划分为堆内存(Heap)和栈内存(Stack),在 Java 内存区域中略显粗糙。“堆”内存我们在后续 Java 堆中详细说明,这里我们关注栈内存。“栈” 通常是指这里讲的虚拟机栈,或者更多情况下只是指虚拟机栈中局部变量表部分。
局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型(
boolean
、byte
、char
、short
、int
、float
、long
、double
)、对象引用(reference
类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress
类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中 64 位长度的
long
和double
类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小(这里指的大小是变量槽的数量,虚拟机真正使用多大的内存空间来实现一个变量槽,这完全是由具体的虚拟机实现自行决定的事情,譬如 1 个变量槽占用 32 个比特、64 个比特,或者更多)。remark:在《Java 虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverFlowError 异常;如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。
-
栈帧
Java 虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始直至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧包括:
-
局部变量表(Local Variables Table)
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序被编译为 Class 文件时,就在方法的
Code
属性的max_locals
数据项中确定了该方法所需分配的局部变量表的最大容量。局部变量表的容量以变量槽(Variable Slot)为最小单位,《Java 虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个
boolean
、byte
、char
、short
、int
、float
、reference
或returnAddress
类型的数据,这 8 种数据类型,都可以使用 32 位或更小的物理内存来存储,但这种描述与明确指出 “每个变量槽应占用 32 位长度的内存空间” 是有本质区别的,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在 64 位虚拟机中使用了 64 位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与 32 位虚拟机中的一致。上面提到了 8 种数据类型,前 6 种可以以 Java 语言中对应的基本数据类型来理解(仅理解,Java 语言和 JVM 中的基本数据类型是存在本质的差别的),而第 7 种
reference
类型表示对一个对象实例的引用,《Java 虚拟机规范》既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是一般来说,虚拟机实现至少都应当能通过这个引用做到两件事:- 从根据引用直接或间接地查找到对象在 Java 堆中的数据存放的起始地址或索引。
- 根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。
否则将无法实现《Java 语言规范》中定义的语法规定(并不是所有语言的对象引用都能满足这两点,例如 C++ 语言,默认情况下,不开启 RTTI 支持的情况,就只能满足第一点,而不满足第二点。这也是为什么 C++ 无法提供 Java 语言里很常见的反射的根本原因)。第 8 种
returnAddress
类型目前已经很少见了,它是为字节码指令jsr
、jsr_w
和ret
服务的,指向了一条字节码指令的地址,某些很古老的 Java 虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常来代替了。对于 64 位的数据类型,Java 虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。Java 语言中明确的 64 位的数据类型只有
long
和double
两种。这里把long
和double
数据类型分割存储的做法与 “long
和double
的非原子性协定” 中允许把一次long
和double
数据类型读写分割为两次 32 位读写的做法有些类似。不过,由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的变量槽数量。如果访问的是 32 位数据类型的变量,索引 N 就代表了使用第 N 个变量槽,如果访问的是 64 位数据类型的变量,则说明会同时使用第 N 和第 N+1 两个变量槽。对于两个相邻的共同存放一个 64 位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,《Java 虚拟机规范》中明确要求了如果遇到进行这种操作的字节码序列,虚拟机就应该在类加载的校验阶段抛出异常。
**当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。**如果执行的是实例方法(没有被
static
修饰的方法),那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this
来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量槽,参数表分配完毕后,再根剧方法体内部定义的变量顺序和作用域分配其余的变量槽。为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前程序计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其它变量来重用。不过这样的设计除了节省栈空间之外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为,比如下面三段代码:
/** * jvm 参数 -verbose:gc * java 8 默认的垃圾回收器组合是 Parallel Scavenge/Serial Old 组合 * 对于具体的垃圾收集日志细节可以通过指定参数 -XX:+PrintGCDetails 来进行查看 * 也可以使用其它收集器去尝试打印日志,观察区别 * 比如 G1:-XX:+UseG1GC */ public static void main(String[] args) { // 向内存填充 64MB 的数据 byte[] placeholder = new byte[64 * 1024 * 1024]; System.gc(); }
我们来看一下控制台输出:
[GC (System.gc()) 70779K->66279K(251392K), 0.0008563 secs] [Full GC (System.gc()) 66279K->66191K(251392K), 0.0065013 secs]
在上面的 GC 日志中,我们能看见新生代产生了 GC,而老年代基本没有 GC,而可见的是新生代的 GC 内存不到 64MB(4500K = 70779K- 66279K),所以根本没有回收我们上面填充的 64MB 的内存。其实这部分内存没回收也是可以理解的,因为在触发
System.gc()
的时候,变量placeholder
还处于作用域之内,虚拟机自然不敢回收掉placeholder
的内存。那我们将上面的代码简单修改一下,再来看一下回收日志:
/** * jvm 参数 -verbose:gc */ public static void main(String[] args) { { // 向内存填充 64MB 的数据 byte[] placeholder = new byte[64 * 1024 * 1024]; } System.gc(); }
我们来看一下控制台输出:
[GC (System.gc()) 70779K->66423K(251392K), 0.0027546 secs] [Full GC (System.gc()) 66423K->66329K(251392K), 0.0124206 secs]
加了花括号以后,
placeholder
的作用域被限制在花括号以内,从代码逻辑上讲,在执行System.gc()
的时候,placeholder
已经不可能再被访问了,但执行这段程序,会发现运行结果还是没有被回收,这是为什么?在解释之前,我们再对代码进行一次修改并观察收集日志:/** * jvm 参数 -verbose:gc */ public static void main(String[] args) { { // 向内存填充 64MB 的数据 byte[] placeholder = new byte[64 * 1024 * 1024]; } int a = 0; System.gc(); }
我们再来看一下控制台输出:
[GC (System.gc()) 70779K->66416K(251392K), 0.0018779 secs] [Full GC (System.gc()) 66416K->793K(251392K), 0.0094384 secs]
不难发现,这 64MB 的内存被回收掉了(65623K = 66416K - 793K),为什么莫名其妙添加了一行代码,就可以将内存回收掉呢?
placeholder
能否被回收的根本原因是:局部变量表中的变量槽是否还存有关于placeholder
数组对象的引用。第一次修改中,代码虽然已经离开了placeholder
的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder
原本所占用的变量槽还没有被其它变量所复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。 这种关联没有被及时打断,绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其置为null
值(用来代替那句int a = 0;
把变量对应的局部变量槽清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收,方法调用次数达不到即时编译器的编译条件)下的 “奇技” 来使用。Java 语言的一本非常著名的书籍《Practical Java》中将把 “不使用的对象手动赋值为 null” 作为一条推荐的编码规则。 -
操作数栈
操作数栈(Operand Stack)也常被称为操作栈,它是一个先入后出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到 Code 属性的
max_stacks
数据项之中。操作数栈的每一个元素都可以是包括 long 和 double 在内的任意 Java 数据类型。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。Javac 编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks
数据项中设定的最大值。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算数运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。举个例子,例如整数加法的字节码指令
iadd
,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int
型的数值,当执行这个指令时,会把这两个int
值出栈并相加,然后将相加的结果重新入栈。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再验证这一点。再以上面的
iadd
指令为例,这个指令只能用于整型数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int
型,不能出现一个 long 和一个 float 使用iadd
命令相加的情况。另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了,重叠的过程如下图:
Java 虚拟机的解释执行引擎被称为 “基于栈的执行引擎”,里面的 “栈” 就是操作数栈。
-
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。我们知道 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就成为动态链接。
-
返回方法地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为 “正常调用完成”(Normal Method Invocation Completion)。
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是 Java 虚拟机内部产生的异常,还是代码中使用
athrow
字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出、这种退出方法的方式称为 “异常调用完成(Abrupt Method Invocation Completion)”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的 PC 计数器的值就可以作为异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
-
附加信息
《Java 虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。一般会把动态链接、方法返回地址与其它附加信息全部归为一类,称为栈帧信息。
栈帧的概念结构图如下:
-
-
-
本地方法栈(Native Method Stacks)
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机(譬如 HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出
StackOverflowError
和OutOfMemoryError
异常。 -
Java 堆(Java Heap)
对于 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里 “几乎” 所有的对象实例都在这里分配内存。**在 《Java 虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在堆上分配。**原文如下:
The heap is the runtime data area from which memory for all class instances and arrays is allocated.
随着 Java 语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即使编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说 Java 对象实例都分配在堆上也渐渐变得不是那么绝对了。
Java 堆是垃圾收集器管理的内存区域,因此一些资料中也被称作 “GC 堆”(Garbage Collected Heap)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以 Java 堆中经常会出现 “新生代” “老年代” “永久代” “Eden 空间” “From Survivor 空间” “To Survivor 空间” 等名词。
如果从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将 Java 堆细分的目的是为了更好地回收内存,或者更快地分配内存。
根据《Java 虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
Java 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数
-Xmx
和-Xms
设定)。如果 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出OutOfMemoryError
异常。 -
方法区(Method Area)
**方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。**虽然《Java 虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作 “非堆”(Non-Heap),目的是与 Java 堆区分开来。
说到方法区,不得不提一下 “永久代” 这个概念,尤其是在 JDK 8 以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为 “永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的 HotSpot 虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot 的垃圾收集器能够像管理 Java 一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java 虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了 Java 应用更容易遇到内存溢出的问题(永久代有
-XX:MaxPermSize
的上限,即使不设置也有默认大小,而 J9 和 JRockit 只要没有触碰到进程可用内存的上限,例如 32 位系统中的 4GB 限制,就不会出问题),而且有极少数方法(例如String::intern()
)会因永久代的原因导致不同虚拟机下有不同的表现。当 Oracle 收够 BEA 获得了 JRockit 的所有权后,准备把 JRockit 中的优秀功能,譬如 Java Mission Control 管理工具,移植到 HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到 HotSpot 未来的发展,在 JDK 6 的时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了 JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Meta space)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。《Java 虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样 “永久” 存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄露。
根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出
OutOfMemoryError
异常。-
运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
Java 虚拟机对于 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,如每个字节用于存储哪种数据都必须符合规范上的要求会被虚拟机认可、加载和执行,但对于运行时常量池,《Java 虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是
String
类的intern()
方法。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出
OutOfMemoryError
异常。
-
-
直接内存(Direct Memory)
直接内存并不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致
OutOfMemoryError
异常出现,所以我们放到这里一起来了解。在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能再一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置
-Xmx
等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError
异常。
5. 经典垃圾收集器
5.1 Serial 收集器 (新生代收集器)
Serial 收集器是最基础、历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是 HotSpot 虚拟机新生代收集器的唯一选择。只看名字就能够猜到,这个收集器是一个单线程工作的收集器,但它的 “单线程” 的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其它所有工作线程,直到它收集结束。“Stop The World” 这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。不妨试想一下,要是你的电脑每运行一个小时就会暂停响应 5 分钟,你会有什么样的心情?Serial/Serial Old 收集器运行示意图如下:
从 JDK 1.3 开始,一直到现在最新的 JDK 13,HotSpot 虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从 Serial 收集器到 Parallel 收集器,再到 Concurrent Mark Sweep(CMS)和 Garbage First(G1)收集器,最终至现在垃圾收集器的最前沿成果 Shenandoah 和 ZCG 等,我们看到了一个个越来越构思精巧、优秀、复杂的垃圾收集器不断涌现,用户线程的停顿时间在持续缩短,但是仍然没有办法彻底消除(这里不去讨论 RTSJ 中的收集器),探索更优秀垃圾收集器的工作仍然在继续。
迄今为止,它依然是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器,有着优于其它收集器的地方,那就是简单而高效(与其它收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的。对于单核处理器或处理器核心数较少的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
5.2 ParNew 收集器 (新生代收集器)
ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio
、-XX:PretenureSizeThreshold
、-XX:HandlePromotionFailure
等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如下图:

ParNew 收集器除了支持多线程并行收集之外,其它与 Serial 收集器相比并没有太多创新之外,但它却是不少运行在服务端模式下的 HotSpot 虚拟机,尤其是 JDK 7 之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。
在 JDK 5 发布时,HotSpot 推出了一款在强交互应用中几乎可称为具有划时代意义意义的垃圾收集器——CMS 收集器。这款收集器是 HotSpot 虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。
遗憾的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。ParNew 收集器是激活 CMS 后(使用 -XX:+UseConcMarkSweepGC
选项)的默认新生代收集器,也可以使用 -XX:+/-UseParNewGC
选项来强制指定或者禁用它。
可以说直到 CMS 的出现才巩固了 ParNew 的地位,但成也萧何败也萧何,随着垃圾收集器技术的不断改进,更先进的 G1 收集器带着 CMS 继承者和替代者的光环登场。G1 是一个面向全堆的收集器,不再需要其它新生代收集器的配合工作。所以自 JDK 9 开始,ParNew 加 CMS 收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被 G1 所取代,甚至还取消了 ParNew 加 Serial Old 以及 Serial 加 CMS 这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了 -XX:+UseParNewGC
参数,这意味着 ParNew 和 CMS 从此只能互相搭配使用,再也没有其它收集器能够和它们配合了。ParNew 可以说是 HotSpot 虚拟机中第一款退出历史舞台的垃圾收集器。
ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越 Serial 收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew 对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程与处理器核心数量相同,在处理器核心非常多(譬如 32 个,现在 CPU 都是多核加超线程设计,服务器达到或超过 32 个逻辑核心的情况非常普遍)的环境中,可以使用 -XX:ParallelGCThreads
参数来限制垃圾收集的线程数。
**remark:**从 ParNew 收集器开始,后面还将会接触到若干款涉及 “并发” 和 “并行” 概念的收集器。在可能产生疑惑之前,有必要先解释这两个名词。并发和并行都是并发编程中的专业名词:
- 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
- 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明统同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
5.3 Parallel Scavenge 收集器 (新生代收集器)
Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记—复制算法实现的收集器,也是能够并行收集的多线程收集器…Parallel Scavenge 的诸多特性从表面上看和 ParNew 非常相似,那它有什么特别之处呢?
Parallel Scavenge 收集器的特点是它的关注点与其它收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)
如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis
参数允许的值时一个大于 0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次,每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
-XX:GCTimeRatio
参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5%(即 1 / (1 + 19)),默认值是 99,即允许最大 1%(即 1 / (1 + 99))的垃圾收集时间。
由于与吞吐量关系密切,Parallel Scavenge 收集器也常被称作 “吞吐量优先收集器”。除了上述两个参数之外,Parallel Scavenge 收集器还有一个参数 -XX:+UseAdaptiveSizePolicy
值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn
)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio
)、晋升老年代对象大小(-XX:PretenureSizeThreshold
)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。如果对于收集器运作不太了解,手工优化存在困难的话,可以使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置设置好(如 -Xms
设置最大堆),然后使用 -XX:MaxGCPauseMillis
参数(更关注最大停顿时间)或 -XX:GCTimeRatio
(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是 Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特性。
5.4 Serial Old 收集器 (老年代收集器)
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记—整理算法。这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在 JDK 5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另外一种就是作为 CMS 收集器发生失败时的后预案,在并发收集发生 Concurrent Mode Failure 时使用。这两点都将在后面的内容中继续讲解。Serial Old 收集器的工作过程如下图所示:
5.5 Parallel Old 收集器 (老年代收集器)
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记—整理算法实现。这个收集器是直到 JDK 6 时才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于相当尴尬的状态,原因是如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器以外别无选择,其它变现良好的老年代收集器,如 CMS 无法与它配合工作。由于老年代 Serial Old 收集器在服务端应用性能上的 “拖累”,使用 Parallel Scavenge 收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比 ParNew 加 CMS 的组合来的优秀。
直到 Parallel Old 收集器出现后,“吞吐量优先” 收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合。Parallel Old 收集器的工作过程如下图所示:
**remark:**需要说明一下,Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器来进行老年代收集,并非直接调用 Serial Old 收集器,但是这个 PS MarkSweep 收集器与 Serial Old 的实现几乎是一样的,所以在官方的资料中都是直接以 Serial Old 代替 PS MarkSweep 进行讲解。
5.6 CMS 收集器 (老年代收集器)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS 收集器就非常符合这类应用的需求。
从名字(包含 “Mark Sweep”)上就可以看出 CMS 收集器是基于标记—清除算法实现的,它的运作过程相对于前面几种收集器来说要复杂一些,整个过程分为四个步骤,包括:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要 “Stop The World”。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快;并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时比较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
由于整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。通过如下图可以比较清除地看到 CMS 收集器的运作步骤中并发和需要停顿的阶段:
CMS 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为 “并发低停顿收集器”(Concurrent Low Pause Collector)。CMS 收集器是 HotSpot 虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:
- 首先,CMS 收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS 默认启动的回收线程数是(处理器核心数量 + 3) / 4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25% 的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS 对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为 “增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的 CMS 收集器变种,所做的事情和以前单核处理器年代 PC 机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,实在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的 CMS 收集器效果很一般,从 JDK 7 开始,i-CMS 模式已经被声明为 “deprecated”,即已过时不再提倡用户使用,到 JDK 9 发布后 i-CMS 模式被完全废弃。
- 然后,由于 CMS 收集器无法处理 “浮动垃圾”(Floating Garbage),有可能出现 “Concurrent Mode Failure” 失败进而导致另一次完全 “Stop The World” 的 Full GC 的产生。在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象时出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为 “浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在 JDK 5 的默认设置下,CMS 收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数
-XX:CMSInitiatingOccupancyFraction
的值来提高 CMS 的触发百分比,降低内存回收频率,获取更好的性能。到了 JDK 6 时,CMS 收集器的启动阈值就已经默认提升至 92%。但这又会更容易面临另一种风险:要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次 “并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction
设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。 - 还有最后一个缺点,CMS 是一款基于 “标记—清除” 算法实现的收集器,在收集时会大量的产生空间碎片。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。为了解决这个问题,CMS 收集器提供了一个
-XX:UseCMSCompactAtFullCollection
开关参数(默认是开启的,此参数从 JDK 9 开始废弃),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在 Shenandoah 和 ZGC 出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction
(此参数从 JDK 9 开始废弃),这个参数的作用是要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。
5.7 Garbage First (不固定回收新生代/老年代)
Garbage First(简称 G1)收集器是垃圾收集器技术发展史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。 早在 JDK 7 刚刚确立项目目标、Oracle 公司制定的 JDK 7 RoadMap 里面,G1 收集器就被视作 JDK 7 中 HotSpot 虚拟机的一项重要进化特征。从 JDK 6 Update 14 开始就有 Early Access 版本的 G1 收集器供开发人员实验和试用,但由此开始 G1 收集器的 “实验状态”(Experimental)持续了数年时间,直至 JDK 7 Update 4,Oracle 才认为它达到足够成熟的商用程度,移除了 “Experimental” 的标识;到了 JDK 8 Update 40 的时候,G1 提供并发的类卸载的支持,补全了其计划功能的最后一块拼图。这个版本以后的 G1 收集器才被 Oracle 官方称为 “全功能的垃圾收集器”(Fully-Featured Garbage Collector)。
G1 是一款主要面向服务端应用的垃圾收集器。HotSpot 开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉 JDK 5 中发布的 CMS 收集器。现在这个期望目标已经实现过半了,JDK 9 发布之日,G1 宣告取代 Parallel Scavenge 加 Parallel Old 组合,成为服务端模式下的默认垃圾收集器,而 CMS 则沦落至被声明为不推荐使用(Deprecate)的收集器。如果对 JDK 9 及以上版本的 HotSpot 虚拟机使用参数 -XX:UseConcMarkSweepGC
来开启 CMS 收集器的话,用户会收到一个警告信息,提示 CMS 未来将会被废弃:
Java HotSpot(TM) 64-Bit Server VM warning: Option UseConcMarkSweepGC was
deprecated in version 9.0 and will likely be removed in a future release.
但作为一款曾被广泛运用过的收集器,经过多个版本的开发迭代后,CMS(以及之前几款收集器)的代码与 HotSpot 的内存管理、执行、编译、监控等子系统都有千丝万缕的联系,这是历史原因导致的,并不符合职责分离的设计原则。为此,规划 JDK 10 功能目标时,HotSpot 虚拟机提出了 “统一垃圾收集器接口”,将内存回收的 “行为” 与 “实现” 进行分离,CMS 以及其它收集器都重构成基于这套接口的一种实现。以此为基础,日后要移除或者加入某一款收集器,都会变得容易许多,风险也可以控制,这算是在为 CMS 退出历史舞台铺下最后的道路了。
作为 CMS 收集器的替代者和继承人,设计者们希望做出一款能够建立起 “停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标,这几乎已经是实时 Java(RTSJ)的中软实时垃圾收集器特征了。
G1 的出现,首先要有一个思想上的改变,在 G1 收集器出现之前的所有其它收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个 Java 堆(Full GC)。而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。
G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。**虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其它收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。**收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数 -XX:G1HeapRegionSize
设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待,如下图所示:
虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。**G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。**更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的 “价值” 大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数 -XX:MaxGCPauseMillis
指定,默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是 “Garbage First” 名字的由来。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。
G1 将堆内存 “化整为零” 的 “解题思路”,看起来似乎没有太多令人惊讶之处,也完全不难理解,但其中的实现细节可是远远没有想象中那么简单,否则就不会从 2004 年 Sun 实验室发表第一篇关于 G1 的论文后一直拖到 2012 年 4 月 JDK 7 Update 4 发布,用将近 10 年时间才倒腾出能够商用的 G1 收集器来。G1 收集器至少有(不限于)以下这些关键的细节问题需要妥善解决:
- 譬如,将 Java 堆分成多个独立 Region 后,Region 里面存在的跨 Region 引用对象如何解决?解决的思路我们已经知道:使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集的应用其实要复杂很多,它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1 的记忆集在存储结构的本质上是一种哈希表,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表的索引号。这种 “双向” 的卡表结构(卡表是 “我指向谁”,这种结构还记录了 “谁指向我”)比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此 G1 收集器要比其它的传统垃圾收集器有着更高的内存占用负担。根据经验,G1 至少要耗费大约相当于 Java 堆容量 10% 至 20% 的额外内存来维持收集器工作。
- 譬如,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法:CMS 收集器采用增量更新算法实现,而 G1 收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1 为每一个 Region 设计了两个名为 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与 CMS 中的 “Concurrent Mode Failure” 失败会导致 Full GC 类似,如果内存回收的速度赶不上内存分配的速度,G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间 “Stop The World”。
- 譬如,怎样建立起可靠地停顿预测模型?用户通过
-XX:MaxGCPauseMillis
参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但 G1 收集器要怎么做才能满足用户的期望呢?G1 收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的 “衰减平均值” 是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表 “最近的” 平均状态。换句话说,Region 的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些 Region 组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1 收集器的运作过程大致可以划分为以下四个步骤:
- 初始标记(Initial Marking): 仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking): 从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
- 最终标记(Final Marking): 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
- 筛选回收(Live Data Counting and Evacuation): 负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
从上阶段的描述可以看出,G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控地情况下获得尽可能高的吞吐量,所以才能担当起 “全功能收集器” 的重任和期望。
从 Oracle 官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到 G1 只是回收一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器(即 ZGC)中。另外,还考虑到 G1 不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。G1 收集器运行示意图如下所示:
毫无疑问,可以由用户指定期望的停顿时间是 G1 收集器很强大的一个功能,设置不同的期望停顿时间,可使得 G1 在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的 “期望值” 必须是符合实际的,不能异想天开,毕竟 G1 是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发 Full GC 反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。
从 G1 开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个 Java 堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思想从工程实现上看是从 G1 开始兴起的,所以说 G1 是收集器技术发展的一个里程碑。
相比 CMS,G1 的优点有很多,暂且不论可以指定最大停顿时间、分 Region 的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1 也更有发展潜力。在 CMS 的 “标记—清除” 算法不同,G1 从整体来看是基于 “标记—整理” 算法实现的收集器,但从局部(两个 Region 之间)上看又是基于 “标记—复制” 算法实现,无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发一次收集。
不过,G1 相对于 CMS 仍然不是占全方位、压倒性优势的,从它出现几年仍不能再所有应用场景中代替 CMS 就可以得知这个结论。比起 CMS,G1 的弱项也可以列举出不少,如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比 CMS 要高。
就内存占用来说,虽然 G1 和 CMS 都使用卡表来处理跨代指针,但 G1 的卡表实现更为复杂,而且堆中每个 Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致 G1 的记忆集(和其它内存消耗)可能会占整个堆容量的 20% 乃至更多的内存空间;相比起来 CMS 的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。
在执行负载的角度上,同样由于两个收集器各自的实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS 用写后屏障来更新维护卡表;而 G1 除了使用写后屏障来进行同样的(由于 G1 的卡表结构复杂,其实是更繁琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免 CMS 那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于 G1 对写屏障的复杂操作要比 CMS 消耗更多的运算资源,所以 CMS 的写屏障实现是直接的同步操作,而 G1 就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情放到消息队列里,然后再异步处理。
以上的优缺点对比仅仅是针对 G1 和 CMS 两款垃圾收集器单独单方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照实践经验,目前在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势,这个优劣势的 Java 堆容量平衡点通常在 6GB 至 8GB 之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣实际测试才能得出最合适的结论,随着 HotSpot 的开发者对 G1 的不断优化,也会让对比结果继续向 G1 倾斜。