# 经典垃圾收集器

这里经典一次想表达的含义是,相对于现代低延时垃圾收集器来说的,可成熟商用的垃圾收集器,这里讨论的也是JDK7U4之后,到JDK11发布之前的OracleJDK中的HotSpot虚拟机所包含的全部可用的垃圾收集器

image-20210420225249453

上图的上半部分是新生代,下半部分是老年代/持久代,如果两个收集器之间有连线,说明它们可以搭配使用

下面我们一起来看收集器的目标,特性,原理和使用场景

JDK版本 新生代默认收集器 老年代默认收集器
JDK7 Parallel Scavenge Parallel Old
JDK8 Parallel Scavenge Parallel Old
JDK9 Garbage First Garbage First
JDK10 Garbage First Garbage First
JDK11 ZGC ZGC

上表为Java各主流版本默认垃圾收集器

# Serial收集器

Serial收集器是最基础,也是历史最久的收集器,看名字能大概知道,它是一个单线程工作的收集器,它在工作时,必须暂停其他所有用户线程,直到它工作结束,这种在用户不可知不可控的情况下暂停用户正常工作的行为,对于很多场景下来说,都是不可接受的,所以长期以来垃圾收集器的迭代都是以尽可能缩短暂停时间为方向而努力的

Serial/Serial Old

虽然和现在最新的垃圾收集器相比,Serial收集器显得非常鸡肋,但是在HotSpot客户端模式下,它依旧是默认的新生代收集器,简单高效,与其他收集器的单线程模式相比,Serial收集器额外内存消耗最小,由于没有线程交互的开销,在单核处理器或核心较少处理器的环境中,它也能有很高的收集效率,并且由于客户端模式下,一些桌面应用场景,Serial收集器可以把停顿时间控制在几十毫秒下,也不太会频繁发生收集动作,所以对许多用户来说是完全可以接受的

# ParNew收集器

ParNew收集器其实是Serial收集器的多线程并行版本,除了使用多个线程并发收集外,其余行为包括控制参数,收集算法,Stop The World,对象分配原则,回收策略两者都完全一致,两者也复用了相当多的代码

ParNew/Serial Old

ParNew收集器也是JDK7之前的首选新生代收集器,因为除了Serial收集器之外,只有它可以和CMS收集器配合工作,在JDK5发布时,HotSpot推出了一款真正意义上支持并发的收集器,CMS收集器,它首次实现了让垃圾收集器线程与用户线程基本上同时工作,但遗憾的是,它不能与JDK1.4中的新生代垃圾收集器Parallel Scavenge配合工作,而只能和Serial收集器或者ParNew收集器其中一个配合,ParNew收集器是激活CMS后的默认新生代收集器

也正是CMS的出现,才让ParNew收集器的地位被巩固下来,但好景不再,JDK9开始,G1收集器作为面向全堆的收集器,不需要与其它收集器配合,同时也取消了Serial/CMS和ParNew/Serial Old的组合,意味着ParNew只能和CMS互相搭配使用了

# Parallel Scavenge收集器

Parallel Scavenge也是一款基于标记-复制算法实现的新生代收集器,那么它与ParNew十分相似,有什么特别之处呢?它的特点是与其它收集器关注点不一样,比如CMS等收集器更关注缩短用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控的吞吐量(Throughput),即:

吞吐量 = 运行用户线程时间 / (运行用户线程时间 + 运行垃圾收集线程时间)

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量:

  • -XX:MaxGCPauseMillis(控制最大垃圾收集停顿时间)
  • -XX:GCTimeRatio(控制吞吐量大小)

-XX:MaxGCPauseMillis允许一个大于0的毫秒数,收集器将尽力确保垃圾收集时间不超过设定值,但并不是将这个值设置足够小就可以让垃圾收集效果更优的,它是以牺牲吞吐量和新生代空间作为代价换取的,比如系统把新生代调小一些,收集300MB肯定比收集500MB快,但是原来10秒收集一次,每次停顿100ms,现在变成5秒收集一次,每次停顿70ms,停顿时间的确降低了,但是吞吐量也下来了

-XX:GCTimeRatio允许设置一个大于0小于100的整数,默认值99,即允许最大1%(1/(1+99))的垃圾收集时间,还有一个参数:

  • -XX:+UseAdaptiveSizePolicy(控制是否需要人工指定如新生代大小,E区和S区比例,晋升老年代对象大小等参数)

开启后,虚拟会自动根据当前系统运行情况收集性能监控信息,并动态调整这些参数以提供最合适的停顿时间或者最大吞吐量,这也是Parallel Scavenge收集器和ParNew收集器的一个重要区别特性

# Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,它也是一个单线程收集器,使用标记-整理算法,它一般可以在JDK5以及之前版本与Parallel Scavenge收集器搭配使用,也可以作为CMS收集器发生失败时作为后备收集器

Serial/Serial Old

# Parallel Old收集器

Parallel Old是Parallel Scavenge的老年代版本,支持多线程并发收集,基于标记-整理算法,JDK6开始提供,在Parallel Old出来之前,如果新生代收集器选择了Parallel Scavenge,会处于非常尴尬的境地,因为那会只有Serial Old可以作为老年代收集器搭配使用,但它又是单线程的,无法充分利用多核处理器的并行处理能力,这种组合甚至不如ParNew加CMS来得优秀,直到Parallel Old出现后,比较注重吞吐量的场景下,才有了比较优秀的收集器组合可以使用

Parallel Scavenge/Parallel Old

# CMS收集器

CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器,从名字也可以看出,它是基于标记-清除算法实现的,运作过程较为复杂,分为以下四个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

Concurrent Mark Sweep

其中如图,只有初始标记和重新标记这两个阶段需要Stop The World,初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快;并发标记就是从GC Roots出发,开始遍历整个对象图的过程,耗时长,但是可以和用户线程并发;而重新标记是为了修正并发标记期间因用户线程运行而导致标记产生变动的那一部分对象的标记记录(还记得之前说过要解决并发扫描时的对象消失问题,只需要破坏其中一个条件即可,那么CMS选择的是破坏第一个条件,使用了增量更新的方式来实现重新标记),这个阶段停顿时间会比初始标记稍长,但是远比并发标记阶段时间短;最后是并发清除阶段,清理删除掉标记阶段判定死亡的对象,由于不需要移动存活对象,所以也是可以与用户线程并发的

CMS对处理器资源非常敏感,也可以说面向并发设计的程序都对处理器资源比较敏感,在并发阶段,虽然不会导致用户线程停顿,但却因为占用了一部分线程资源导致程序运行变慢,降低了总吞吐量,CMS默认启动的垃圾收集线程数是(处理器核心数 + 3) / 4,即处理器核心数在四个或以上时,并发回收时垃圾收集线程会占用不到25%的处理器运算资源,并随着处理器核心数增加而下降,但是如果处理器核心数不足四个,CMS就会让用户程序执行速度突然大幅降低

由于CMS收集器无法处理浮动垃圾(Floating Garbage),有可能出现Concurrent Mode Failure失败而导致另一次完全Stop The World的Full GC产生。在CMS收集器的并发标记和并发清理阶段,用户线程还在继续运行着的,自然会伴随有垃圾不断产生,但这一部分垃圾对象是出现在标记过程结束后,CMS无法在本次收集中清理掉它们,只能在下一次垃圾收集时清理,这部分对象就被称为浮动垃圾

同样由于CMS在垃圾收集阶段时,用户线程需要并发运行,所以需要预留足够内存空间给用户线程使用,不能像其它收集器那样等待到老年代几乎完全被填满了再进行收集,在JDK5默认设置下,CMS收集器当老年代使用了68%的空间后,会被激活,这是一个偏保守的设置,如果实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,降低内存回收频率,到了JDK6时,这个百分比被默认设置到92%了,但是会遇到另一种风险,要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次并发失败,这时虚拟机就不得不启动后备预案,冻结所有用户线程,临时启用Serial Old作为老年代收集器,所以说-XX:CMSInitiatingOccupancyFraction参数将百分比设置得太高就容易导致大量并发失败,反而让停顿时间更久了,需要根据实际情况来权衡

最后一个缺点,由于CMS垃圾收集器是基于标记-清除算法实现的,意味着收集结束会产生大量内存碎片,给大对象的分配带来麻烦,往往容易出现明明老年代还有很多剩余空间,但是就是没有足够多连续空间来分配给大对象,而不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(从JDK9开始废弃),默认开启,它被用于在CMS收集器不得不进行Full GC的时候开启内存碎片的合并整理工作,由于这个工作必须移动存活对象,(在Shenandoah和ZGC出现之前)是无法和用户线程并发的,所以虽然解决了内存碎片问题,这样又会导致用户线程停顿时间变长,因此还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(从JDK9开始废弃),用来要求CMS收集器在执行"该参数值"设置的次数不整理空间的Full GC之后,下一次进入Full GC之前会进行碎片整理(默认值是0,表示每次进入Full GC时都会进行碎片整理)

# Garbage First收集器

Garbage First,简称G1收集器,是垃圾收集器技术发展历史上里程碑式的成果,它开创了收集器面向局部收集的思路和基于Region的内存布局形式,G1是一款面向服务端应用的垃圾收集器,HotSpot团队最初对它的期望是取代JDK5中发布的CMS收集器,在JDK9发布之时,G1就宣告取代了Parallel Scavenge和Parallel Old的组合,成为服务端模式下默认垃圾收集器,而CMS则被声明为不推荐使用的垃圾收集器

作为CMS收集器的替代者和继承者,设计者希望做出一款能够建立起停顿时间模型(Pause Prediction Model)的垃圾收集器,意思是支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎就是Java(RTSJ)中软实时垃圾收集器的特征了

那么,怎么做才能实现呢?

首先要有个思想上的转变,在G1之前的所有垃圾收集器,收集范围要么是整个新生代(Minor GC),要么是整个老年代(Major GC),在要么就是整个Java堆(Full GC),而G1可以面向堆内存的任何部分来组成回收集(Collection Set),衡量是否回收的标准,从垃圾属于哪个分代,变为了哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式

image-20210425225143675

如上图,G1收集器把堆内存布局分为了多个Region,不再坚持用固定大小和数量的分代区域划分,每个Region都可以根据需要扮演不同角色(如新生代的Eden空间,Survivor空间,老年代空间),这样G1就可以根据不同策略来回收不同Region

Region里还有一类特殊的Humongous区域,专门用来存储大对象,G1认为大小超过一个Region容量一半的对象就可以被判定为大对象,每个Region大小可以通过-XX:G1HeapRegionSize参数确定,取值范围1MB-32MB,且为2的N次幂,如果是那种超过了整个Region容量的超级大对象,会被存放在多个连续的Humongous Region内,G1大多数行为都会将Humongous Region作为老年代的一部分来对待

这样G1就可以有计划地避免在整个Java堆中全区域进行垃圾收集,它会去跟踪各个Region中垃圾堆积的可回收价值,这个价值就是回收所获得的空间大小以及回收所需时间的经验值,并维护一份优先级列表,每次根据用户设定允许的收集停顿时间来优先处理回收价值最大的那些Region,可使用-XX:MaxGCPauseMillis参数指定,默认200毫秒,这种具有优先级的区域回收方式,让G1收集器在有限时间内可以尽可能获取最大收集效率

那么,这种化整为零的思路,会比之前整堆收集多遇到哪些问题呢?

  • 跨Region引用问题

在G1之前,可以通过记忆集来避免全堆作为GC Roots扫描,但是G1中每个Region都需要维护自己的记忆集,它本质上是一个哈希表,Key是引用到自己的Region的起始地址(即谁指向了我),Value是一个集合,用于存储卡表的索引号(即我指向了谁),这种双向的卡表结构比原来的卡表实现起来更复杂,根据经验,需要耗费大约相当于Java堆10%至20%的内存来维持收集器工作,也属于空间换时间的思想

  • 收集器线程与用户线程并发问题

这里需要解决的是,在用户线程改变引用关系时,必须保证它不能打破原本的对象图结构,从而导致标记结果出现错误,CMS收集器采用的是增量更新算法实现,而G1则使用了原始快照(SATB)算法来实现。此外,程序在运行过程中,还会持续不断地产生新的对象,G1为每一个Region都设计了两个名为TAMS(Top at Mark Start)的指针,用于把Region中一部分空间划分出来分配新对象,并发回收时,新分配的对象都必须要在这两个指针位置以上,这块内存空间都是被G1隐式标记过的,默认对象都是存活状态,不进行回收。与CMS中的“Concurrent Mode Failure”会导致Full GC类似,如果收集器回收速度赶不上内存分配速度,G1也会冻结用户线程,导致Full GC

  • 如何建立可靠的停顿预测模型

G1是如何通过用户配置的-XX:MaxGCPauseMillis参数来实现可靠停顿预测的呢,它是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里衰减均值代表的是最近一段时间的平均状态,它相比普通的总体平均状态会更准确地代表平均水平

G1收集器运行过程大致分为下面四个步骤:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

G1

初始标记阶段仅仅是标记一下GC Roots能直接关联到的对象,并修改TAMS指针的值,让下一阶段用户线程在并发运行时能够正确分配新对象,这个阶段需要很短暂地停顿用户线程,且是借用Minor GC的时候同步完成的

并发标记阶段开始从GC Roots中关联的对象进行可达性分析,递归扫描整个堆里的对象图,这个阶段耗时较长,但是可以和用户线程并行,当对象图扫描完成后,还要重新处理一下原始快照(SATB)中记录的引用变动的对象

最终标记阶段会对用户线程做个短暂的暂停,用于处理并发标记阶段中遗留下来的少量SATB记录中的对象

筛选回收阶段负责更新Region的统计数据,开始对各个Region的回收价值和成本进行排序,根据用户期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定要回收的那部分Region的存活对象复制到空的Region中,再清理掉整个旧Region占用的空间,这里涉及到了存活对象的移动,必须停顿用户线程,但是可以由多条收集器线程并行完成

通过上述描述,我们就可以看出,除了并发标记阶段,其它阶段都需要停顿用户线程,或多或少的,G1并非纯粹地追求低延时,而是希望在延迟可控的前提下,尽可能获得更高吞吐量

G1收集器的默认停顿时间是两百毫秒,一般情况下,在几十到一百甚至接近两百毫秒都是正常的,如果设置的预期停顿时间特别低,那么G1收集器就只能筛选出很少的待回收Region,逐渐的,收集器收集速度跟不上分配器分配的速度,就会导致垃圾堆积,从而最终引发Full GC反而降低了收集性能

那么G1相比CMS,有什么优劣呢?

与CMS的“标记-清除”不同,G1从整体来看都是基于“标记-复制”算法,从局部(两个Region之间)是由“标记-整理”算法实现的,这两种算法意味着G1收集器运行期间都不会产生内存空间碎片,这种特性非常利于程序长时间运行,不容易出现程序在分配大对象时找不到连续内存空间而必须触发Full GC的问题

又从内存占用来说,虽然二者都使用了卡表来处理跨代引用问题,但是G1的卡表实现更复杂,且每个Region都有自己的卡表,导致G1的记忆集会占整堆容量的20%乃至更多,而CMS只需要维护一份全局卡表即可

还从执行负载角度来说,它们都使用了写屏障,CMS用写后屏障来维护卡表,G1除了使用写后屏障维护卡表,还为了实现原始快照搜索算法(SATB),还需要使用写前屏障来跟踪并发时对象引用变化情况,相比增量更新算法,原始快照搜索算法能减少并发标记和重新标记阶段的消耗,但是在用户程序运行时会产生由跟踪引用变化带来的额外负担,由于G1对写屏障的复杂操作要比CMS消耗更多运算资源,所以CMS的写前屏障实现是直接的同步操作,而G1不得不实现成类似消息队列的结构,把写前屏障和写后屏障中要做的事情放在队列里,异步处理

image-20210511215144502

上图是垃圾收集器的对比,浅色部分表示这个阶段必须挂起用户线程,让它们被停顿,深色部分表示收集器线程和用户线程可以并发工作,我们可以发现,在CMS和G1之前的所有收集器,都会Stop The World,CMS和G1分别使用了增量更新和原始快照算法,实现了标记阶段的并发,使这个阶段不会因为管理的堆内存变大,对象变多而导致停顿时间随着正比例增长,但是对于标记阶段之后的处理,仍然是需要停顿用户线程的。

我们再看Shenandoah和ZGC,可以发现它们在整个工作过程全都是可和用户线程并发进行的,只有初始标记、最终标记这些阶段有短暂停顿,而这部分时间也是基本固定的,不会随着堆容量和对象数量成正比例增长,它们被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)

# Shenandoah收集器

与G1收集器相比,Shenandoah收集器虽然也是基于Region的内存布局,也优先回收最大价值Region,但是有三个明显不同之处

image-20210512221956622

  • G1的回收阶段可以多线程并行,却不能和用户线程并发,Shenandoah可以
  • G1的Region有分代的概念,Shenandoah没有
  • G1的记忆集需要耗费大量资源维护,Shenandoah改用了连接矩阵(Connection Matrix)来实现,比如图中Region5中有对象Baz引用了Region3中的Foo,Region3中的Foo又引用了Region1中的Bar,那么在矩阵中的5行3列和3行1列就会被打上标记,回收的时候可以通过这张表知道哪些Region之间产生了跨代引用

Shenandoah收集器的工作过程大致可分为以下九个阶段:

  • 初始标记(Initial Marking):与G1一样,会引起Stop The World,但是只标记与GC Roots关联的对象,停顿对象与堆大小和对象数量无关
  • 并发标记(Concurrent Marking):与G1一样,遍历对象图,可以和用户线程并发
  • 最终标记(Final Marking):也与G1一样,处理剩余的SATB扫描,统计回收价值最高的Region并组成回收集(Collect Set),有一小段短暂停顿
  • 并发清理(Concurrent Cleanup):用于清理整个堆里一个存活对象都没有找到的Region(也被称为Immediate Garbage Region)
  • 并发回收(Concurrent Evacuation):是Shenandoah收集器和之前收集器的核心差异,这个阶段Shenandoah会通过读屏障和被称为“Brooks Pointers”的转发指针来解决并发回收遇到的问题,后文会细聊
  • 初始引用更新(Initial Update Reference):是在并发回收阶段复制好对象后,需要把堆中所有指向旧对象的引用都修正到复制后的新对象上,实际上这个阶段没有做什么具体操作,只是为了建立一个线程集合点,确保所有并发回收阶段中的收集器线程都完成了分配给它们的对象移动任务而已,这个阶段时间很短,只会产生一个很短暂的停顿
  • 并发引用更新(Concurrent Update Reference):是真正开始进行引用更新的阶段,与用户线程一起并发,时间长短取决于内存里涉及到的引用数量,这个阶段不需要和并发标记阶段一样沿着对象图来搜索,只需要按照物理地址顺序,线性搜索出引用类型,并把旧值改成新值即可
  • 最终引用更新(Final Update Reference):在解决堆中对象的引用更新后,还需要修正一下存在GC Roots中的引用,这个阶段是Shenandoah收集器最后一次停顿,停顿时间只与GC Roots数量相关
  • 并发清理(Concurrent Cleanup):经过并发回收和引用更新后,整个回收集中所有Region再无存活对象,都变成Immediate Garbage Region了,所有需要最后调一次并发清理来回收掉这些Region的内存空间

https://shipilev.net/talks/devoxx-Nov2017-shenandoah.pdf

其实我们只要关注最重要的3个并发阶段(并发标记,并发回收,并发引用更新),就能比较容易地理解Shenandoah收集器工作原理了。如上图,黄色区域代表的是被选入回收集的Region,绿色区域代表还存活的对象,蓝色区域代表用户线程可以用来分配对象的内存Region,找出回收对象确定回收集,移动回收集中的存活对象,将指向回收集中存活对象的所有引用全部修正,这些动作全部在图中形象地被表达了出来

Shenandoah收集器的核心概念,其实就是Brooks Pointer,在这之前,要实现类似的并发操作,通常需要在被移动对象上原有的内存上设置保护陷阱(Memory Protection Trap),当用户程序访问到归属于旧对象的内存空间时,就会自陷中断,进入预设好的异常处理器,再由其中的代码逻辑把访问转发到复制后的新对象上,虽然确实能够实现对象移动与用户线程并发,但是如果没有操作系统层面的直接支持,这种方案将会导致用户态频繁切换到核心态,代价非常大,不能频繁使用。

而Brooks Pointer的提出者Brooks的新方案不需要用到内存保护陷阱,而是在原有对象布局结构最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,这个引用指向对象自己。

其实这个方案和早期Java虚拟机使用的句柄定位有相似之处,都是一种间接性对象访问方式,多了一次转发过程,然而差别是句柄会统一存储在专门的句柄池中,而转发指针则分散存放在每一个对象头前。

有了转发指针后,虽然每次对象访问多了一次转发开销,但是相比内存保护陷阱方案已经好了很多,当对象有了新的副本时,只需要修改一处引用值,就可以将所有对该旧对象的访问转发到新对象上,这样只要旧的对象内存仍然存在,虚拟机内存中通过旧引用地址访问的代码都仍然可用

事实上,转发指针的设计是会存在多线程竞争问题的,想象一下下面三个步骤:

  • 收集器线程复制了新的对象副本
  • 用户线程更新对象的某个字段
  • 收集器线程更新转发指针的引用值为新对象副本的地址

如果不做任何保护措施,让事件2发生在1和3之间,那么2的操作就会生效在旧的对象上,所以这里必须采取同步措施,Shenandoah收集器是通过比较并交换CAS(Compare And Swap)操作来保证并发时对象的访问正确性的

转发指针方案另一个问题是执行效率问题,对于一门面向对象编程语言来说,对象的读取,写入,比较,哈希值计算,对象加锁,都属于对象访问范畴,要覆盖这些操作,Shenandoah收集器不得不同时设置读写屏障来拦截

Shenandoah收集器在读写屏障中都加入了额外的转发处理,尤其是读屏障,比写屏障频率高出很多,Shenandoah也是第一款提到使用读屏障的收集器

# ZGC收集器

这款收集器名称中的Z并非专业名词的缩写,它的名字就叫ZGC

它的目标和Shenandoah收集器是高度相似的,都希望在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾手机的停顿时间限制在十毫秒以内的低延迟。

从ZGC的内存布局说起,它和Shenandoah还有G1一样,都使用基于Region的堆内存布局,但是不同之处在于,ZGC的Region具有动态性,可以动态销毁和创建,以及动态的区域容量大小,在x64硬件平台下,ZGC的Region可以分为以下大,中,小三类容量:

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象
  • 大型Region(Large Region):容量可以动态变化,但必须为2MB整数倍,用于放置4MB及以上的大对象,大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂

image-20210901220110209

ZGC在面对并发整理算法的时候,虽然和Shenandoah收集器一样用到了读屏障,但是用了另一种精巧的方式

ZGC使用了一种染色指针技术(Colored Pointer),在此之前如果需要在对象上存储一些额外的数据,通常会在对象头中增加存储字段,如对象的哈希码,分代年龄,锁记录等等,这些信息被存储在对象内存里,被访问的时候是非常自然的,但是如果当对象本身有被移动的可能性呢?另外是否可以在不访问对象本身的前提下,又能得知对象的某些信息呢?能否从与对象内存无关的地方来获取这些信息,或者能否看出对象被移动过?

在此之前,给对象打上三色标记(黑灰白)时,这些逻辑上的标记就和对象本身毫无关系,只与对象的引用有关,也就是说,只有对象的引用关系能决定它的存活与否,对象本身任何属性都不能影响它的存活判定结果。HotSpot虚拟机的几种标记实现方案,有的把标记记录在对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1,Shenandoah收集器使用了一种称为BitMap的数据结构),而ZGC使用的染色指针是最直接,最纯粹的,它直接把标记信息记录在引用对象的指针上

那么为什么指针本身也可以存储额外信息呢?在64位系统中,理论可以访问的内存高达16EB(2^64)字节,但实际上,基于需求(用不到那么多内存),性能(地址越宽在做地址转换的时候需要的页表级数越多)和成本(消耗更多晶体管)的考虑,现在64位的硬件实际能支持的最大内存只有256TB,此外,操作系统也会约束这些,64位的Linux分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,64位的Windows只支持44位(16TB)的物理地址空间

Linux下有46位能用来寻址,ZGC将其高4位提取出来存储4个标志信息,通过这4个标志位,虚拟机可以直接从指针中看出其引用对象的三色标记状态,是否进入了重分配集(即被移动过),是否只能通过finalize方法才能被访问到,这也正是为什么ZGC最高只支持管理4TB(2^42)及以下的内存空间

image-20210902221831914

即便染色指针有4TB内存限制,不支持32位平台,不支持压缩指针(-XX:+UseCompressedOops),它带来的收益也是相当可观的,如:

  • 当一个Region的存活对象被移走之后,这个Region能立即被释放和重用,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,而Shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region
  • 染色指针可以大幅减少垃圾收集过程中的内存屏障的使用数量,设置内存读写屏障的目的通常是为了记录对象引用的变动情况,如果将这部分信息直接维护在指针里就可以省去这些记录操作,ZGC没有使用任何写屏障,只用了读屏障(一方面因为染色指针,另一方面因为ZGC不支持分代收集,天然就没有跨代引用问题),不使用/少使用内存屏障对程序运行效率是非常有益的
  • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记,重定位过程相关的数据,以后可以进一步提高性能,现在Linux下64位指针还有前18位并未使用,如果腾出来,ZGC可支持的最大堆内存可从4TB扩展到64TB,可以利用其余位置再存储更多标记位,比如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域

接下来,我们来学习ZGC收集器是如何工作的,大致可分为以下四个大的阶段,全部四个阶段都可并发执行:

image-20210903214408456

  • 并发标记(Concurrent Mark):对遍历对象图做可达性分析,短暂停顿,标记发生在指针上而非对象上,会更新染色指针中的Marked0和Marked1标志位
  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定查询条件统计出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set),ZGC划分重分配集并非像G1划分回收集一样为了收益优先的增量回收,而是每次扫描所有Region,用范围更大的扫描换取省去G1中记忆集的维护成本,因此,ZGC重分配集只是决定了里边的存活对象会被重新复制到其它Region中,重分配集里边的Region会被释放,并不能说回收行为只针对重分配集中的Region,因为标记过程是针对全堆的,此外,JDK12中的ZGC会开始支持类卸载以及弱引用的处理,就是在这个阶段完成的
  • 并发重分配(Concurrent Relocate):重分配是ZGC执行的核心阶段,这个阶段要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转发关系,得益于转发指针的设计,ZGC收集器仅从指针上就可以明确得知一个对象是否处于重分配集中,如果用户线程此时并发访问到了重分配集中的对象,这次访问将会被预置的内存屏障截获,然后根据Region转发表的记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力,这样做的好处是只有第一次访问旧对象会陷入转发,只慢一次,相比Shenandoah收集器的Brooks转发指针的每次都需要转发,开销要低很多,还有另一个好处是,当重分配集中某个Region存活对象全部被复制完毕后,这个Region就可以立即释放了(但是转发表还需要保留),哪怕堆中还有很多指向这个对象的未更新指针也没关系,一旦旧对象被访问,指针都是可以自愈的
  • 并发重映射(Concurrent Remap):这个阶段做的事情就是修正整个堆中指向重分配集中对旧对象的所有引用的,但是这个阶段并不是必须要迫切完成的任务,因为旧引用是可以自愈的,最多也就是多了第一次访问需要额外转发一次的开销,而并发重映射阶段清理这些旧引用仅仅是为了不变慢,以及清理后可以释放转发表所占用的空间,因此ZGC巧妙地将这个步骤合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销,一旦所有指针都被修正后,原来记录新旧对象关系的转发表就可以被释放掉了
最近更新: 9/26/2021, 8:49:10 AM