Yujun's Blog
G1 GC的一些关键技术
G1 GC的一些关键技术
G1(Garbage-First)垃圾收集器自JDK 7正式发布以来,已成为服务端Java应用的主流选择,并在JDK 9中成为默认的垃圾收集器。
相较于简单的了解其特性,更重要的是构建一个关于G1内部机制的逻辑自洽的认知体系。
一、核心设计目标&基本架构选择
在G1之前,服务端Java应用的垃圾收集主要由Parallel Scavenge/Parallel Old
和CMS
主导。前者追求高吞吐量,但其全停顿(Stop-The-World, STW)的回收模式在大内存环境下会导致不可接受的长时间暂停。后者通过并发标记极大地缩短了停顿时间,但其基于“标记-清除”的算法带来了两个很严重的问题:内存碎片和与之相关的、可能由并发模式失败(Concurrent Mode Failure)触发的、灾难性的Full GC。
G1的设计旨在融合二者的优点并规避其缺点,它被设计为一款面向大内存(数十乃至数百GB)服务器的垃圾收集器,其核心设计目标可概括为:
在多核、大内存的服务器环境中,提供一种能够满足可预测(允许用户通过 -XX:MaxGCPauseMillis
参数设定)、短暂停顿时间(STW)的垃圾回收方案,避免内存碎片,同时维持较高的吞吐量。
这三大目标,尤其是第一个,成为了驱动G1所有架构设计和技术选择的“第一性原理”。
为实现“可预测停顿”,G1必须打破传统GC中回收范围与整个堆或分代大小强绑定的桎梏。G1的革命性一步:放弃物理连续的分代,引入Region化堆布局。
整个Java堆被划分为一组数量固定(通常是2048个)、大小相等(1MB到32MB之间)的独立区域,即Region。
与物理上连续的新生代和老年代不同,G1的分代是逻辑上的。每个Region在任意时刻都只属于一个分代角色:Eden、Survivor或Old。
此外,还有一类特殊的Humongous Region,用于存储大小超过Region容量一半的巨型对象。
这种设计将大规模、不可控的全堆回收问题,转化为了小规模、可控的部分区域回收问题。
GC的基本单位从整个分代缩小为单个或多个Region。这使得G1可以根据需要,自由选择一部分Region构成一个回收集合(Collection Set, CSet)进行回收。
通过控制CSet的大小和其中Region的复杂性,G1能够将单次GC的工作量化,从而向着用户设定的停顿时间目标靠拢。
同时,G1的回收算法基于“复制”算法。在回收CSet时,G1会将其中所有存活的对象拷贝到其他空闲的Region中,然后将CSet中的所有Region整体清空。这个过程被称为Evacuation(转移)。Evacuation天然地完成了内存的紧凑化整理,从而彻底避免了内存碎片的产生。
二、关键技术一:跨Region引用的高效追踪 - Remembered Set
Region化的架构虽然解决了回收范围的问题,但也带来了新的挑战:当回收一个Region集合(Collection Set, CSet)时,如何高效地找到所有从CSet外部指向CSet内部的引用?如果为此扫描整个堆,则Region化的优势将不复存在。
G1的解决方案是引入一种空间换时间的数据结构——Remembered Set (RSet)。
每个Region都关联一个RSet,用于记录其他Region中的对象引用本Region中对象的关系。更精确地说,RSet记录的不是对象的精确地址,而是引用来源所在的卡片(Card)的索引。
在GC时,特别是Young GC或Mixed GC,当确定了CSet后,GC线程不再需要扫描整个老年代或所有非CSet的Region。对于CSet中的每一个Region,GC线程只需遍历其RSet。RSet就像一张精确的“引路图”,告诉GC线程:“去检查Region X的第a、b、c号卡片,以及Region Y的第d、e号卡片,那里有指向我的引用。”这些被RSet指向的Card,连同线程栈、JNI引用等,共同构成了GC的根集合(Root Set)。
因此,G1 GC回收搜索复杂度就由之前的与堆相关,变成与CSet实现的复杂度相关。那么CSet实现是不是也很复杂呢?
RSet的实现相当精巧,它依赖于底层的Card Table和一套高效的异步更新机制。
Card Table是RSet的底层支撑。它是一个存在于本地内存(Native Memory)中的字节数组,将堆内存划分为固定大小(如512字节)的Card。当一个写操作导致了跨Region引用时,一个被称为写后屏障(Post-Write Barrier)的机制会被触发。该屏障会定位到发起引用的对象所在的Card,并将其在Card Table中标记为“脏”(Dirty)。
脏卡的后续处理是异步的。G1的并发优化线程(Concurrent Refinement Threads)会持续扫描Card Table,找到脏卡。然后,这些线程会扫描脏卡覆盖的内存区域,解析出具体的引用关系,并用这些信息去更新被引用Region的RSet。
通过RSet,G1在GC时无需扫描全堆。对于CSet中的每个Region,只需遍历其RSet,就能找到所有外部引用源。这样,扫描范围被大幅缩小,停顿时间得以有效控制。需要注意的是,G1的任何一次Evacuation Pause(无论是Young GC还是Mixed GC)都会全量扫描整个新生代,因此所有从新生代发出的引用都会被直接发现。故此,RSet中无需记录任何从年轻代发出的引用,这是一个重要的性能优化。
三、关键技术二:并发标记的正确性保障 - SATB
G1的混合回收(Mixed GC)模式需要一个并发标记阶段来识别老年代中的存活对象。在标记线程与应用线程并发执行时,必须解决经典的三色标记漏标问题。这一过程与CMS类似,但其正确性保障机制却截然不同。
G1采用Snapshot-At-The-Beginning (SATB)算法来保证正确性。SATB的核心思想是,在并发标记开始时,逻辑上对存活对象生成一个快照,并保证在本轮GC中,快照里的所有对象都会被认为是存活的。
为实现此目的,G1使用了写前屏障(Pre-Write Barrier)。当应用线程试图覆盖一个对象的字段引用时(obj.field = new_ref
),写前屏障会先将该字段的旧引用值记录到一个线程本地的SATB标记队列中。
通过这种方式,即使后续该对象的引用路径被全部切断,由于其引用已经被SATB“备份”,在最终标记(Remark)阶段,GC线程会处理SATB队列,确保该对象不被错误回收。
SATB的代价是可能产生更多的浮动垃圾(Floating Garbage)——即在快照生成后,到GC结束前这段时间才变成垃圾的对象。这些对象会被SATB机制“豁免”,存活到下一次GC。这是G1为了换取更短、更可预测的Remark停顿而做出的权衡。
四、G1的回收周期与策略
G1的运作主要围绕两种GC模式:Young GC和Mixed GC。
1. Young GC
当为新对象分配内存导致Eden空间不足时,Young GC被触发。这是一次完全的STW过程,其CSet包含所有Eden和Survivor Region。GC线程扫描GC Roots和所有Young Region的RSet(查找来自老年代的引用),并通过Evacuation将存活对象拷贝到新的Survivor或Old Region。
2. Mixed GC周期
这是G1的核心。它并非单一事件,而是一个包含并发阶段和多次STW回收的完整周期。
- 并发标记阶段:当堆整体使用率达到
InitiatingHeapOccupancyPercent
(IHOP)阈值时,G1启动此阶段。它由初始标记、并发标记、最终标记和清理四个步骤组成。其主要目的是识别所有Old Region的存活对象,并计算每个Region的可回收空间(即回收价值)。在清理步骤中,G1会生成一个按回收价值从高到低排序的Old Region列表。 - 混合回收阶段:并发标记完成后,G1便掌握了老年代的“垃圾地图”。在后续的若干次GC中(通常是Young GC触发时),G1会执行Mixed GC。Mixed GC的CSet不仅包含所有Young Region,还会包含一部分Old Region。
G1选择哪些Old Region加入CSet的过程,体现了其“Garbage-First”的精髓。在STW的初始阶段,G1会:
- 启用停顿预测模型,该模型基于历史数据预测回收不同Region的耗时。
- 从并发标记阶段生成的高价值Old Region列表顶端开始,采用贪心策略。
- 逐个尝试将高价值的Old Region加入CSet,并实时累加预测的停顿时间。
- 一旦预测的总停顿时间接近用户设定的
-XX:MaxGCPauseMillis
目标,或者触及了其他如G1OldCSetRegionThresholdPercent
等限制,选择过程即停止。
最终,G1对这个智能选择出的CSet 执行与Young GC相同的复制/转移回收。
结论
G1垃圾收集器的设计是一个层层递进、逻辑严密的系统工程。它以“可预测的短暂停顿”为最高目标,通过Region化堆布局将问题分解;通过RSet和Card Table解决了部分回收带来的引用查找难题;通过SATB确保了并发标记的正确性;最终,通过并发标记周期和停顿预测模型,实现了其“Garbage-First”的核心策略——在有限的时间预算内,优先回收价值最高的区域。
CMS
多多面试题: 标记整理算法与复制算法的区别,两个算法都需要清除+整理,复制算法空间利用率只有50%,好像复制算法完全不如标记整理算法,怎么解释?
3.三色标记法
4.CMS垃圾回收器执行过程(很深入,涉及到跨代引用问题)
5.如果是老年代垃圾回收,标记可达对象时,遇到新生代对象怎么办,需不需要继续标记新生代对象的可达对象?
Card Table
什么是Dirty Card 和 Card Table ?
首先我们先来想一下为什么要有卡表?
在分代垃圾回收器中,为了优化回收性能,JVM需要解决一个核心问题:跨代引用问题(Cross-Generational Reference Problem)。
如何在只回收其中一代的内存时,正确判断该代中对象的存活性,而无需扫描整个堆。
总结一下:记忆集和卡表机制为了解决跨代引用问题,通过“空间换时间”和“增量记录”的方式,使得分代回收器能够在保持效率的同时,避免全堆扫描,正确处理对象之间的跨代引用关系。
CMS垃圾回收器的工作原理
老年代的垃圾回收器,诞生于JDK5;在JDK9时被标记为Deprecated;在JDK14后被废弃。
初始标记(Initial Mark)
在初始标记阶段,仅仅标记GC Roots 可直达的对象,
并发标记
为什么并发标记阶段不用STW?
正是因为有了三色标记和写屏障(或者其他并发标记算法的等效机制),CMS 才能够让标记阶段的大部分工作与应用程序并行进行,从而大幅度减少了用户可感知的停顿时间。它通过牺牲一些精确性(需要额外的重新标记阶段来修正并发带来的影响)和额外的 CPU 资源(回收器线程和写屏障的开销),来换取应用程序的低延迟。
不用STW是CMS的核心设计目标,而三色标记结合写屏障是实现这一目标的关键技术。
重新标记(Remark)
CMS缺点
CMS 也有其缺点,例如它不进行内存碎片整理(可能导致 Full GC 和更大的停顿),并且对 CPU 资源比较敏感(并发阶段会占用部分 CPU)。正因如此,在 JDK 9 之后,G1 垃圾收集器成为了默认的垃圾收集器,并最终在 JDK 14 中废弃了 CMS。
思考:CMS和G1分别怎样解决漏标问题的?
为了解决“漏标”问题,CMS 使用了一种叫做 增量更新(Incremental Update)的并发标记方案。
其实实现思路很简单:通过写屏障(Write Barrier)来实现。写屏障是 JVM 在程序修改对象引用时(即写操作,例如 obj.field = newOb
j)插入的一小段代码。
那么当我们应用程序修改一个对象的引用时,写屏障会记录下这个“引用变更”的操作,将其标记为“脏”(dirty),或者将其放入一个特殊的队列。
接下来就是发挥作用的时刻,在重新标记阶段(短STW),CMS会集中处理这些在并发标记期间产生的“脏”记录,重新扫描这些被修改过的对象,从而修正标记结果,确保所有存活对象都被正确标记,避免误回收。
前置知识:三色标记算法
三色标记算法基础,感性的认识: 在并发标记过程中,JVM将对象分为三种颜色:
- 黑色:对象本身被标记,且其所有引用的对象也已被标记。可以安全地认为这些对象是存活的,且它们的所有可达对象也已被处理。
- 灰色:对象本身已被标记,但其部分引用的对象还没有被标记。需要进一步扫描其引用的对象。
- 白色:对象尚未被标记。在标记结束时,所有白色对象都将被视为垃圾。
前置知识:Minor GC (年轻代垃圾回收)
Minor GC 主要发生在年轻代空间不足以分配新对象时,具体来说,当以下任一情况发生时,JVM 会触发 Minor GC:
- Eden 区满: 这是最常见的触发条件。当新的对象需要分配内存,而 Eden 区已经没有足够的连续空间时,就会触发 Minor GC。
- 需要分配大对象时:即使 Eden 区还有空间,如果需要分配一个非常大的对象,大到 Eden 区无法容纳,或者大到直接进入老年代的阈值,JVM 可能会尝试触发 Minor GC 腾出空间。
那么整个Minor GC 的流程是怎样的呢?
目标是回收年轻代(Eden 区和 Survivor 区)中的垃圾对象。大致流程如下:
- 暂停应用线程 (Stop-The-World): 这是所有垃圾回收器都不可避免的阶段。为了确保回收的准确性,JVM 会暂停所有应用线程,直到垃圾回收完成。
- 根对象枚举 (Root Scanning): 找出年轻代中所有活跃的对象。这里的“根”对象包括:
- 虚拟机栈中引用的对象(如方法参数、局部变量)。
- 本地方法栈中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- JNI (Native) 方法中引用的对象。
- 老年代中对年轻代对象的引用。
- 可达性分析 (Reachability Analysis): 从这些根对象开始,遍历它们引用的所有对象。所有能够被根对象直接或间接访问到的对象都被认为是存活对象。
- 复制存活对象:
- 将 Eden 区和 From Survivor 区中所有存活的对象复制到 To Survivor 区。
- 在复制过程中,对象的年龄(age)会增加。如果对象的年龄达到一定阈值(通常是 15,可以通过
-XX:MaxTenuringThreshold
参数设置),它们将被晋升到老年代。
- 清空 Eden 区和 From Survivor 区: 将 Eden 区和 From Survivor 区中的所有对象全部清空,因为它们要么被回收了,要么被复制到了 To Survivor 区或老年代。
- 交换 Survivor 区: 将 To Survivor 区和 From Survivor 区的角色互换,以便下一次 Minor GC 使用。
- 恢复应用线程: 垃圾回收完成后,恢复所有应用线程的执行。
为什么扫描年轻代要找老年代?
在进行年轻代垃圾回收时,为了确保不会错误地回收掉仍然被引用的年轻代对象,垃圾回收器必须知道所有指向年轻代对象的引用。
除了前面提到的来自虚拟机栈、本地方法栈等区域的引用外,老年代中的对象也可能引用年轻代的对象。 想象一下,一个老年代的对象 A
持有一个年轻代对象 B
的引用。如果 Minor GC 在扫描年轻代时只看年轻代内部的引用,而忽略了来自老年代的引用,那么当 B
在年轻代中没有其他年轻代对象引用它时,它就会被错误地当作垃圾回收掉。但实际上,老年代的 A
仍在引用 B
,B
是不应该被回收的。
因此,为了保证垃圾回收的准确性,在扫描年轻代时,必须考虑老年代对年轻代的引用。 理论上,要找出所有这样的引用,就需要扫描整个老年代,检查每一个老年代对象是否引用了年轻代对象。
那么如何避免扫描整个老年代?
这就是大多数人感到困惑的地方,也是 JVM 进行优化的关键。 扫描整个老年代的代价是巨大的,尤其是在 Minor GC 频繁发生的情况下,这会极大地增加回收时间。为了解决这个问题,JVM 引入了以下机制:
- 卡表 (Card Table):
- JVM 将老年代划分为一块块小的区域,每块区域称为一个“卡”(Card),通常每卡大小为 512 字节。
- JVM 为每张卡维护一个卡表 (Card Table)。当老年代中的对象引用关系发生变化时(特别是老年代对象引用了年轻代对象),JVM 会将这张卡对应的卡表条目标记为“脏” (Dirty)。
- 在 Minor GC 时,垃圾回收器不再扫描整个老年代,而是只扫描卡表中被标记为“脏”的卡。 这些“脏”卡中可能包含指向年轻代对象的引用,垃圾回收器只需要检查这些卡中的对象即可。
- 这种机制通过空间换时间的方式,大大减少了扫描范围,避免了对整个老年代的遍历。
- Remembered Set (记忆集):
- Remembered Set 是卡表的一种具体实现,它记录了从非收集区域到收集区域的引用关系。
- 在 Minor GC 中,Remembered Set 记录的是老年代到年轻代的引用。
- 当一个老年代对象引用了一个年轻代对象时,JVM 会在对应的卡表中标记该卡为“脏”。垃圾回收器在查找根对象时,会根据 Remembered Set 来查找这些跨代引用,从而避免全量扫描老年代。
通过卡表和 Remembered Set 的配合使用,JVM 成功地解决了在 Minor GC 时效率地发现老年代对年轻代引用的问题,极大地优化了年轻代垃圾回收的性能。