JVM——ZGC 垃圾收集器原理详解

2023-03-07  本文已影响0人  小波同学

一、ZGC简介和性能

G1的目标是在可控的停顿时间内完成垃圾回收,所以进行了分区设计,在回收时采用部分内存回收(在YGC时会回收所有新生代分区,在混合回收时会回收所有的新生代分区和部分老生代分区),支持的内存也可以达到几十个GB或者上百个GB。为了进行部分回收,G1实现了RSet管理对象的引用关系。基于G1设计上的特点,导致存在以下问题:

ZGC作为新一代的垃圾回收器,在设计之初就定义了三大目标:

实际上目前ZGC已经满足设计之初定义的目标,最大支持4TB堆空间,依据实际测试的情况来看,停顿时间通常都在10ms以下,并且垃圾回收所引起的暂停时间并不会随着内存的增大而延长。

ZGC如何设计以达成目标?
简单地说,就是ZGC把一切能并发处理的工作都并发执行。
ZGC是在G1的基础上发展起来的,我们知道G1中实现了并发标记,所以标记已经不会再影响停顿时间了。

G1中的停顿时间主要来自垃圾回收(YGC和混合回收)阶段中的复制算法,在复制算法中,需要把对象转移到新的空间中,并且更新其他对象到这个对象的引用。实际中对象的转移涉及内存的分配和对象成员变量的复制,而对象成员变量的复制是非常耗时的。

在G1中对象的转移都是在STW中并行执行的,而ZGC就是把对象的转移也并发执行,从而满足停顿时间在10ms以下。

我们看到G1只有在MARKING的时候,是并发的。而ZGC 在对象的复制和压缩,复制集的选择,等很多方面都改成了并发(和应用线程同时进行)。这就是它STW时间如此之短的秘诀。

JDK 16 发布后,GC 暂停时间已经缩小到 1 ms 以内,并且时间复杂度是 o(1),这也就是说 GC 停顿时间是一个固定值了,并不会受堆内存大小影响。

ZGC 有 3 个重要特性:

图片来自:https://malloc.se/blog/zgc-jdk16

1、内存多重映射

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


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

当应用程序创建对象时,会在堆上申请一个虚拟地址,这时 ZGC 会为这个对象在 Marked0、Marked1 和 Remapped 这三个视图空间分别申请一个虚拟地址,这三个虚拟地址映射到同一个物理地址。

Marked0、Marked1 和 Remapped 这三个虚拟内存作为 ZGC 的三个视图空间,在同一个时间点内只能有一个有效。ZGC 就是通过这三个视图空间的切换,来完成并发的垃圾回收。

2、染色指针

2.1 三色标记回顾

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


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

三色标记的过程如下:

三色标记结束后,白色对象就是没有被引用的对象(比如上图中的 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 :

4、读屏障

读屏障类似于 Spring AOP 的前置增强,是 JVM 向应用代码中插入一小段代码,当应用线程从堆中读取对象的引用时,会先执行这段代码。注意:只有从堆内存中读取对象的引用时,才会执行这个代码。下面代码只有第一行需要加入读屏障。

Object o = obj.FieldA
Object p = o //不是从堆中读取引用
o.dosomething() //不是从堆中读取引用
int i =  obj.FieldB //不是引用类型

读屏障在解释执行时通过 load 相关的字节码指令加载数据。作用是在对象标记和转移过程中,判断对象的引用地址是否满足条件,并作出相应动作。如下图:


读屏障会对应用程序的性能有一定影响,据测试,对性能的最高影响达到 4%,但提高了 GC 并发能力,降低了 STW。

二、ZGC流程介绍

并发垃圾回收算法实际上是以复制算法为基础,增加了并发处理。我们先回顾一下复制算法,它可以概括为3个阶段,分别为标记(mark)、转移(relocate)和重定位(remap)。这3个阶段分别完成的功能是:

2.1 ZGC中的转移

首先我们回顾一下复制算法和标记整理算法。
在复制算法中,会有俩块内存空间,空间A和空间B,清理步骤如下:

在标记整理算法中,只在同一块内存空间中完全全部操作。

而在ZGC中的转移,有可能是复制算法,也有可能是标记整理算法。这取决于是否还有空余页,如果有的话就使用复制算法,如果已经没有多余的页,就使用标记整理算法,在一个页中完成垃圾回收。

因此,对于转移的定义是:对象的复制或移动(标记整理算法)

2.2 ZGC中的重定位

在复制对象后,对象的内存地址发生了变化,所以所有指向对象老地址的指针都需要调整到对象的新地址上,这个过程称为重定位。
比方说,我们堆中有一个对象,此时指针为蓝色(Remapped视图)


在标记阶段,因为该对象是可达的活跃对象,因此指针会被标记为绿色(M0视图)。然后在转移阶段,绿色的对象会被转移到另外一个页内存中。


原有的空间将会释放:


转移结束后,变量u持有的指针还是指向原来的老地址。但因为此时的指针不是处于Remapped视图。因此,当应用程序线程读取到这个指针时,会去一个转发表中找到对象的最新地址,然后修正原来的指针。(转发表可以理解成一个Map,可以根据老地址找到新的地址)


最终,指针重新变回了Remapped视图。

以上演示的是惰性对象重定位。这也是为什么ZGC在垃圾回收阶段不用STW的原因。而G1在筛选回收阶段会进行STW,就是因为G1没有实现对象重定位,如果不STW,应用程序线程就可能对老对象地址进行操作,从而造成不可预知的错误。

从细节角度可以分为如下步骤



上述3个蓝线代表需要STW, 灰线代表可以并发运作。

并发算法中明确地提到重定位阶段,但上面的步骤中并没有体现。在ZGC中并没有明确这一步,重定位实际上被合并到标记阶段中,即在标记的时候如果发现对象引用到老的地址,这时会先完成重定位更新对象的引用关系,然后再标记对象。所以实质上ZGC的并发垃圾回收中还是包含了重定位这一阶段,只不过重定位和标记阶段复用了。

关于漏标问题可以看这篇文章:https://www.cnblogs.com/hongdada/p/14578950.html

下面通过画图介绍初始标记、并发标记、初始转移、并发转移这四个步骤的流程:


上图表示的是一个页中的对象分布情况,其中有一个GC Roots根直接引用的对象,还有一个垃圾对象(左下角那个指针没有被任何变量持有的对象)。
接下来,我们做初始标记,标记GC Roots根直接引用的对象。被标记的对象指针将变为M0视图(绿色)


接下来进入并发标记阶段,顺着GC roots根的引用,标记所有可达的对象:


跳过重新标记和并发转移准备,进入初始转移阶段,初始转移阶段会转移初始标记阶段标记的对象,也就是那些GC roots直接引用的对象,并且会完成对象的重定位。


接下来进入到并发转移阶段,该阶段会转移并发标记阶段标记的对象,但是不会完成对象的重定向(转移后因为没有做对象的重定位,原来的绿色指针还在,及时它指向的空间已经被释放了。如果此时有线程访问了这些指针,就会触发我们前面说的惰性重定位)

在并发转移阶段后,页内存1中存活的对象都会被标记成绿色(M0视图),所以如果还有蓝色对象的话,那这些对象就都是垃圾对象。

在并发转移结束后,还有不少变量持有旧的指针,如果要修正这些指针的话,就需要重新遍历所有变量,找到那些持有旧指针的变量然后修正。但因为由惰性重定位的原因,因此这个过程并不急切,因此ZGC将这个阶段巧妙的放到了下一次GC时的并发标记阶段了(因为并发标记也会遍历所有变量,这样就只需要遍历一次)。这个阶段被称为并发重映射。

在下一次GC,做可达性分析时,存活对象指针标记为M1。而如果一个遇到绿色的指针,说明这是上一次GC阶段未重定位的指针,此时会完成对象的重定位。因此,M0和M1其实是交替使用的,目的就是为了区分相邻俩次GC的标志。

三、ZGC流程详解

ZGC 使用内存多重映射技术,把物理内存映射为 Marked0、Marked1 和 Remapped 三个地址视图,利用地址视图的切换,ZGC 实现了高效的并发收集。

ZGC 的垃圾收集过程包括标记、转移和重定位三个阶段。如下图:


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

3.1 初始标记

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

3.2 并发标记

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

\color{red}{标记阶段的活跃视图也可能是 Marked1,为什么会采用两个视图呢?}
这里采用两个视图是为了区分前一次标记和这一次标记。如果这次标记的视图是 Marked0,那下一次并发标记就会把视图切换到 Marked1。这样做可以配合 ZGC 按照页回收垃圾的做法。如下图:

第二次标记的时候,如果还是切换到 Marked0,那么 2 这个对象区分不出是活跃的还是上次标记过的。如果第二次标记切换到 Marked1,就可以区分出了。

这时 Marked0 这个视图的对象就是上次标记过程被标记过活跃,转移的时候没有被转移,但这次标记没有被标记为活跃的对象。Marked1 视图的对象是这次标记被标记为活跃的对象。Remapped 视图的对象是上次垃圾回收发生转移或者是被 Java 应用线程访问过,本次垃圾回收中被标记为不活跃的对象。

3.3 再标记

并发标记阶段 GC 线程和 Java 应用线程并发执行,标记过程中可能会有引用关系发生变化而导致的漏标记问题。再标记阶段重新标记并发标记阶段发生变化的对象,还会对非强引用(软应用,虚引用等)进行并行标记。

这个阶段需要 STW,但是需要标记的对象少,耗时很短。

3.4 初始转移

转移就是把活跃对象复制到新的内存,之前的内存空间可以被回收。

初始转移需要扫描 GC Roots 直接引用的对象并进行转移,这个过程需要 STW,STW 时间跟 GC Roots 成正比。

3.5 并发转移

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

3.6 重定位

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

四、垃圾收集算法

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


4.1 JDK 16 之前

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

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

4.2 JDK 16 改进

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


不过就地搬移也有一定的挑战。比如:必须考虑搬移对象的顺序,否则可能会覆盖尚未移动的对象。这就需要 GC 线程之间更好的进行协作,不利于并发收集,同时也会导致搬移对象的 Java 线程需要考虑什么可以做什么不可以做。

为了获得更好的 GC 表现,JDK 16 在支持就地搬移的同时,也支持预留(Reserve)堆内存的方式,并且 ZGC 不需要真的预留空闲的堆内存。默认情况下,只要有空闲的 region,ZGC 就会使用预留堆内存的方式,如果没有空闲的 region,否则 ZGC 就会启用就地搬移。如果有了空闲的 region, ZGC 又会切换到预留堆内存的搬移方式。

五、ZGC什么时候触发垃圾回收

ZGC触发GC的时机主要有七种:

六、ZGC的参数设置

ZGC 优势不仅在于其超低的 STW 停顿,也在于其参数的简单,绝大部分生产场景都可以自适应。当然,极端情况下,还是有可能需要对 ZGC 个别参数做个调整,大致可以分为三类:

• 堆大小:Xmx。当分配速率过高,超过回收速率,造成堆内存不够时,会触发 Allocation Stall,这类 Stall 会减缓当前的用户线程。因此,当我们在 GC 日志中看到 Allocation Stall,通常可以认为堆空间偏小或者 concurrent gc threads 数偏小。

• GC 触发时机:ZAllocationSpikeTolerance, ZCollectionInterval。ZAllocationSpikeTolerance 用来估算当前的堆内存分配速率,在当前剩余的堆内存下,ZAllocationSpikeTolerance 越大,估算的达到 OOM 的时间越快,ZGC 就会更早地进行触发 GC。ZCollectionInterval 用来指定 GC 发生的间隔,以秒为单位触发 GC。

• GC 线程:ParallelGCThreads, ConcGCThreads。ParallelGCThreads 是设置 STW 任务的 GC 线程数目,默认为 CPU 个数的 60%;ConcGCThreads 是并发阶段 GC 线程的数目,默认为 CPU 个数的 12.5%。增加 GC 线程数目,可以加快 GC 完成任务,减少各个阶段的时间,但也会增加 CPU 的抢占开销,可根据生产情况调整。

七、总结

内存多重映射和染色指针的引入,使 ZGC 的并发性能大幅度提升。

ZGC 只有 3 个需要 STW 的阶段,其中初始标记和初始转移只需要扫描所有 GC Roots,STW 时间 GC Roots 的数量成正比,不会耗费太多时间。再标记过程主要处理并发标记引用地址发生变化的对象,这些对象数量比较少,耗时非常短。可见整个 ZGC 的 STW 时间几乎只跟 GC Roots 数量有关系,不会随着堆大小和对象数量的变化而变化。

ZGC 也有一个缺点,就是浮动垃圾。因为 ZGC 没有分代概念,虽然 ZGC 的 STW 时间在 1ms 以内,但是 ZGC 的整个执行过程耗时还是挺长的。在这个过程中 Java 线程可能会创建大量的新对象,这些对象会成为浮动垃圾,只能等下次 GC 的时候进行回收。

参考:
https://www.likecs.com/show-204318492.html

https://blog.csdn.net/fedorafrog/article/details/113782570

https://blog.csdn.net/weixin_44335140/article/details/127219698

https://mp.weixin.qq.com/s?__biz=MzAwNTQ4MTQ4NQ==&mid=2453586896&idx=1&sn=cf74a9f6c4e2686093224574e952f352

https://zhuanlan.zhihu.com/p/474527679

https://blog.csdn.net/fedorafrog/article/details/113782570

https://www.likecs.com/show-204318492.html

https://wiki.openjdk.java.net/display/zgc

https://openjdk.java.net/jeps/304

https://openjdk.java.net/jeps/376

https://malloc.se/blog/zgc-jdk16

https://mp.weixin.qq.com/s/ag5u2EPObx7bZr7hkcrOTg

https://mp.weixin.qq.com/s/FIr6r2dcrm1pqZj5Bubbmw

https://www.jianshu.com/p/664e4da05b2c

https://www.cnblogs.com/jimoer/p/13170249.html

https://www.jianshu.com/p/12544c0ad5c1

上一篇 下一篇

猜你喜欢

热点阅读