Hotspot 经典垃圾收集器

Hotspot 中的经典垃圾收集器虽然用的不是最先进的技术,但它们都经过了实践的检验,已经足够成熟,可以放心用于商业环境。

1 Serial 收集器

Serial 是一款基于 标记-复制算法 的新生代收集器,他是 Hotspot 中历史最悠久、最基础的收集器。

“Serial” 这个名称源自其串行收集的特性,它的垃圾回收过程是单线程的,同时垃圾回收和用户线程之间也是串行执行的,示意图如下:

1
2
3
4
5
6
       用户线程      标记-复制算法     用户线程
CPU 0 ---------->|    Young GC     |---------->
CPU 1 ---------->| ==============> |---------->
CPU 2 ---------->|                 |---------->
CPU n ---------->|                 |---------->
             Safepoint

2 Serial Old 收集器

Serial Old 是一款基于 标记-整理算法 的老年代收集器,是 Serial 收集器的老年代版本,示意图如下:

1
2
3
4
5
6
       用户线程      标记-整理算法     用户线程
CPU 0 ---------->|     Old GC      |---------->
CPU 1 ---------->| ==============> |---------->
CPU 2 ---------->|                 |---------->
CPU n ---------->|                 |---------->
             Safepoint

该收集器主要用于客户端的老年代收集,而在服务端通常作为 CMS 收集器并发失败 (Concurrent Mode Failure) 时的备用方案。

3 ParNew 收集器

ParNew 收集器是 Serial 收集器的并行版本,它在用户线程与 Young GC 之间依旧保持串行的基础上,实现了 Young GC 过程的并发执行,除此之外与 Serial 收集器一致。示意图如下:

1
2
3
4
5
6
       用户线程      标记-复制算法     用户线程
CPU 0 ---------->|    Young GC 1   |---------->
CPU 1 ---------->| ==============> |---------->
CPU 2 ---------->|    Young GC 2   |---------->
CPU n ---------->| ==============> |---------->
              Safepoint

ParNew 收集器最重要的特性是可以与 CMS 收集器配合使用,而 Parallel Scavenge 收集器则不具备该特性。

4 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 Scavenge 无法与 CMS 配合使用,因此在 Parallel Old 出现之前它只能与 Serial Old 配合,处境非常尴尬。在 Serial Old 的性能拖累下,吞吐量甚至不如 PerNew + CMS 组合。

5 Parallel Old 收集器

Parallel Old 是一款基于 标记-整理算法 的老年代收集器,从 JDK 6 开始提供,是 Parallel Scavenge 收集器的老年代版本,支持多线程并发 Old GC,并与 Parallel Scavenge 收集器配合使用以实现“吞吐量优先”的 GC。示意图如下:

1
2
3
4
5
6
       用户线程      标记-整理算法     用户线程
CPU 0 ---------->|    Old GC 1     |---------->
CPU 1 ---------->| ==============> |---------->
CPU 2 ---------->|    Old GC 2     |---------->
CPU n ---------->| ==============> |---------->
             Safepoint

处理器资源较为稀缺或者注重吞吐量的场景,可以优先考虑 Parallel Scavenge + Parallel Old 这个组合。

6 CMS 收集器

CMS (Concurrent Mark Sweep) 收集器是一款以最短停顿时间为目标的老年代收集器,基于 标记-清除算法。它的运行过程相对复杂,分为以下四个阶段:

  1. 初始标记 (initial mark):需要用户线程停顿,但仅仅记录 GC Roots 能直接关联到的对象,速度很快;
  2. 并发标记 (concurrent mark):阶段是从 GC Roots 的直接关联对象开始遍历整个对象图的过程。这个过程耗时较长,但不需要用户线程停顿,可以与 GC 线程并发执行;
  3. 重新标记 (remark):需要用户线程停顿,该阶段是 增量更新算法的应用,是为了修正并发标记期间由于用户线程的操作而导致引用关系变动的那一部分对象的标记记录。这个阶段需要的用户线程停顿时间比初始标记阶段长一些,但远比并发标记阶段的时间短;
  4. 并发清除 (concurrent sweep):该阶段会清理标记阶段已经判定为死亡的对象。由于采用 标记-清除算法,不需要移动对象,因此可以与用户线程并发执行。

其示意图如下:

1
2
3
4
5
6
7
8
9
  用户线程 1               用户线程 1               用户线程 1    用户线程 1
---------->|           |---------->|           |---------->|---------->
  用户线程 2    初始标记    用户线程 2    重新标记    用户线程 2    用户线程 2
---------->|==========>|---------->|==========>|---------->|---------->
  用户线程 3               *并发标记                *并发清理     重置线程
---------->|           |==========>|           |==========>|======>||=>
  用户线程 4               用户线程 3                用户线程 3    用户线程 3
---------->|           |---------->|           |---------->|---------->
       Safepoint   Safepoint   Safepoint   Safepoint    Safepoint

CMS 收集器是 HotSpot 虚拟机追求低延迟的第一次成功尝试,但它远没有达到完美的程度,至少有以下三个缺点:

  • CMS 收集器对处理器资源非常敏感,特别是在处理器核心数量不足四个时可能对应用程序的执行速度造成影响

    面向并发设计的程序都对处理器资源比较敏感。在并发阶段,CMS 虽然不会阻塞用户线程,但却会因为占用 CPU 资源而拖慢用户程序,降低总吞吐量。

    CMS 默认启动的回收线程数是 $(core\_nums + 3) / 4$,也就是说,如果处理器核心数在四个或以上,并发回收时 GC 线程只占用不到 25% 的 CPU 资源,并且占比会随着 CPU 核心数的增加而下降。但是当处理器核心数不足四个时,并发回收对用户程序的影响就会特别明显。

    为了缓解这种情况,虚拟机提供了增量式并发收集器 (i-CMS) 作为 CMS 收集器的变种,它可以让收集器线程和用户线程交替运行,以减少垃圾收集线程占用资源的时间。然而,实践证明 i-CMS 的效果一般,并从 JDK 7 开始被视为过时技术,JDK 9 更是完全废弃了这一模式。

  • CMS 收集器无法处理“浮动垃圾”,可能导致 “Concurrent Mode Failure” 从而触发 Full GC 导致 “Stop The World”

    在 CMS 的并发标记和清理阶段,用户线程继续运行会产生新的垃圾对象,但这些对象出现在标记过程结束后,CMS 无法在当次收集中处理掉它们,只好留到下一次垃圾收集时清理。

    而且在垃圾收集阶段用户线程需要持续运行,所以 CMS 收集器不能等到老年代几乎被填满才进行收集,必须预留一部分空间给用户线程使用。

    • JDK 5 的默认设置下,CMS 收集器老年代使用了 68% 的空间后会被激活,可以适当调高参数 -XX:CMSInitiatingOccupancyFraction 来提高 CMS 的触发百分比,获取更好的性能。
    • 到了 JDK 6,CMS 收集器的启动阈值默认提升至 92%,但这会更容易面临并发失败的风险。

    参数 -XX:CMSInitiatingOccupancyFraction 设置得太高将容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际情况权衡设置。

    当出现并发失败时,虚拟机将启用 Serial Old 收集器重新进行垃圾收集,停顿时间大幅延长。

  • CMS 收集器基于 “标记-清除” 算法实现,在收集结束时会产生大量空间碎片。当空间碎片过多时,就会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但却找不到足够大的连续空间来分配当前对象,只能提前触发一次 Full GC 的情况

    为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMS-CompactAtFullCollection 开关参数(默认是开启的,此参数从 JDK 9 开始废弃),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程。由于这个内存整理必须移动存活对象,所以(在 Shenandoah 和 ZGC 出现前)是无法并发的,这样空间碎片问题是解决了,但停顿时间又会变长。

    因此虚拟机设计者们还提供了另外一个参数 -XX:CMSFullGCsBefore-Compaction(此参数也从 JDK 9 开始废弃),这个参数的作用是要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。

7 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 示意图
  • 每个 Region 都可以扮演新生代的 Eden 空间、Survivor 空间或老年代空间。收集器针对不同角色的 Region 采用不同的策略进行处理,这样新创建的对象和已经存活了一段时间的旧对象都能获得很好的收集效果。
  • 专门用来存放大对象的 Region 被称作 Humongous Region。G1 将大小超过一个 Region 容量一半的对象称为大对象,对于超过整个 Region 容量的超大对象,它们将存放在 N 个连续的 Humongous Region 中。G1 的大多数操作将 Humongous Region 视为老年代的一部分。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数 -XX:MaxGCPauseMillis 指定,默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是 “Garbage First” 名字的由来。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。

G1 收集器将堆内存“化整为零”的思想看似简单,但实际上在实现过程中涉及相当复杂的细节。这也是 Sun 实验室花费近 10 年时间才推出可商用的 G1 收集器的原因。G1 面临诸多关键的细节问题,包括但不限于以下几点:

  • 虚拟机通过使用“记忆集”来解决跨代引用问题,从而避免了对整个堆进行 GC Roots 扫描。然而,在 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 收集器的运行过程大致可以分为以下四个步骤:

  1. 初始标记 (initial mark):仅仅标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短。
  2. 并发标记 (concurrent mark):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  3. 最终标记 (final mark):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  4. 筛选回收 (live data counting and evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集 (Collection Set),然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,所以必须暂停用户线程,但会由多条收集器线程并行完成的。

    同一个操作,以 Region 的视角看,回收过程使用的是标记-复制算法,但站在全局视角,使用的就是标记-整理算法。

运行示意图如下:

1
2
3
4
5
6
7
8
9
  用户线程 1               用户线程 1    最终标记     筛选回收     用户线程 1
---------->|           |---------->|==========>|==========>|---------->
  用户线程 2    初始标记    用户线程 2    最终标记     筛选回收     用户线程 2
---------->|==========>|---------->|==========>|==========>|---------->
  用户线程 3               *并发标记    最终标记     筛选回收     用户线程 3
---------->|           |==========>|==========>|==========>|---------->
  用户线程 4               用户线程 3    最终标记     筛选回收     用户线程 4
---------->|           |---------->|==========>|==========>|---------->
       Safepoint   Safepoint   Safepoint                Safepoint
注释

从上述对各阶段的描述可以看出,G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。

从 Oracle 官方透露出来的信息可知,回收阶段 (Evacuation) 其实本也有想过设计成与用户程序一起并发执行,但实现起来比较复杂,考虑到 G1 只是回收一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器 ZGC 中。另外,还考虑到 G1 不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

从 G1 开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率 (Allocation Rate),而不再追求一次把整个 Java 堆全部清理干净。这样应用在分配的同时收集器也在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从 G1 开始兴起的,所以说 G1 是收集器技术发展的一个里程碑。


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容