1. 前言
本文参考了众多博客文章,现在按照自己的理解,将众文章中的知识点提取出来进行组合,文章列表详见附录。
Java 8之后,JVM的首选垃圾收集器由Par NEW + CMS组合变更为G1(Garbage First)。虽然Java 21及之后的版本,会更推崇使用ZGC,但是项目中应该会坚持使用G1一段时间,所以还是有必要深入理解G1 GC。
2. 设计理念
上图为传统的JVM内存布局,主要分割为Young(由Eden、Survivor)、Old以及Permanent(存放class等meta信息)三个分区。在应用启动时,每个分区的大小按照默认数值或显式设置固定好。
随着科技的进步,单个应用的Old区会被分配十几至几十G内存。随着应用的持续运行,Old区不可避免的会执行Full GC,以便对老年区的内存碎片进行整理以腾出可用内存空间,然而如何对巨大的老年区进行内存清理,且保证STW在一个合理的阈值内成了一个巨大的考验。CMS算法无法解决这个问题,于是G1应运而生。
G1是分代回收期,仍然保留了“Par New+CMS”垃圾回收器中新生代和老年代的分代逻辑,但是与传统分代回收器不同的是,G1引入了Region的概念。G1将整个堆划分为2048(默认数值,可调整)个大小相等的Region(具体大小对齐至2的次方,通常大小在1~32M之间)。
上图中整个堆被划分为大小相等的Region,标注颜色的区域表示当前Region已被使用。G1的新生代由Eden区域以及Survivor区域构成,这一点上与“Par New + CMS”是相等的。不同的点主要在于:
G1不要求新生代或者老年代的Region是连续分布的,换言之,新生代和老年代所属的Region可以自由分布在整个堆区,每个Region和新生代或者老年代的关系属于逻辑关系;
如果一个对象大小超过Region大小的一半,就被当作巨型对象(Humongous Object,需要注意对象大小还需要考虑Java对象头),巨型对象会被分配到Humongous Region。若巨型对象大小超过单个Humongous Region大小,则G1会将Humongous Object分配至连续的Humongous Region中。
通过将堆划分为Region,G1执行垃圾回收时,可指定对具体数量的Region进行垃圾回收,通过此方式,避免对整个区域执行垃圾回收,此外亦可以较为准确的控制STW时间,避免对应用的持续运行造成影响(当然,对于吞吐存在一些影响)。
3. 基础知识
3.1 Card Table
3.1.1 概念
Card Table并不是一个新概念,在"ParNew + CMS"算法中已经被使用了。在"ParNew + CMS"的新生代进行垃圾回收的过程,新生代对象会被拷贝至另外一块区域(可能是Survivor区域,亦或是晋升至老年代等),若新生代对象被老年代对象所依赖,那么我们就需要更新老年代对象引用的新生代对象地址。此时问题就出现了,如何精准的定位老年代中哪些对象依赖新生代对象呢?可以通过扫描整个老年代去进行定位,但是这个方案会消耗大量的资源,尤其是在老年代空间较大的情况下。
因此GC算法设计师设计了Card Table这个数据结构,在"ParNew + CMS"组合中记录老年代对象到新生代对象的引用关系。
通常有两种方法以记录对象之间的引用关系,一种成为Ponit In,一种记录为Ponit Out。假设存在如下引用关系,对象A的成员变量指向对象B(ObjA.Field = ObjB):
Point In:在对象B的Card Table中记录对象A的地址;
Point Out:在对象A的Card Table中记录对象B的地址;
两者的区别在于:
Ponit In:记录引用关系操作相对比较复杂,但是在标记扫描时可以直接找到有用和无用的对象;
Point Out:记录引用关系操作相对比较简单,但是在反向查找需要时需要对Card Table进行全表扫描。
在"ParNew + CMS"组合中,老年代的Card Table对象可以视为根对象,无需进行反向查找,因此老年代和新生代的引用关系通过Ponit Out方式进行记录。
3.1.2 数据结构
CMS算法中将老年代的连续堆内存空间划分成连续的512Byte内存块,Card Table为一个连续数组,数组中每个元素大小为1Byte(可理解为简单的位图),每个元素与老年代按照512Byte划分的内存块进行一一映射。如果老年代的某个对象产生了跨代引用,就将老年代此对象所在的内存块对应的Card Table元素标记为Dirty,这个Card称之为Dirty Card。具体如下图所示:
有了Card Table标记,在进行诸如Young GC时,就无需扫描整个老年代,只需要扫描Card Table中的Dirty Card对应的堆空间,对Dirty Card对应堆空间中的对象引用进行调整(调整完成,需重置Dirty Card),这样极大程度提升了扫描的效率。
3.1.3 G1应用
G1仍然是分代垃圾回收算法,在进行诸如Yonug GC的过程中,仍需要处理跨代引用,因此G1算法沿用Card Table记录跨代引用。
3.2 RSet
3.2.1 概念
G1将堆空间划分为Region,其目的在于让各个Region相互独立,可以分别进行GC操作,而不是对一次性回收整个区域,造成不可控的STW。但是现在的GC算法基于可达性标记,在标记的过程中,需要遍历所有的活跃对象。如果为了收集一个Region的垃圾,但是需要遍历所有的活跃对象就得不偿失了,因此G1引入了Remember Set(简称RSet)数据结构。
每个Region都有其对应的RSet,用以记录堆空间中哪些Region存在对当前Region中对象的引用。需要注意RSet不是直接记录引用对象的地址,而是记录引用对象所在的Card编号(每个Card对应512Byte的内存空间,这里可能存在不止一个对象,但是这个粒度足以支撑GC),具体入上图所示。当我们需要确定当前Region中哪些对象存在外部引用时(对象被其他Region中对象引用,是可达的,不能被回收),只需要扫描RSet的记录,进行反向查询即可。
3.2.2 数据结构
RSet的具体实现是一个HashMap,其中Key是引用的Region,value是一个List,List中存储引用Region的Card列表,具体示意图如上所示。上图中,RegionA和RegionB中分别有对象引用RegionC中的对象,在RegionC对应的RSet就会记录这样的引用关系。该RSet中有两个KV对,第一个KV对的key是RegionA,value是一个列表,列表中有两个元素3和65534,分别代表RegionA中引用对象在对应Card Table中的下标。第二个KV对的key是RegionB,value中列表只有一个元素1565,代表RegionB中引用对象在对应Card Table中的下标。
通过以上阐述,将RSet与Card Table进行比较,可知Card Table的Dirty Card代表“我引用了别的对象”,属于Point Out;RSet记录的是哪些Region引用了当前Region的对象,属于Ponit In。
3.2.3 结构优化
如果Region是热点Region(当前Region中的对象大量被其他Region中的对象所引用),那么此RSet占用的内存空间开销就比较大。然而,RSet内存开销不是为业务服务的,应该对其进行精简,尽量降低对业务内存的侵占。为了控制RSet的内存开销,G1会根据引用 Region个数,为RSet设置三种不同的实现方式(3.2.2中是其中一种),分别是:
sparse per-region-table (PRT),从字面意思来看表示这个RSet是一个稀疏的集合。具体实现使用HashMap方式记录引用关系,其中Map的key是引用Region,value是一个List,List中存储引用Region中的引用Card列表。上文有过介绍。
fine-grained PRT,还是使用HashMap方式记录引用关系,其中Map的key是引用Region,但value不再是List,而是一个bitmap,bit位为1表示对应Card是引用Card,否则不是引用Card。
coarse-grained bitmap,从字面意思可以看出来这就是一个bitmap,不过bitmap中每个bit位引用粒度不再是Card,而是Region。如果bit位值为1,表示这个Region是引用Region,即这个Region中有对象引用了该Region中的对象。
很显然,上述3种实现方式中,spase PRT和fine-grained PRT都是精确到Card,而coarse-grained bitmap是精确到Region。
3.2.4 记录更新
G1创建一个管理RSet的线程池,称为Refine线程。G1中RSet的更新是异步得 ,G1
将引用关系的记录存放至队列(Dirty Card Queue,简称DCQ)中,然后Refine线程池会消费DCQ中的记录并更新至RSet。如果当前Refine线程跟不上生产速度,那么GC线程或应用线程(此时可能会降低应用的生产速率,来形成背压机制)也可能会来协助更新RSet。
4. GC流程
4.1 基本流程
上图为G1的工作流程,G1会频繁的进行新生代Young GC,跟CMS一样有老年代并发标记周期;但是与CMS不同点在于,G1在并发标记之后并不直接清理全部垃圾对象,而是新增一个混合垃圾收集周期,这个周期包含多次Mixed GC,每次Mixed GC只回收部分Region(会同时回收新生代和老年代中的Region),直至未处理Region集合占比低于阈值。
4.2 Yonug GC
需要注意的是,每次Yonug GC会回收所有新生代分区,包括Eden以及Survivor分区。这些分区中的对象都会被转移到新的Survivor分区,或者被提升到老年代分区。
上图展示了Young GC,将所有的Eden分区活跃对象转移至Survivor分区中。
当Survivor分区中的对象存活的次数超过一定阈值,此对象会被晋升至老年代中;否则,继续留在Survivor分区中。
为什么Young GC会收集所有的新生代分区呢,原因如下:
新生代绝大多数对象的寿命都比较短暂,在新生代Region中的对象大多都是垃圾,收集的收益较高;
因为新生代所有的分区都会被收集,因此新生代Region的RSet不需要维护新生代Region之间的引用关系,换言之RSet只关心old-to-young以及old-to-old的引用,新生代GC后再去重新扫描构建新生代RSet即可。
4.3 Mixed GC
多次Young GC后,老年代Region不断累积,最终当老年代的空间占用达到或超过了堆空间的占用阈值(InitiatingHeapOccupancyPercent
,简称 IHOP,默认45%),那么G1就会启动一次老年代收集。
G1的老年代收集分为marking 和 sweeping 两个过程。marking 阶段找到当前 Old Generation Heap 中所有 live 的 object,sweeping 过程将 live object 拷贝到新的 available region
从而留下 Garbage 在老的 Region ,之后直接清理掉这些老的 Region。live object 拷贝到 available region
时 live object 是紧挨着排列的,所以没有碎片。清理过程自带 compat 效果。
对 Old Regions 的收集会同时涉及若干个 Young 和 Old Regions,因此被称为 Mixed GC。Mixed GC 很多地方都和 Young GC 类似,不同之处是:它还会选择若干最有潜力的 Old Regions(收集垃圾的效率最高的 Regions),这些选出来要被 Evacuate 的 Region 称为本次的 Collection Set (CSet)。
Mixed GC 的重要性不言而喻:Old Regions 的垃圾就是在这个阶段被收集掉的,也正是因为这样,Mixed GC 是工作量最为繁重的一个环节,如果不加以控制,就会像 CMS 一样发生长时间的 Full GC 停顿。这时候 Region 的设计就发挥出优越性了:只要把每次的 Collection Set 规模控制在一定范围,就能把每次收集的停顿时间软性地控制在 MaxGCPauseMillis
以内。起初这个控制可能不太精准,随着 JVM 的运行估算会越来越准确。
那来不及收集的那些 Region 呢?多来几次就可以了。所以你在 GC 日志中会看到 continue mixed GCs
的字样,代表分批进行的各次收集。这个过程会多次重复,直到垃圾的百分比降到 G1HeapWastePercent
以内,或者到达 G1MixedGCCountTarget
上限。
4.4 Old GC Marking
当 Old Generation 空间占用整个 Heap 比例超过 IHOP 后,下一次 YGC 时就会开始 initial-mark,STW 且并发的标记所有 Root Object。跟着 YGC 一起是因为 YGC 本就需要标记一次所有 Root object。也正因为 initial-mark 是在 YGC 中进行的,所以 concurrent marking 开始的时候只用标记 Old Generation Region 就行了,Young Generation 的 Eden 此时都被清理完了,Survivor 是算作 live object 存在的。
initial-mark 结束后开始 concurrent root scanning. 因为 initial-mark 就是一次 YGC。YGC 后 live Object 都放在 Survivor Region 中。这个过程就是标记所有 Survivor 内 Object 引用的对象。这个过程跟它名字指示的一样是并发的,唯一限制是必须在下一次 YGC 之前完成,因为下一次 YGC 就会产生新的 Survivor ,很有可能跟当前 Survivor 完全不同。
之后是 concurrent marking。大部分 mark 工作都在这里完成。后面会详细再说。这个过程是并发的,对业务的影响主要是降低业务的 throughput.
concurrent marking 结束后开始 STW 的 remark。标记所有因为 concurrent marking 阶段 marking 线程和业务线程并发运行而导致的没有标记到的 live object.
remark 完毕后,开始 clean up。 如果 mark 阶段找到没有任何 object 存活的 region,该 region 在该阶段直接被放入available regions
。
G1 的这套 Marking 算法借鉴了 Taiichi Yuasa 的 Snapshot-at-the-beginning (SATB) 算法,并进行了一些改进。Marking 的最基本目标就是在 Heap 耗尽之前,完成对整个 Heap 的 marking 工作,从而能够在 Heap 耗尽前开始清理。
SATB 内部会对 Heap 维护一个 Snapshot,标记工作也是在这个 Snapshot 上进行。SATB 保证:
所有在 concurrent marking 阶段开始时 live 的 object ,一定会被 marked and traced;
所有在 concurrent marking 过程中产生或死掉的 object 都一定被标记为 live 并且不被 traced ;
SATB 会维护两个 bitmap,preivous 和 next。previous bitmap 存的是上一次完成的 marking 信息,当前 marking 阶段会创建并更新 next bitmap。随着 marking 阶段的进行,next bitmap 会逐渐被完善,当 next bitmap 拥有整个 Heap 的 marking 信息后,next bitmap 会替代 previous bitmap。
在 G1 Region 上,有两个 top-at-mark-start (TAMS) 标记位,一个是标记上一次 marking 阶段使用的 TAMS,也称为 PrevTAMS;另一个用来标记本次 marking 阶段,也称为 NextTAMS。
上面两张图是连续两次标记的过程,解释如下:
A: PrevBitmap 和 NextBitmap 都是空的,说明这是个新 Region,没有经历过 marking 阶段。Bottom 和 Top 之间是 Region 当前已经分配的空间。因为没有经历过 marking,PrevTAMS 指向 Bottom。NextTAMS 不管 Region 之前是否经历过 marking,initial marking 的时候都会指向 Top。
B: 在 Remark 阶段结束之后,来到了图中 B 指示的阶段。看到 NextBitmap 上已经被标记了哪些是 live object,没被标记的就是 dead object。NextTAMS 到 Top 之间的 object 是 concurrent marking 阶段,因为业务线程跟 marking 线程并发运行而新产生的 object。按照 SATB 之前说的,这部分 object 全部认为是 live 的。也正是因为这个原因,本次 marking 只针对 PrevTAMS 到 NextTAMS 之间的区域进行标记。
C: Cleanup 阶段,NextBitmap 替换 PrevBitmap,因为 marking 工作已经完成,NextBitmap 已经有了整个 Heap 的信息。注意:NextBitmap 和 PrevBitmap 实际都是全局的一个 Bitmap,是标识整个 Heap 的。上图中 Bitmap 看上去跟 Region 绑定只是为了方便看,便于理解。
D: 能到 D 这个阶段,这个 Region 经历两次 initial marking,说明在上一次 marking 后,这个 Region 并没有被收集。后面会说,G1 的 Mixed GC 不是一定要在整个 Heap 上所有 dead object 都被收集干净了才停止,而是只要根据 marking 提供的 dead object 占用的空间在整个 Heap 中占比小于一定值后,就停止收集。所以完全是有可能存在能经历两次甚至更多次 marking 的 Region。从 D 能看到 Top 相对于 C 增长了一些,说明上次 marking 结束后,这个 Region 上又有新晋升上来的 object。跟 A 一样,PrevBitmap、PrevTAMS 保持不变,创建 NextBitmap,NextTAMS 指向 Top。并且看到 PrevBitmap 没有变化,因为不经历 makring 这个 bitmap 是不可能变化的。
E: 跟 B 一样,Remark 结束,Bottom 到 NextTAMS 之间所有 live object 都被标识出来,NextTAMS 到 Top 之间是本轮 concurrent marking 阶段新晋升的 object,直接被标记为 live。注意:看到第二次 marking 的时候 mark 的还是 Bottom 到 NextTAMS,上一轮已经被 mark 过的 Bottom 到 PrevTAMS 的还是会参与 marking。
F: 跟 C 一样,NextBitmap 替换 PrevBitmap。NextBitmap 被清理。
从上面看到 Marking 阶段实际就是为了维护 PrevBitmap,有了这个 Bitmap,就能知道一个 Region 上有多少 live object,从而能够根据 dead object 空间占比来排序,找出 GC 效率最高的 Region 来 GC。
附录:
2. 深(浅)入(出)剖析G1(Garbage First)
3. G1 垃圾收集器
5. Garbage-First Garbage Collection by David Detlefs, Christine Flood, Steve Heller & Tony Printezis