ZGC 原理是什么,它为什么能做到低延时?

论坛 期权论坛 股票     
期权匿名问答   2023-2-13 13:03   3378   5
JDK 11将引入实验性的 ZGC,看介绍平均暂停时间低到1ms 左右,那么它是如何做到的?
分享到 :
0 人收藏

5 个回复

正序浏览
6#
期权匿名回答  16级独孤 | 2023-2-13 13:06:16 发帖IP地址来自 中国
ZGC所采用的算法就是Azul Systems很多年前提出的Pauseless GC
https://www.usenix.org/legacy/events/vee05/full_papers/p46-click.pdf而实现上它介乎早期Azul VM的Pauseless GC与后来Zing VM的C4之间。
虽然Oracle出的各种介绍资料上都完全没有提及ZGC与Azul的Pauseless GC(下面简称Azul PGC)之间的关系,而且我们从外部也无法证实或否认Oracle GC团队在研发ZGC的时候是否参考了Azul的论文,所以还不至于扣上抄袭啊克隆啊之类的帽子,但就结果来看ZGC确实就是换了一通术语、纯软件实现的Azul PGC。
这周在Oracle的Santa Clara园区(旧Sun园区)刚开了JVMLS 2018,我也找机会跟ZGC的领队Per大大聊了下,抽样问了若干设计点细节之后更加确认了ZGC与Azul PGC之间的对应性——核心算法没有差异,所以想要了解原理的话只要读上面的Azul论文即可。

Azul PGC简单来说是:它是一个mark-compact GC,但是GC过程中所有的阶段都设计为可以并发的,包括移动对象的阶段,所以GC正常工作的时候除了会在自己的线程上吃点CPU之外并不会显著干扰应用的运行。为了实现上方便,PGC虽然算法上可以做成完全并发,Azul PGC在Azul VM里的实现还是有三个非常短暂的safepoint,其中第一个是做根集合(root set)扫描,包括全局变量啊线程栈啊啥的里面的对象指针,但不包括GC堆里的对象指针,所以这个暂停就不会随着GC堆的大小而变化(不过会根据线程的多少啊、线程栈的大小之类的而变化)。另外两个暂停也同样不会随着堆大小而变化。
这样,Azul一般在宣称PGC / C4的时候会很保守地说“暂停不会超过10ms”,实际上维持在最大暂停时间1ms并不是难事。注意是最大暂停时间,而不是平均、90%、99%。
ZGC采用了同样的原理,于是也拥有相似的特性。

这种并发算法的核心思想就是:

  • 在标记阶段,与其说是标记对象(记录对象是否已经被标记),不如说是标记指针(记录GC堆里的每个指针是否已经被标记)。这就与传统的三色标记对象的GC算法有非常大的区别,虽然两者从收敛性上看是等价的——最终所有对象以及所有指针都会被遍历过。
  • 在标记和移动对象的阶段,每次从GC堆里的对象的引用类型字段里读取一个指针的时候,这个指针都会经过一个“Loaded Value Barrier”(LVB)。这是一种“Read Barrier”(读屏障),会在不同阶段做不同的事情。最简单的事情就是,在标记阶段它会把指针标记上并把堆里的这个指针给“修正”到新的标记后的值;而在移动对象的阶段,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而不需要通过stop-the-world这种最粗粒度的同步方式来让GC与应用之间同步。
  • LVB中有一点很重要,就是“self healing”性质:如果堆上有指针当前处于“尚未更新”的状态,一旦经过LVB之后就会被就地更新,于是在同一个GC周期内再次访问这个字段的话就不需要再修正了。这样LVB带来的性能开销(吞吐量的下降)就是非常短暂的,而不像Shenandoah GC所使用的Brooks indirection pointer那样一直都慢。

Azul PGC 与 Azul Zing VM里的C4 GC之间最大的区别就是,前者不分代,而后者是分两代的pauseless GC。在Zing的内部代码里,其实C4是叫做GPGC——Generational Pauseless GC。C4的New Generation与Old Generation采用的是完全一样的Pauseless算法,两代都同样(几乎)不暂停,New GC并不会导致完全stop-the-world。这跟HotSpot VM里的分代式GC实现们很不一样——那些Young GC都是会stop-the-world的。这是因为在Zing的应用场景里,New Generation可能就已经有几十GB了,如果完全stop-the-world那根本受不了。
ZGC目前不分代,所以跟Azul PGC更相似,而离C4还有距离。
至于为何ZGC目前不分代,有什么技术上的考量,在JVMLS 2018的ZGC Workshop里Per大大也给出了明确的回答:因为分代实现起来麻烦,想先实现出比较简单可用的版本;后续正在考量是添加分代版ZGC好还是添加一个Thread-Local GC作为ZGC的“前端”好,目前还在探索中。Per大大毫无遮掩地表示当前的ZGC如果遇到非常高的对象分配速率(allocation rate)的话会跟不上,目前唯一有效的“调优”方式就是增大整个GC堆的大小来让ZGC有更大的喘息空间。而添加分代或者Thread-Local GC则可以有效降低这种情况下对堆大小(喘息空间)的需求。

最后放俩传送门:
Azul Systems 是家什么样的公司?java的gc为什么要分代?
5#
期权匿名回答  16级独孤 | 2023-2-13 13:05:36 发帖IP地址来自 中国
泻药
https://dinfuehr.github.io/blog/a-first-look-into-zgc/大概能猜出来,缩短时长的策略几乎都是从串行改为并行,那就看并行的话,困难在哪里
最重要一点从传统的write barrier转换到read barrier,在zgc里面叫做load barrier
其他的gc策略,需要write barrier,不需要read barrier,zgc则相反
因为并行gc的策略,read barrier是必需的,而write barrier则不是必需的
但是每一次程序access对象的时候,都需要将该屏障插入一次,cache中所有read的操作全部被kick out
这能解释为什么吞吐会比g1下降不到15%
4#
期权匿名回答  16级独孤 | 2023-2-13 13:04:59 发帖IP地址来自 北京
为什么低延迟,请听我慢慢道来。
现代垃圾收集器的演进大部分都是往减少停顿方向发展。
像 CMS 就是分离出一些阶段使得应用线程可以和垃圾回收线程并发,当然还有利用回收线程的并行来减少停顿的时间。
基本上 STW 阶段都是利用多线程并行来减少停顿时间,而并发阶段不会有太多的回收线程工作,这是为了不和应用线程争抢 CPU,反正都并发了慢就慢点(不过还是得考虑内存分配速率)。
而 G1 可以认为是打开了另一个方向的大门:只回收部分垃圾来减少停顿时间
不过为了达到只回收部分 reigon,每个 region 都需要 RememberSet 来记录各 region 之间的引用。这个内存的开销其实还是挺大的,可能会占据整堆的20%或以上。
并且 G1 还有写屏障的开销,虽说用了 logging wtire barrier,但也还是有开销的。
当然 CMS 也用了写屏障,不过逻辑比较简单,啥都没判断就单纯的记录。
其实 G1 相对于 CMS 只有在大堆的场景下才有优势,CMS 比较伤的是 remark 阶段,如果堆太大需要扫描的东西太多。
而 G1 在大堆的时候可以选择部分收集,所以停顿时间有优势。
今天的主角 ZGC 和 G1 一样是基于 reigon 的,几乎所有阶段都是并发的,整堆扫描,部分收集
而且 ZGC 还不分代,就是没分新生代和老年代。
那它为啥比 G1 要牛皮?今天咱们就来盘一盘。
本文会先介绍 ZGC 的特性,或者说几个关键点,然后再简述下整体回收流程
基本上看下来对 ZCG 心中就有数了,作为普通的 Javaer,了解到这个程度就差不多了。




好了,让我们进入今天的正题!
ZGC 的目标

垃圾收集器设计出来都有目标的,有些是为了更高的吞吐,有些是为了更低的延迟。
所以我们先看看 ZGC 的目标:




可以看到它的目标就是低延迟,保证最大停顿时间在几毫秒之内,不管你堆多大或者存活的对象有多少。
可以处理 8MB-16TB 的堆。
咱们就按 openjdk 的 wiki 来展开今天的内容。




关键字:并发、基于Region、整理内存、支持NUMA、用了染色指针、用了读屏障,对了 ZGC 用的是 STAB。
Concurrent

这个 Concurrent 的意思是和应用线程并发执行,ZGC 一共分了 10 个阶段,只有 3 个很短暂的阶段是 STW 的。




可以看到只有初始标记、再标记、初始转移阶段是 STW 的。
初始标记就扫描 GC Roots 直接可达的,耗时很短,重新标记一般而言也很短,如果超过 1ms 会再次进入并发标记阶段再来一遍,所以影响不大。
初始转移阶段也是扫描 GC Roots 也很短,所以可以认为 ZGC 几乎是并发的。
而且之所以说停顿时间不会随着堆的大小和存活对象的数量增加而增加,是因为 STW 几乎只和 GC Roots 集合大小有关,和堆大小没啥关系。
这其实就是 ZGC 超过 G1 很关键的一个地方, G1 的对象转移需要 STW 所以堆大需要转移对象多,停顿的时间就长了,而 ZGC 有并发转移
不过并发回收有个情况就是回收的时候应用线程还是在产生新的对象,所以需要预留一些空间给并发时候生成的新对象。
如果对象分配过快导致内存不够,在 CMS 中是发生 Full gc,而 ZGC 则是阻塞应用线程。
所以要注意 ZGC 触发的时间。
ZGC 有自适应算法来触发也有固定时间触发,所以可以根据实际场景来修改 ZGC 触发时间,防止过晚触发而内存分配过快导致线程阻塞。
还有设置 ParallelGCThreads 和 ConcGCThreads,分别是 STW 并行时候的线程数和并发阶段的线程数来加快回收的速度。
不过 ConcGCThreads 数量需要注意,因为此阶段是和应用线程并发,如果线程数过多会影响应用线程
其实 ZGC 的每个阶段都是串行的,所以理论上其实可以不需要分两类线程,那为什么分了这两类线程?
就是为了灵活设置。分成两类就可以通过配置来调优,达到性能最大值。
对了上面提到 ZGC 的 STW 和 GC Roots 集合大小有关系,所以如果在会生成很多线程、动态加载很多 ClassLoader 等情况下会增加 ZGC 的停顿时间。
这点需要注意。
Region-based

为了能更细粒度的控制内存的分配,和 G1 一样 ZGC 也将堆划分成很多分区。
分了三种:2MB、32MB 和 X*MB(受操作系统控制)。
下图为源码中的注释:




对于回收的策略是优先收集小区,中、大区尽量不回收。
Compacting

和 G1 一样都分区了所以肯定从整体来看像是标记-复制算法,所以也是会整理的。
因此 ZGC 也不会产生内存碎片。
具体的流程下文再做分析。
NUMA-aware

以前的 G1 是不支持的,不过在 JDK14 G1 也支持了。




可能有的同学对 NUMA 不太熟悉,没事我先来解释一波。
在早期处理器都是单核的,因为根据摩尔定律,处理器的性能每隔一段时间就可以成指数型增长。
而近年来这个增长的速度逐渐变缓,于是很多厂商就推出了双核多核的计算机。
早期 CPU 通过前端总线到北桥到内存总线然后才访问到内存。




这个架构被称为 SMP (Symmetric Multi-Processor),因为任一个 CPU 对内存的访问速度是一致的,不用考虑不同内存地址之间的差异,所以也称一致内存访问(Uniform Memory Access, UMA )。
这个核心越加越多,渐渐的总线和北桥就成为瓶颈,那不能够啊,于是就想了个办法。
把 CPU 和内存集成到一个单元上,这个就是非一致内存访问 (Non-Uniform Memory Access,NUMA)。




简单的说就是把内存分一分,每个 CPU 访问自己的本地的内存比较快,访问别人的远程内存就比较慢。
当然也可以多个 CPU 享受一块内存或者多块,如下图所示:




但是因为内存被切分为本地内存和远程内存,当某个模块比较“热”的时候,就可能产生本地内存爆满,而远程内存都很空闲的情况。
比如 64G 内存一分为二,模块一的内存用了31G,而另一个模块的内存用了5G,且模块一只能用本地内存,这就产生了内存不平衡问题。
如果有些策略规定不能访问远程内存的时候,就会出现明明还有很多内存却产生 SWAP(将部分内存置换到硬盘中) 的情况。
即使允许访问远程内存那也比本地内存访问速率相差较大,这是使用 NUMA 需要考虑的问题。
ZGC 对 NUMA 的支持是小分区分配时会优先从本地内存分配,如果本地内存不足则从远程内存分配。
对于中、大分区的话就交由操作系统决定。
上述做法的原因是生成的绝大部分都是小分区对象,因此优先本地分配速度较快,而且也不易造成内存不平衡的情况。
而中、大分区对象较大,如果都从本地分配则可能会导致内存不平衡的情况。
Using colored pointers

染色指针其实就是从 64 位的指针中,拿几位来标识对象此时的情况,分别表示 Marked0、Marked1、Remapped、Finalizable。




我们再来看下源码中的注释,非常的清晰直观:




0-41 这 42 位就是正常的地址,所以说 ZGC 最大支持 4TB (理论上可以16TB)的内存,因为就 42 位用来表示地址。
也因此 ZGC 不支持 32 位指针,也不支持指针压缩。
然后用 42-45 位来作为标志位,其实不管这个标志位是啥指向的都是同一个对象。
这是通过多重映射来做的,很简单就是多个虚拟地址指向同一个物理地址,不过对象地址是 0001.... 还是0010....还是0100..... 对应的都是同一个物理地址即可。
具体这几个标记位怎么用的,待下文回收流程分析再解释。
不过这里先提个问题,为什么就支持 4TB,不是还有很多位没用吗
首先 X86_64 的地址总线只有 48 条 ,所以最多其实只能用 48 位,指令集是 64 位没错,但是硬件层面就支持 48 位。
因为基本上没有多少系统支持这么大的内存,那支持 64 位就没必要了,所以就支持到 48 位。
那现在对象地址就用了 42 位,染色指针用了 4 位,不是还有 2 位可以用吗?
是的,理论上可以支持 16 TB,不过暂时认为 4TB 够了,所以暂做保留,仅此而已没啥特别的含义。
Using load barriers

在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障。
写屏障是在对象引用赋值时候的 AOP,而读屏障是在读取引用时的 AOP。
比如 Object a = obj.foo;,这个过程就会触发读屏障。
也正是用了读屏障,ZGC 可以并发转移对象,而 G1 用的是写屏障,所以转移对象时候只能 STW。
简单的说就是 GC 线程转移对象之后,应用线程读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移。
如果是的话修正对象的引用,按照上面的例子,不仅 a 能得到最新的引用地址,obj.foo 也会被更新,这样下次访问的时候一切都是正常的,就没有消耗了。
下图展示了读屏障的效果,其实就是转移的时候找地方记一下即 forwardingTable,然后读的时候触发引用的修正。




这种也称之为“自愈”,不仅赋值的引用时最新的,自身引用也修正了。
染色指针和读屏障是 ZGC 能实现并发转移的关键所在
ZGC 回收流程解析

ZGC 的步骤大致可分为三大阶段分别是标记、转移、重定位。

  • 标记:从根开始标记所有存活对象
  • 转移:选择部分活跃对象转移到新的内存空间上
  • 重定位:因为对象地址变了,所以之前指向老对象的指针都要换到新对象地址上。
并且这三个阶段都是并发的。
这是意识上的阶段,具体的实现上重定位其实是糅合在标记阶段的
在标记的时候如果发现引用的还是老的地址则会修正成新的地址,然后再进行标记。
简单的说就是从第一个 GC 开始经历了标记,然后转移了对象,这个时候不会重定位,只会记录对象都转移到哪里了。
在第二个 GC 开始标记的时候发现这个对象是被转移了,然后发现引用还是老的,则进行重定位,即修改成新的引用。
所以说重定位是糅合在下一步的标记阶段中。




我再简单说一下十个步骤。
不过步骤里有些不影响整体回收流程的,我就不多加分析了。
这篇文章的目的不是深入 ZGC 实现的细节,而是了解 ZGC 大致的突出点和简单流程即可
因此想知道细节的自行查阅,或者可以看看我文末推荐的书籍。
初始标记

这个阶段其实大家应该很熟悉,CMS、G1 都有这个阶段,这个阶段是 STW 的,仅标记根直接可达的对象,压到标记栈中
当然还有其他动作,比如重置 TLAB、判断是否要清除软引用等等,不做具体分析。
并发标记

就是根据初始标记的对象开始并发遍历对象图,还会统计每个 region 的存活对象的数量。
这个并发标记其实有个细节,标记栈其实只有一个,但是并发标记的线程有多个。
为了减少之间的竞争每个线程其实会分到不同的标记带来执行。
你就理解为标记栈被分割为好几块,每个线程负责其中的一块进行遍历标记对象,就和1.7 Hashmap 的segment 一样。
那肯定有的线程标记的快,有的标记的慢,那么先空闲下来的线程会去窃取别人的任务来执行,从而实现负载均衡。
看到这有没有想到啥?没错就是 ForkJoinPool 的工作窃取机制!
再标记阶段

这一阶段是 STW 的,因为并发阶段应用线程还是在运行的,所以会修改对象的引用导致漏标的情况。
因此需要个再标记阶段来标记漏标的那些对象。
如果这个阶段执行的时间过长,就会再次进入到并发标记阶段,因为 ZGC 的目标就是低延迟,所以一有高延迟的苗头就得扼制。
这个阶段还会做非强根并行标记,非强根指的是:系统字典、JVMTI、JFR、字符串表。
有些非强根可以并发,有些不行,具体不做分析。
非强引用并发标记和引用并发处理

就是上一步非强根的遍历,然后引用就软引用、弱引用、虚引用的一些处理。
这个阶段是并发的。
重置转移集

还记得标记时候的重定位么?在写读屏障时候提到的 forwardingTable 就是个映射集,你可以理解为 key 就是对象转移前的地址,value 是对象转移后的地址。
不过这个映射集在标记阶段已经用了,也就是说标记的时候已经重定位完了,所以现在没用了。
但新一轮的垃圾回收需要还是要用到这个映射集的。
因此在这个阶段对那些转移分区的地址映射集做一个复位的操作。
回收无效分区

回收那些物理内存已经被释放的无效的虚拟内存页面。
就是在内存紧张的时候会释放物理内存,如果同时释放虚拟空间的话也不能释放分区,因为分区需要在新一轮标记完成之后才能释放
所以就会有无效的虚拟内存页面存在,在这个阶段回收。
选择待回收的分区

这和 G1 一样,因为会有很多可以回收的分区,会筛选垃圾较多的分区,来作为这次回收的分区集合。
初始化待转移集合的转移表

这一步就是初始化待回收的分区的 forwardingTable。
初始转移

这个阶段其实就是从根集合出发,如果对象在转移的分区集合中,则在新的分区分配对象空间。
如果不在转移分区集合中,则将对象标记为 Remapped。
注意这个阶段是 STW,只转移根直接可达的对象。
并发转移

这个阶段和并发标记阶段就很类似了,从上一步转移的对象开始遍历,做并发转移。
这一步很关键。
G1 的转移对象整体都需要 STW,而 ZGC 做到了并发转移,所以延迟会低很多。
至此十个步骤就完毕了,一次 GC 结束。
可以还能同学对染色指针的几个标记位有点懵,没事看了下文就懂了。
染色指针的标记位

来分析下几个标记位,M0、M1、Remapped。
先来介绍个名词,地址视图:指的就是此时地址指针的标记位。
比如标记位现在是 M0,那么此时的视图就是 M0 视图。
在垃圾回收开始前视图是 Remapped 。
进入标记标记时。
标记线程访问发现对象地址视图是 Remapped 这时候将指针标记为 M0,即将地址视图置为 M0,表示活跃对象。
如果扫描到对象地址视图是 M0 则说明这个对象是标记开始之后新分配的或者已经标记过的对象,所以无需处理。
应用线程 如果创建新对象,则将其地址视图置为 M0,如果访问的对象地址视图是 Remapped 则将其置为 M0,并且递归标记其引用的对象。
如果访问到的是 M0 ,则无需操作。
标记阶段结束后,ZGC 会使用一个对象活跃表来存储这些对象地址,此时活跃的对象地址视图是 M0。
并发转移阶段,地址视图被置为 Remapped 。
也就是说 GC 线程如果访问到对象,此时对象地址视图是 M0,并且存在或活跃表中,则将其转移,并将地址视图置为 Remapped 。
如果在活跃表中,但是地址视图已经是 Remapped 说明已经被转移了,不做处理。
应用线程此时创建新对象,地址视图会设为  Remapped 。
此时访问对象如果对象在活跃表中,且地址视图为 Remapped  说明转移过了,不做处理。
如果地址视图为 M0,则说明还未转移,则需要转移,并将其地址视图置为 Remapped  。
如果访问到的对象不在活跃表中,则不做处理。
那 M1 什么用
M1 是在下一次 GC 时候用的,下一次的 GC 就用 M1来标记,不用 M0。
再下一次再换过来。
简单的说就是 M1 标识本次垃圾回收中活跃的对象,而 M0 是上一次回收被标记的对象,但是没有被转移,在本次回收中也没有被标记活跃的对象。
其实从上面的分析以及得知,如果没有被转移就会停留在 M0 这个地址视图。
而下一次 GC 如果还是用 M0 来标识那混淆了这两种对象。
所以搞了个 M1。
至此染色指针这几个标志位应该就很清晰了,我在用图来示意一下。




不清晰的同学建议再多看几遍标记位的变更,不复杂的。
最后

简单的总结下,ZGC 就是通过多阶段的并发和几个短暂的 STW 阶段来达到低延迟的特性。
利用指针染色技术和读屏障实现并发转移对象,利用 STAB 保证并发阶段不会漏标对象。
这一波一下相信大家对于 ZGC 有了一定的了解。
我个人认为重点就掌握官网罗列的那几个要点就行,毕竟咱们也不是写 GC 的,作为了解即可。
到时候和学妹呀,或者在面试官前面呀都可以小吹一下。
如果想深入了解当然可以,可先看看《新一代垃圾回收器ZGC设计与实现》这本书,然后再源码走起。
ZGC 的不分代其实是它的缺点,因为分代比较难实现,不过以后应该会加上吧。
其实从现代垃圾收集器的演进可以看出就是往并发上面靠,目标就是减少停顿的时间。
不过并发需要注意内存分配的速率,因为并发导致一次垃圾回收总的时间变长了
如果内存分配过快那就回收不过来了,因此都需要预留内存空间或者说要更大的内存空间来应对快速的分配速率。
巨人的肩膀

https://www.iteye.com/blog/user/rednaxelafx R大的博客
https://malloc.se/blog/zgc-jdk15
https://wiki.openjdk.java.net/display/zgc/Main
《新一代垃圾回收器ZGC设计与实现》
我是 yes,从一点点到亿点点,我们下篇见。
3#
期权匿名回答  16级独孤 | 2023-2-13 13:04:19 发帖IP地址来自 福建
大家好,我是君哥。今天来聊一聊 ZGC。

ZGC(Z Garbage Collector) 是一款性能比 G1 更加优秀的垃圾收集器。ZGC 第一次出现是在 JDK 11 中以实验性的特性引入,这也是 JDK 11 中最大的亮点。在 JDK 15 中 ZGC 不再是实验功能,可以正式投入生产使用了,使用 –XX:+UseZGC 可以启用 ZGC。
ZGC 有 3 个重要特性:

  • 暂停时间不会超过 10 ms。
JDK 16 发布后,GC 暂停时间已经缩小到 1 ms 以内,并且时间复杂度是 o(1),这也就是说 GC 停顿时间是一个固定值了,并不会受堆内存大小影响。
下面图片来自:https://malloc.se/blog/zgc-jdk16



  • 最大支持 16TB 的大堆,最小支持 8MB 的小堆。
  • 跟 G1 相比,对应用程序吞吐量的影响小于 15 %。
1 内存多重映射

内存多重映射,就是使用 mmap 把不同的虚拟内存地址映射到同一个物理内存地址上。如下图:


ZGC 为了更灵活高效地管理内存,使用了内存多重映射,把同一块儿物理内存映射为 Marked0、Marked1 和 Remapped 三个虚拟内存。

当应用程序创建对象时,会在堆上申请一个虚拟地址,这时 ZGC 会为这个对象在 Marked0、Marked1 和 Remapped 这三个视图空间分别申请一个虚拟地址,这三个虚拟地址映射到同一个物理地址。
Marked0、Marked1 和 Remapped 这三个虚拟内存作为 ZGC 的三个视图空间,在同一个时间点内只能有一个有效。ZGC 就是通过这三个视图空间的切换,来完成并发的垃圾回收。
2 染色指针

2.1 三色标记回顾

我们知道 G1 垃圾收集器使用了三色标记,这里先做一个回顾。下面是一个三色标记过程中的对象引用示例图:


总共有三种颜色,说明如下:

  • 白色:本对象还没有被标记线程访问过。
  • 灰色:本对象已经被访问过,但是本对象引用的其他对象还没有被全部访问。
  • 黑色:本对象已经被访问过,并且本对象引用的其他对象也都被访问过了。
三色标记的过程如下:

  • 初始阶段,所有对象都是白色。
  • 将 GC Roots 直接引用的对象标记为灰色。
  • 处理灰色对象,把当前灰色对象引用的所有对象都变成灰色,之后将当前灰色对象变成黑色。
  • 重复步骤 3,直到不存在灰色对象为止。
三色标记结束后,白色对象就是没有被引用的对象(比如上图中的 H 和 G),可以被回收了。
2.2 染色指针

ZGC 出现之前, GC 信息保存在对象头的 Mark Word 中。比如 64 位的 JVM,对象头的 Mark Word 中保存的信息如下图:


前 62位保存了 GC 信息,最后两位保存了锁标志。
ZGC 的一大创举是将 GC 信息保存在了染色指针上。染色指针是一种将少量信息直接存储在指针上的技术。在 64 位 JVM 中,对象指针是 64 位,如下图:


在这个 64 位的指针上,高 16 位都是 0,暂时不用来寻址。剩下的 48 位支持的内存可以达到 256 TB(2 ^48),这可以满足多数大型服务器的需要了。不过 ZGC 并没有把 48 位都用来保存对象信息,而是用高 4 位保存了四个标志位,这样 ZGC 可以管理的最大内存可以达到 16 TB(2 ^ 44)。
通过这四个标志位,JVM 可以从指针上直接看到对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集(Remapped)、是否需要通过 finalize 方法来访问到(Finalizable)。
无需进行对象访问就可以获得 GC 信息,这大大提高了 GC 效率。
3 内存布局

首先我们回顾一下 G1 垃圾收集器的内存布局。G1把整个堆分成了大小相同的 region,每个堆大约可以有 2048 个region,每个 region 大小为 1~32 MB (必须是 2 的次方)。如下图:


跟 G1 类似,ZGC 的堆内存也是基于 Region 来分布,不过 ZGC 是不区分新生代老年代的。不同的是,ZGC 的 Region 支持动态地创建和销毁,并且 Region 的大小不是固定的,包括三种类型的 Region :

  • Small Region:2MB,主要用于放置小于 256 KB 的小对象。
  • Medium Region:32MB,主要用于放置大于等于 256 KB 小于 4 MB 的对象。
  • Large Region:N * 2MB。这个类型的 Region 是可以动态变化的,不过必须是 2MB 的整数倍,最小支持 4 MB。每个 Large Region 只放置一个大对象,并且是不会被重分配的。
4 读屏障

读屏障类似于 Spring AOP 的前置增强,是 JVM 向应用代码中插入一小段代码,当应用线程从堆中读取对象的引用时,会先执行这段代码。注意:只有从堆内存中读取对象的引用时,才会执行这个代码。下面代码只有第一行需要加入读屏障。
Object o = obj.FieldA
Object p = o //不是从堆中读取引用
o.dosomething() //不是从堆中读取引用
int i =  obj.FieldB //不是引用类型读屏障在解释执行时通过 load 相关的字节码指令加载数据。作用是在对象标记和转移过程中,判断对象的引用地址是否满足条件,并作出相应动作。如下图:


标记、转移和重定位这些过程请看下一节。
读屏障会对应用程序的性能有一定影响,据测试,对性能的最高影响达到 4%,但提高了 GC 并发能力,降低了 STW。
5 GC 过程

前面已经讲过,ZGC 使用内存多重映射技术,把物理内存映射为 Marked0、Marked1 和 Remapped 三个地址视图,利用地址视图的切换,ZGC 实现了高效的并发收集。
ZGC 的垃圾收集过程包括标记、转移和重定位三个阶段。如下图:


ZGC 初始化后,整个内存空间的地址视图被设置为 Remapped。
5.1 初始标记

从 GC Roots 出发,找出 GC Roots 直接引用的对象,放入活跃对象集合,这个过程需要 STW,不过 STW 的时间跟 GC Roots 数量成正比,耗时比较短。
5.2 并发标记

并发标记过程中,GC 线程和 Java 应用线程会并行运行。这个过程需要注意下面几点:

  • GC 标记线程访问对象时,如果对象地址视图是 Remapped,就把对象地址视图切换到 Marked0,如果对象地址视图已经是 Marked0,说明已经被其他标记线程访问过了,跳过不处理。
  • 标记过程中Java 应用线程新创建的对象会直接进入 Marked0 视图。
  • 标记过程中Java 应用线程访问对象时,如果对象的地址视图是 Remapped,就把对象地址视图切换到 Marked0,可以参考前面讲的读屏障。
  • 标记结束后,如果对象地址视图是 Marked0,那就是活跃的,如果对象地址视图是 Remapped,那就是不活跃的。
标记阶段的活跃视图也可能是 Marked1,为什么会采用两个视图呢?
这里采用两个视图是为了区分前一次标记和这一次标记。如果这次标记的视图是 Marked0,那下一次并发标记就会把视图切换到 Marked1。这样做可以配合 ZGC 按照页回收垃圾的做法。如下图:


第二次标记的时候,如果还是切换到 Marked0,那么 2 这个对象区分不出是活跃的还是上次标记过的。如果第二次标记切换到 Marked1,就可以区分出了。
这时 Marked0 这个视图的对象就是上次标记过程被标记过活跃,转移的时候没有被转移,但这次标记没有被标记为活跃的对象。Marked1 视图的对象是这次标记被标记为活跃的对象。Remapped 视图的对象是上次垃圾回收发生转移或者是被 Java 应用线程访问过,本次垃圾回收中被标记为不活跃的对象。
5.3 再标记

并发标记阶段 GC 线程和 Java 应用线程并发执行,标记过程中可能会有引用关系发生变化而导致的漏标记问题。再标记阶段重新标记并发标记阶段发生变化的对象,还会对非强引用(软应用,虚引用等)进行并行标记。
这个阶段需要 STW,但是需要标记的对象少,耗时很短。
5.4 初始转移

转移就是把活跃对象复制到新的内存,之前的内存空间可以被回收。
初始转移需要扫描 GC Roots 直接引用的对象并进行转移,这个过程需要 STW,STW 时间跟 GC Roots 成正比。
5.5 并发转移

并发转移过程 GC 线程和 Java 线程是并发进行的。上面已经讲过,转移过程中对象视图会被切回 Remapped 。转移过程需要注意以下几点:

  • 如果 GC 线程访问对象的视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
  • 如果 GC 线程访问对象的视图是 Remapped,说明被其他 GC 线程处理过,跳过不再处理。
  • 并发转移过程中 Java 应用线程创建的新对象地址视图是 Remapped。
  • 如果 Java 应用线程访问的对象被标记为活跃并且对象视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
5.6 重定位

转移过程对象的地址发生了变化,在这个阶段,把所有指向对象旧地址的指针调整到对象的新地址上。
6 垃圾收集算法

ZGC 采用标记 - 整理算法,算法的思想是把所有存活对象移动到堆的一侧,移动完成后回收掉边界以外的对象。如下图:


6.1 JDK 16 之前

在 JDK 16 之前,ZGC 会预留(Reserve)一块儿堆内存,这个预留内存不能用于 Java 线程的内存分配。即使从 Java 线程的角度看堆内存已经满了也不能使用 Reserve,只有 GC 过程中搬移存活对象的时候才可以使用。如下图:


这样做的好处是算法简单,非常适合并行收集。但这样做有几个问题:

  • 因为有预留内存,能给 Java 线程分配的堆内存小于 JVM 声明的堆内存。
  • Reserve 仅仅用于存放 GC 过程中搬移的对象,有点内存浪费。
  • 因为 Reserve 不能给 GC 过程中搬移对象的 Java 线程使用,搬移线程可能会因为申请不到足够内存而不能完成对象搬移,这返回过来又会导致应用程序的 OOM。
6.2 JDK 16 改进

JDK 16 发布后,ZGC 支持就地搬移对象(G1 在 Full GC 的时候也是就地搬移)。这样做的好处是不用预留空闲内存了。如下图:


不过就地搬移也有一定的挑战。比如:必须考虑搬移对象的顺序,否则可能会覆盖尚未移动的对象。这就需要 GC 线程之间更好的进行协作,不利于并发收集,同时也会导致搬移对象的 Java 线程需要考虑什么可以做什么不可以做。
为了获得更好的 GC 表现,JDK 16 在支持就地搬移的同时,也支持预留(Reserve)堆内存的方式,并且 ZGC 不需要真的预留空闲的堆内存。默认情况下,只要有空闲的 region,ZGC 就会使用预留堆内存的方式,如果没有空闲的 region,否则 ZGC 就会启用就地搬移。如果有了空闲的 region, ZGC 又会切换到预留堆内存的搬移方式。
7 总结

内存多重映射和染色指针的引入,使 ZGC 的并发性能大幅度提升。
ZGC 只有 3 个需要 STW 的阶段,其中初始标记和初始转移只需要扫描所有 GC Roots,STW 时间 GC Roots 的数量成正比,不会耗费太多时间。再标记过程主要处理并发标记引用地址发生变化的对象,这些对象数量比较少,耗时非常短。可见整个 ZGC 的 STW 时间几乎只跟 GC Roots 数量有关系,不会随着堆大小和对象数量的变化而变化。
ZGC 也有一个缺点,就是浮动垃圾。因为 ZGC 没有分代概念,虽然 ZGC 的 STW 时间在 1ms 以内,但是 ZGC 的整个执行过程耗时还是挺长的。在这个过程中 Java 线程可能会创建大量的新对象,这些对象会成为浮动垃圾,只能等下次 GC 的时候进行回收。

参考:

1.https://wiki.openjdk.java.net/display/zgc
2.https://openjdk.java.net/jeps/304
3.https://openjdk.java.net/jeps/376
4.https://malloc.se/blog/zgc-jdk16
5.https://mp.weixin.qq.com/s/ag5u2EPObx7bZr7hkcrOTg
6.https://mp.weixin.qq.com/s/FIr6r2dcrm1pqZj5Bubbmw
7.https://www.jianshu.com/p/664e4da05b2c
8.https://www.cnblogs.com/jimoer/p/13170249.html
9.https://www.jianshu.com/p/12544c0ad
2#
期权匿名回答  16级独孤 | 2023-2-13 13:03:42 发帖IP地址来自 中国
有性能问题,上HeapDump性能社区!
ZGC(The Z Garbage Collector)是JDK 11中推出的一款追求极致低延迟的实验性质的垃圾收集器,它曾经吹的牛有:

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持8MB~4TB级别的堆(未来支持16TB)。
现在这些牛正在一个个被实现,有种好莱坞英雄的爽感!
基于最新的JDK15来看,“停顿时间不超过10ms”和“支持16TB的堆”这两个目标已经实现,并且官方明确指出JDK15中的ZGC不再是实验性质的垃圾收集器,且建议投入生产了。
为什么可以实现?Java最前沿技术--ZGC ,这一篇告诉你一些细节!
它什么时候会开启垃圾回收?这里告诉你:ZGC什么时候会进行垃圾回收
当然,最新的JDK16里,ZGC的源码也有,字节跳动大佬来解析:OpenJDK16 ZGC 详细源码分析
还有:
怎么调优,通过12 张图带你彻底理解 ZGC及调优
如何实战,58同城关于ZGC的应用实践
了解更多ZGC,可移步有性能问题,上HeapDump性能社区!
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:400157
帖子:80032
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP