原文标题:极致八股文之JVM垃圾回收器G1&ZGC详解
原文作者:阿里云开发者
冷月清谈:
G1是一款面向服务端的高效垃圾回收器,其特点是能提供可预测的停顿时间。它采用region内存布局,将堆内存划分为一个个大小不等的分区,并根据分区中的对象存活时间将其划分为Eden、Survivor、Old等区域。在回收过程中,G1会优先回收存活时间较短的Eden和S区的对象,然后再逐步回收存活时间较长的Old区的对象。G1实现了三色标记算法,采用SATB机制避免漏标,并通过写屏障记录引用关系的变化。
ZGC 垃圾回收器
ZGC是一款低延迟的垃圾回收器,其目标是将停顿时间控制在10毫秒以内。它也采用region内存布局,但目前还没有分代功能。ZGC利用指针染色、读屏障和NUMA等技术,实现了全程并发的回收过程。其回收过程主要分为以下几个阶段:初始标记、并发标记、重新标记、并发预备重分配、初始迁移、并发迁移和并发重映射。
优势与不足
G1的优点是停顿时间可预测,吞吐量较高。但它也会带来额外的内存开销,且可能触发Full GC。ZGC的优点是停顿时间低,但其吞吐量相对较低,且目前还不支持分代回收。总体上,G1适用于对吞吐量要求较高的大型应用,而ZGC适用于对延迟要求非常严格的应用。
怜星夜思:
2、对于一个高并发场景,G1和ZGC哪个更适合?
3、在生产环境中,如何选择合适的JVM垃圾回收器?
原文内容
阿里妹导读
垃圾回收器介绍
G1(Garbage First)垃圾回收器
G1垃圾收集器在JDK7被开发出来,JDK8功能基本完全实现。并且成功替换掉了Parallel Scavenge成为了服务端模式下默认的垃圾收集器。对比起另外一个垃圾回收器CMS,G1不仅能提供能提供规整的内存,而且能够实现可预测的停顿,能够将垃圾回收时间控制在N毫秒内。这种“可预测的停顿”和高吞吐量特性让G1被称为"功能最全的垃圾回收器"。
G1同时回收新生代和老年代,但是分别被称为G1的Young GC模式和Mixed GC模式。这个特性来源于G1独特的内存布局,内存分配不再严格遵守新生代,老年代的划分,而是以Region为单位,G1跟踪各个Region的并且维护一个关于Region的优先级列表。在合适的时机选择合适的Region进行回收。这种基于Region的内存划分为一些巧妙的设计思想提供了解决停顿时间和高吞吐的基础。接下来我们将详细讲解G1的详细垃圾回收过程和里面可圈可点的设计。
Region内存划分
G1将堆划分为一系列大小不等的内存区域,称为Region(单词语义范围,地区,接下来我简述为分区)。每个region为1-32M,都是2的n次幂。在分代垃圾回收算法的思想下,region逻辑上划分为Eden,Survivor和老年代。每个分区都可能是eden区,Survivor区也可能是old区,但在一个时刻只能是一种分区。各种角色的region个数都是不固定的,这说明每个代的内存也是不固定的。这些region在逻辑上是连续的,而不是物理上连续,这点和之前的young/old区物理连续很不一样。
除了前面说的Eden区,Survivor区,old区,G1中还有一种特殊的分区,Humongous区。Humongous用于存放大对象。当一个对象的容量超过了Region大小的一半,就会把这个对象放进Humongous分区,因为如果对一个短期存在的大对象使用复制算法回收的话,复制成本非常高。而直接放进old区则导致原本应该短期存在的对象占用了老年代的内存,更加不利于回收性能。如果一个对象的大小超过了一个Region的大小,那么就需要找到连续的Humogous分区来存放这个大对象。有时候因为找不到连续的Humogous分区甚至不得不开启Full GC。
Region内部结构
Card Table卡表
Rset记忆集合
Young GC流程
-
stop the world,整个young gc的流程都是在stw里进行的,这也是为什么young gc能回收全部eden区域的原因。控制young gc开销的办法只有减少young region的个数,也就是减少年轻代内存的大小,还有就是并发,多个线程同时进行gc,尽量减少stw时间。
-
扫描GCRoots,注意这里扫描的GC Roots就是一般意义上的GC Roots,是扫描的直接指向young代的对象,那如果GC Root是直接指向老年代对象的,则会直接停止在这一步,也就是不往下扫描了。被老年代对象指向的young代对象会在接下来的利用Rset中key指向老年代的卡表识别出来,这样就避免了对老年代整个大的heap扫描,提高了效率。这也是为什么Rset能避免对老年代整体扫描的原因。
-
排空dirty card quene,更新Rset。Rset中记录了哪些对象被老年代跨带引用,也就是当新生代对象被老年代对象引用时,应该更新这个记录到RSet中。但更新RSet记录的时机不是伴随着引用更改马上发生的。每当老年代引用新生代对象时,这个引用记录对应的card地址其实会被放入Dirty Card Queue(线程私有的,当线程私有的dirty card queue满了之后会被转移到全局的dirty card queue,这个全局是唯一的),原因是如果每次更新引用时直接更新Rset会导致多线程竞争,因为赋值操作很频繁,影响性能。所以更新Rset交由Refinement线程来进行。全局DirtyCardQueue的容量变化分为4个阶段
-
扫描Rset,扫描所有Rset中Old区到young区的引用。到这一步就确定出了young区域哪些对象是存活的。
-
拷贝对象到survivor区域或者晋升old区域。
-
处理引用队列,软引用,弱引用,虚引用
三色标记算法
-
白色:对象还没被检查。
-
灰色:对象被检查了,但是对象的成员Field(对象中引用的其他对象)还没有被检查。这说明这个对象是可达的。
-
黑色:对象被检查了,对象的成员Fileld也被检查了。
已经存在的对象被漏标
-
灰色对象不再指向白色对象,即objectB.d = null
-
黑色对象指向白色对象,即objectA.d = objectD
-
原始快照(Snapshot At The Beginning,简称SATB)
当任意的灰色对象到白色对象的引用被删除时,记录下这个被删除的引用,默认这个被删除的引用对象是存活的。这也可以理解为整个检查过程中的引用关系以检查刚开始的那一刻为准。 -
增量更新(Incremental Update)
当灰色对象被新增一个白色对象的引用的时候,记录下发生引用变更的黑色对象,并将它重新改变为灰色对象,重新标记。这是CMS采用的解决办法(没错,CMS也是三色标记算法实现的)。
新产生的对象被漏标
SATB
Mixed GC
这个阶段会STW,标记从GC Root开始直接可达的对象,这一步伴随着young gc。之所以要young gc是为了处理跨代引用,老年代独享也可能被年轻代跨代引用,但是老年代不能使用RSet来解决跨代引用。还有就是young gc也会stw,在第一步young gc可以共用stw的时间,尽量减少stw时间。
这个阶段在stw之后,会扫描survivor区域(survivor分区就是根分区),将所有被survivor区域对象引用的老年代对象标记。这也是上一步需要young gc的原因,处理跨代引用时需要知道哪些old区对象被S区对象引用。这个过程因为需要扫描survivor分区,所以不能发生young gc,如果扫描过程中新生代被耗尽,那么必须等待扫描结束才可以开始young gc。这一步耗时很短。
3. 并发标记(Concurrent Marking)
从GC Roots开始对堆中对象进行可达性分析,找出各个region的存活对象信息,耗时较长。粗略过程是这样的,但实际这一步的过程很复杂。因为要考虑在SATB机制之下,各个指针的变化。
假设在根分区扫描后没有引用的改变,那么一个region的分区状态和第一步init marking初始化完一致。此时如果再继续分配对象,那么对象会分配在nextTAMS之后,随着对象的分配,TOP指针会向后移动。
标记那些并发标记阶段发生变化的对象,就是将线程satb mark queue中引用发生更改的对象找出来,放入satb mark queue。这个阶段为了保证标记正确必须STW。
对各个region的回收价值和成本进行排序,根据用户期待的GC停顿时间指定回收计划,选中部分old region,和全部的young region,这些被选中的分区称为Collection Set(Cset),还会把没有任何对象的region加入到可用来分配对象的region集合中。注意这一步不是清除,是清点出哪些region值得回首,不会复制任何对象。清点执行完,一个全局并发标记周期基本就执行完了。这时还会将nextTAMS指针赋值给prevTAMS,且nextBitMap赋值给prevBitMap。这里是不是很奇怪为什么要记录本轮标记的结果到prevBitMap,难道下次再来检查本region时还可以再复用这个标记结果吗。
G1点评
ZGC
Page内存布局
-
小型page(small page)
容量2M,存放小于256k的对象 -
中型page(medium page)
容量32M,存放大于等于256k,但是小于4M的page -
大型page(large page)
容量不固定,但是必须是2M的整数倍。存放4M以上的对象,且只能存放一个对象。
内存回收算法
染色指针
读屏障
NUMA
-
uma(Uniform Memory Access Architeture)
统一内存访问,也是一般电脑的正常架构,即一块内存多个CPU访问,所以在多核CPU在访问一块内存时会出现竞争。操作系统为了为了锁住某一块内存会限制总线上对某个内存的访问,当CPU变多时总线就会变成瓶颈。 -
numa(non Uniform Memory Access Architeture)
非统一内存访问,每块CPU都有自己的一块对应内存,一般是距离CPU比较近的,CPU会优先访问这块内存。因为CPU之间访问各自的内存这样就减少了竞争,效率更高。numa技术允许将多台机器组成一个服务供外部使用,这种技术在大型系统上比较流行,也是一种高性能解决方案,而且堆空间也可以由多台机器组成。ZGC对numa的适配就是ZGC能够自己感知numa架构。
ZGC流程
-
初始标记(Init Mark)
初始标记,这一步和G1类似,也是记录下所有从GC Roots可达的对象。除此之外,还切换了good_mask的值,good_mask初始化出来是remapped,经过这次切换,就变为了marked1(这里很多人认为第一次是切换到0,其实不是的)。需要注意的是,对象的指针,因为还没有参加过GC,所以对象的标志位是出于Remapped。经过这一步,所有GC Roots可达的对象就被标记为了marked1。
-
并发标记(Concurrent Mark)
第一执行标记时,视图为marked1,GC线程从GCRoots出发,如果对象被GC线程访问,那么对象的地址视图会被Remapped切换到marked1,在标记结束时,如果对象的地址空间是marked1,那么说明对象是活跃的,如果是Remapped,那么说明对象是不活跃的。同时还会记录下每个内页中存活的对象的字节数大小,以便后续做页面迁移。这个阶段新分配的对象的染色指针会被置为marked1。
这个阶段还有一件事,就是修复上一次GC时被标记的指针,这也是为什么染色指针需要两个命名空间的原因。如果命名空间只有一个,那么本次标记时就区分不出来一个已经被标记过的指针是本次标记还是上次标记的。
-
重新标记(Remark)
这个阶段是处理一些并发标记阶段未处理完的任务(少量STW,控制在1ms内)如果没处理完还会再次并发标记,这里其实主要是解决三色标计算法中的漏标的问题,即白色对象被黑色对象持有的问题。并发标记阶段发生引用更改的对象会被记录下来(触发读屏障就会记录),在这个阶段标记引用被更改的对象。这里我就不画图了,大家理解意思就行。 -
并发预备重分配(Concurrent Prepare for Relocate)
这一步主要是为了之后的迁移做准备,这一步主要是处理软引用,弱引用,虚引用对象,以及重置page的Forwarding table,收集待回收的page信息到Relocation Set
Forwarding table是记录对象被迁移后的新旧引用的映射表。Relocation Set是存放记录需要回收的存活页集合。这个阶段ZGC会扫描所有的page,将需要迁移的page信息存储到Relocation Set,这一点和G1很不一样,G1是只选择部分需要回收的Region。在记录page信息的同时还会初始化page的Forwarding table,记录下每个page里有哪些需要迁移的对象。这一步耗时很长,因为是全量扫描所有的page,但是因为是和用户线程并发运行的,所以并不会STW,而且对比G1,还省去了维护RSet和SATB的成本。
-
初始迁移(Relocate Start)
这个阶段是为了找出所有GC Roots直接可达的对象,并且切换good_mask到remapped,这一步是STW的。这里注意一个问题,被GC Roots直接引用的对象可能需要迁移。如果需要,则会将该对象复制到新的page里,并且修正GC Roots指向本对象的指针,这个过程就是"指针的自愈"。当然这不是重点重点是切换good_mask。
-
并发迁移(Concurrent Relocate)这个阶段需要遍历所有的page,并且根据page的forward table将存活的对象复制到其他page,然后再forward table里记录对象的新老引用地址的对应关系。page中的对象被迁移完毕后,page就会被回收,注意这里并不会回收掉forward table,否则新老对象的映射关系就丢失了。
这个阶段如果正好用户线程访问了被迁移后的对象,那么也会根据forward table修正这个对象被持有的引用,这也是"指针的自愈"。
-
并发重映射(Concurrent Remap)
这个阶段是为了修正所有的被迁移后的对象的引用。严格来说并发重映射并不属于本轮GC阶段要采取的操作。因为在第6步执行后,我们就得到了所有的需要重新映射的对象被迁移前后地址映射关系,有了这个关系,在以后的访问时刻,都可以根据这个映射关系重新修正对象的引用,即"指针自愈"。如果这里直接了当的再重新根据GC Roots遍历所有对象,当然可以完成所有对象的"指针自愈",但是实际是额外的产生了一次遍历所有对象的操作。所以ZGC采取的办法是将这个阶段推迟到下轮ZGC的并发标记去完成,这样就共用了遍历所有对象的过程。而在下次ZGC开始之前,任何的线程访问被迁移后的对象的引用,则可以触发读屏障,并根据forward table自己识别出对象被迁移后的地址,自行完成"指针自愈"。
ZGC点评
当然这些缺点随着ZGC的成熟,以及JDK21在ZGC里加入分代的特性,都会一点点的好转。总而言之ZGC还是设计非常优秀的一款垃圾回收器。大家要好好学,尤其是现在ZGC还不是特别流行时,面试时多吹一吹,说不定能唬住一般的面试官。
END
JVM垃圾回收器的知识实在太多了,写起来非常费劲,关于GC日志相关的知识我就放到后面再讲了,后续应该还有一点点JVM垃圾回收器的收尾知识,有机会会分享给大家。
一站式快速开发多平台小程序