HotSpot 垃圾收集器详解
在 上文 中,我们详细介绍了 JVM 垃圾收集算法的核心原理。本文将趁热打铁,进一步介绍 HotSpot 虚拟机中基于这些算法实现的常用垃圾收集器的工作原理。
Serial 收集器
Serial 是一款基于 标记-复制算法 实现的新生代收集器,他是 Hotspot 中历史最悠久、最基础的垃圾收集器。
“Serial” 这个名称源自其串行收集的特性,它的垃圾回收过程是单线程的,同时垃圾回收和用户线程之间也是串行执行的,示意图如下:
|
|
Serial Old 收集器
Serial Old 是一款基于 标记整理算法 的老年代收集器,是 Serial 收集器的老年代版本,示意图如下:
|
|
该收集器主要用于客户端的老年代收集,而在服务端通常只作为 CMS 收集器并发失败 (Concurrent Mode Failure1) 时的兜底方案。
ParNew 收集器
ParNew 收集器是 Serial 收集器的并行版本,它在用户线程与 Young GC 之间依旧保持串行的基础上,实现了 Young GC 过程的多线程并行,除此之外与 Serial 收集器一致。示意图如下:
|
|
ParNew 收集器最重要的特性是可以与 CMS 收集器配合使用,而 Parallel Scavenge 收集器则不具备该特性。
Parallel Scavenge 收集器
Parallel Scavenge 也是一款基于标记-复制算法的新生代收集器,它与 ParNew 收集器在多个方面类似,不过该收集器的设计目标是实现可控的吞吐量,而非缩短用户线程的停顿时间。
吞吐量 (Throughput) 是指处理器用于运行用户代码地时间,与处理器总消耗时间地比值,公式如下:
$$ 吞吐量 = \frac{用户线程运行时间}{用户线程运行时间 + GC 耗时} $$
Parallel Scavenge 收集器提供了两个参数来精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis
和直接设置吞吐量大小的 -XX:GCTimeRatio
:
-XX:MaxGCPauseMillis
参数允许的值是大于 0 的毫秒数,收集器会尽力保证内存回收花费的时间不超过用户设置的值。警告不要误以为将此参数设置得更小,就能使系统的垃圾收集速度更快。缩短垃圾收集停顿时间是以牺牲吞吐量和新生代空间为代价实现的,举个例子:系统将新生代空间设置得更小,虽然收集 300MB 新生代比收集 500MB 要快,但也会导致垃圾收集更加频繁。以前是每隔 10 秒进行一次收集,每次停顿 100 毫秒,而现在则变成了每隔 5 秒进行一次收集,每次停顿 70 毫秒。尽管停顿时间确实有所下降,但吞吐量也相应地降低了。-XX:GCTimeRatio
参数的值应为大于 0 小于 100 的整数,表示垃圾收集时间占总时间的比例,相当于吞吐量的倒数。- 例如:如果将此参数设置为 19,则最大允许的垃圾收集时间占总时间的 5%(即 $\frac{1}{1 + 19}$);
- 默认值为 99,表示最大允许 1%(即 $\frac{1}{1 + 99}$)的垃圾收集时间。
除了上述两个参数,Parallel Scavenge 还支持自适应调节 Eden 和 Survivor 比例,参数为 -XX:+UseAdaptiveSizePolicy
。启用此参数后,不需要手动指定新生代的大小 (-Xmn
)、Eden 和 Survivor 区的比例 (-XX:SurvivorRatio
)、晋升老年代对象的大小 (-XX:PretenureSizeThreshold
) 等细节参数。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或最大的吞吐量。
Parallel Old 收集器
Parallel Old 也是一款基于 标记整理算法 的老年代收集器,从 JDK 6 开始提供,是 Parallel Scavenge 收集器的老年代版本,支持多线程并发 Old GC。与 Parallel Scavenge 收集器配合使用以实现“吞吐量优先”的 GC。示意图如下:
|
|
在处理器资源较为稀缺且注重吞吐量的场景,可以优先考虑 Parallel Scavenge + Parallel Old 这个组合。
CMS 收集器
CMS (Concurrent Mark Sweep) 收集器是一款以最短停顿时间为目标的老年代收集器,因此采用了 标记-清除算法。它的运行过程相对复杂,分为以下四个阶段:
- 初始标记 (initial mark):需要用户线程停顿,但仅仅记录 GC Roots 能直接关联到的对象,速度很快;
- 并发标记 (concurrent mark):从 GC Roots 的直接关联对象开始遍历整个对象图。这个过程耗时较长,但不需要用户线程停顿,可以与用户线程并发执行;
- 重新标记 (remark):需要用户线程停顿,该阶段是 增量更新算法 的应用,负责修正并发标记期间发生变动的引用关系。该阶段需要的用户线程停顿时间比初始标记阶段长一些,但远比并发标记阶段的时间短;
- 并发清除 (concurrent sweep):该阶段会清理标记阶段已经判定为消亡的对象。由于采用标记-清除算法,不需要移动对象,因此可以与用户线程并发执行。
其示意图如下:
|
|
CMS 收集器是 HotSpot 虚拟机追求低延迟的第一次成功尝试,但它还远没有达到完美的程度,至少有以下三个缺点:
CMS 收集器对处理器资源非常敏感,特别是在处理器核心数量不足四个时可能对应用程序的执行速度造成影响:
面向并发设计的程序都对处理器资源比较敏感。CMS 默认启动的回收线程数是 $(core\_nums + 3) / 4$,也就是说,如果处理器核心数在四个或以上,并发回收时 GC 线程只占用不到 25% 的 CPU 资源,并且占比会随着 CPU 核心数的增加而下降。但是当处理器核心数不足四个时,并发回收对用户程序的影响就会特别明显。
为了缓解这一问题,HotSpot 后来尝试过增量式并发收集 (i-CMS),它允许收集器线程和用户线程交替运,然而实践证明效果并不显著。因此在 JDK 7 中被标记为过时,直到 JDK 9 被完全废弃。
CMS 收集器无法处理“浮动垃圾”,可能导致 Concurrent Mode Failure1 从而触发 Full GC 导致 “Stop The World”:
在 CMS 的并发标记和清理阶段,用户线程继续运行会产生新的垃圾,但这些对象出现在标记过程结束后,CMS 无法在当次收集中处理掉它们,只好留到下一次垃圾收集时清理。所以 CMS 收集器不能等到老年代几乎被填满才进行收集,必须预留一部分空间给用户线程使用。
- 在 JDK 5 的默认设置下,CMS 收集器在老年代使用了 68% 的空间后运行。且可以通过
-XX:CMSInitiatingOccupancyFraction
控制比例。 - 到了 JDK 6,CMS 收集器的启动阈值默认提升至 92%,但这样一来 Concurrent Mode Failure 的概率更大了,某些场景下性能反而更差 2。
所以用户应在生产环境中根据实际情况调整设置。
- 在 JDK 5 的默认设置下,CMS 收集器在老年代使用了 68% 的空间后运行。且可以通过
CMS 收集器使用“标记-清除”算法,在垃圾收集结束后会产生大量的内存碎片。这些碎片可能导致在内存充足的情况下仍无法分配大对象,从而提前触发 Full GC:
为了解决这个问题,CMS 收集器提供了一个开关参数
(默认是开启的,此参数从 JDK 9 开始废弃),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程。由于这个内存整理必须移动存活对象,所以(在 Shenandoah 和 ZGC 出现前)是无法并发的,这样空间碎片问题是解决了,但停顿时间又会变长。-XX:+UseCMS-CompactAtFullCollection
因此设计者们又提供了另外一个参数
(此参数也从 JDK 9 开始废弃),这个参数的作用是要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。-XX:CMSFullGCsBefore-Compaction
G1 (Garbage First) 收集器
G1 是一款面向服务端应用的垃圾收集器,是 CMS 收集器的继任者和替代者,其设计初衷是能够建立“停顿时间模型”。所谓“停顿时间模型”是指在 M
毫秒时间段内,垃圾收集过程的用时不会超过 N
毫秒。这几乎达到了软实时垃圾收集器的要求。
在 G1 收集器出现之前,包括 CMS 在内的其他所有收集器的垃圾收集目标范围要么是整个新生代 (Minor GC),要么是整个老年代 (Major GC),要么是整个 Java 堆 (Full GC)。而 G1 打破了这种限制,它可以针对堆内存中的任何部分来组成回收集 (Collection Set, CSet) 进行垃圾回收,衡量标准不再是对象所属的代,而是哪块内存中包含的垃圾数量最多、回收收益最大。这就是 G1 收集器的 Mixed GC 模式。
虽然 G1 仍然遵循分代收集理论的设计,但其堆内存布局与其他收集器有显著的差异:G1 不再坚守固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等、独立的区域 (Region),可以通过 -XX:G1HeapRegionSize
参数设置 Region 的大小,取值范围为 1MB 至 32MB,且为 2 的 N 次幂。示意图如下:
![G1 Region 示意图](/blog/posts/detailed-explanation-of-hotspot-garbage-collector/images/g1-region.webp)
- 每个 Region 都可以扮演新生代的 Eden 空间、Survivor 空间和老年代空间。收集器针对不同角色的 Region 采用不同的策略进行处理,这样新创建的对象和已经存活了一段时间的旧对象都能获得很好的收集效果。
- 专门用来存放大对象的 Region 被称作 Humongous Region。G1 将大小超过一个 Region 容量一半的对象称为大对象,对于超过整个 Region 容量的超大对象,它们将存放在 N 个连续的 Humongous Region 中。G1 的大多数操作将 Humongous Region 视为老年代。
G1 如何建立停顿时间模型
G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全域收集。更具体的处理思路是让 G1 收集器去跟踪各个 Region 的回收“价值”大小,即回收所获得的空间大小以及回收所需的时间(估计值),然后在后台维护一个优先级列表,每次根据用户设定的允许收集停顿时间(使用参数 -XX:MaxGCPauseMillis
指定,默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是 “Garbage First” 名字的由来。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。
G1 实现的技术难点
G1 收集器将堆内存“化整为零”的思想看似简单,实现起来却相当的复杂。这也是 G1 收集器打磨了近 10 年才商用的原因。其面临的技术难点包括但不限于以下几点:
JVM 普遍使用“记忆集”来解决跨代引用问题,然而在 G1 收集器中实现这一功能会更加困难:
G1 收集器的每个 Region 都维护自己的记忆集。记忆集存储结构本质上是一种哈希表,以别的 Region 的起始地址为 Key,对应的 Value 是一个集合,记录了指向该 Region 的指针在哪些卡页范围之内。与传统卡表不同的是,G1 收集器的记忆集是双向的,既记录了“我指向谁”,又记录了“谁指向我”。这种复杂的卡表结构,加上 G1 收集器的 Region 数量明显要比传统收集器的分代数量多,导致 G1 收集器的内存占用更大。根据经验,为了维持收集器工作,G1 至少需要额外占用 Java 堆容量的 10% 至 20% 的内存。
如何在并发标记阶段保证收集线程与用户线程互不干扰地运行:
这里需要解决的首要问题是如何在用户线程改变对象引用关系时,确保对象图结构不被破坏以避免标记结果出现错误。CMS 收集器采用增量更新算法实现,而 G1 收集器则通过 原始快照 (SATB) 算法 实现。
此外,垃圾收集对用户线程的影响还体现在新创建对象的内存分配上,因为程序在继续运行时肯定会持续创建新对象。为了解决这个问题,G1 为每个 Region 设计了两个名为 TAMS (Top at Mark Start) 的指针,将 Region 中的一部分空间用于并发回收过程中的新对象分配,而在并发回收时,新分配对象的地址必须在这两个指针位置以上。G1 收集器默认将这个地址以上的对象视为存活对象,不进行回收。如果内存回收速度跟不上内存分配速度,G1 收集器就会像 CMS 中的 “Concurrent Mode Failure” 一样,被迫冻结用户线程执行 Full GC。
如何建立起可靠的停顿预测模型:
用户通过
-XX:MaxGCPauseMillis
参数指定的停顿时间只是垃圾收集发生之前的期望值,G1 收集器是如何满足用户期望的呢?G1 收集器的停顿预测模型是以衰减平均值为理论基础实现的。在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量步骤的成本,并分析得出平均值、标准偏差、置信度等统计信息。
这里强调的“衰减平均值”比普通的平均值更容易受到新数据的影响,它更准确地代表“最近的”平均状态。换句话说,Region 的统计状态越新,就越能决定其回收的价值。通过这些信息,G1 收集器可以预测哪些 Region 可以组成回收集,在不超过期望停顿时间的约束下获得最高的收益。
G1 运行过程的大致步骤
如果我们不考虑用户线程运行期间的动作(例如使用写屏障维护记忆集),G1 收集器的运行过程大致可以分为以下四个步骤:
- 初始标记 (initial mark):仅仅标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短。
- 并发标记 (concurrent mark):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 中记录的引用变动对象。
- 最终标记 (final mark):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
- 筛选回收 (live data counting and evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集 (Collection Set),然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,所以必须暂停用户线程,但会由多条收集器线程并行完成。
该过程操作以 Region 的视角看,使用的是标记-复制算法。但站在全局视角,使用的则是标记整理算法。
运行示意图如下:
|
|
从上述对各阶段的描述可以看出,G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。
从 Oracle 官方透露出来的信息可知,回收阶段 (Evacuation) 其实本也有想过设计成与用户程序一起并发执行,但实现起来比较复杂,考虑到 G1 只是回收一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器 ZGC 中。
从 G1 开始,后续的垃圾收集器设计都转向追求更上应用程序的内存分配速率,而不再强调一次性完全清理整个 Java 堆。这种设计理念意味着收集器在分配对象的同时进行垃圾收集,只要收集速度能跟上对象分配的速度,系统就能够顺畅运行。这种新的收集器设计思路从 G1 开始逐渐形成,因此 G1 可视为垃圾收集技术发展的一个里程碑。