深入理解ZGC回收器---关键技术详解
⭐⭐⭐⭐⭐⭐
Github主页👉https://github.com/A-BigTree
笔记链接👉https://github.com/A-BigTree/Code_Learning
⭐⭐⭐⭐⭐⭐
如果可以,麻烦各位看官顺手点个star~😊
文章目录
- 深入理解ZGC回收器---关键技术详解
-
- [1 关键技术1---堆内存布局](#1 关键技术1—堆内存布局)
-
- [1.1 G1的内存布局](#1.1 G1的内存布局)
- [1.2 ZGC的内存布局](#1.2 ZGC的内存布局)
- [2 关键技术2🌟---指针着色技术Colored Pointers](#2 关键技术2🌟—指针着色技术Colored Pointers)
-
- [2.1 具体实现](#2.1 具体实现)
- [2.2 GC中的应用](#2.2 GC中的应用)
- [3 关键技术3---读屏障](#3 关键技术3—读屏障)
- [4 总结](#4 总结)
关于JVM回收器的前置知识点:
- 【JVM】---Java内存区域详解
- 【JVM】---JVM垃圾回收详解
- 【JVM】---深入理解G1回收器------概念详解
- 【JVM】---深入理解G1回收器---回收过程详解
- 【JVM】---G1中的Young GC、Mixed GC、Full GC详解
- 【JVM】---G1 GC日志详解
- 【JVM】---深入理解ZGC回收器---背景概念&回收流程
前面介绍了ZGC的诞生背景和回收流程(传送门),这篇文章从内存布局、着色指针和读屏障三个角度出发,详细介绍一下ZGC用到的关键技术。
1 关键技术1---堆内存布局
1.1 G1的内存布局
让我先回顾一下G1的内存布局如下:
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
1.2 ZGC的内存布局
类似的,ZGC中没有了分代的概念(新生代、老年代),内存布局如下图所示:
ZGC支持3种页面(ZPages),分别为小页面、中页面和大页面。其中小页面指的是2MB的页面空间,中页面指32MB的页面空间,大页面指受操作系统控制的大页。
- 当对象大小小于等于256KB时,对象分配在小页面;
- 当对象大小在256KB和4M之间,对象分配在中页面;
- 当对象大于4M,对象分配在大页面;
ZGC对于不同页面回收的策略也不同。 简单地说,小页面优先回收;中页面和大页面则尽量不回收。 同时ZGC 的物理堆区域可以映射到更大的堆地址空间(可以包括虚拟内存),这对于解决内存碎片问题至关重要
想象一下,用户想要在堆内存中分配一个非常大的对象,但由于内存中没有连续的空间,这通常会需要多个 GC 周期来释放足够的连续空间。而且如果GC后还没有可用空间,,JVM 就是抛出
OutOfMemoryError
。但是由于物理内存映射到更大的地址空间,找到更大的连续空间对于ZGC是可行的。
2 关键技术2🌟---指针着色技术Colored Pointers
在 ZGC 中,Colored Pointers 主要用于解决并发垃圾回收过程中的指针更新问题。ZGC 使用了一种称为 "指针压缩" 的技术,其中指针的一部分位被用来存储额外的信息,以便于垃圾回收器进行高效的并发操作。
以下是颜色指针的数据结构:
在堆中指针指引结构如下:
具体来说,ZGC 使用了以下几种状态来标记指针:
- 未标记(Unmarked): 指针没有任何特殊标记,表示该对象还没有被垃圾回收器处理。
- 标记(Marked): 指针被标记为已处理,表示该对象已经被垃圾回收器发现并处理过。
- 重定位(Relocated): 指针被标记为已重定位,表示该对象已经被移动到新的内存位置,并且指针指向了新的位置。
这些状态通常通过指针的最低几位来实现。例如,ZGC 可能会使用指针的最低两位来存储这些状态信息。
2.1 具体实现
- 未标记(Unmarked):指针的最低两位为 00。表示该对象还没有被垃圾回收器处理。
- 标记(Marked):指针的最低两位为 01。表示该对象已经被垃圾回收器发现并处理过。
- 重定位(Relocated):指针的最低两位为 10。表示该对象已经被移动到新的内存位置,并且指针指向了新的位置。
并发垃圾回收过程
- 标记阶段:
- 垃圾回收器遍历对象图,将发现的对象标记为 Marked。
- 如果对象需要被移动,垃圾回收器会将其标记为 Relocated,并将指针更新为新位置。
- 更新指针:
- 在并发阶段,应用程序线程继续运行,可能会修改对象图。
- 垃圾回收器需要确保在更新指针时不会丢失对已移动对象的引用。
- 清除阶段:
- 垃圾回收器清理未被标记的对象,释放内存。
- 通过这种方式,ZGC 能够在并发垃圾回收过程中高效地管理和追踪对象引用,减少停顿时间并提高整体性能。
2.2 GC中的应用
首先是初始阶段,使用蓝色来表示Remapped
接下来我们以M0区域表示第一次GC的指针染色,根据我们熟知的GC的根可达算法,将指针标记为绿色,表示此次我们GC存活的对象指针是用绿色表示
接下来就开始执行转移,可以看到使用了复制算法,将对象移动到了新的页,然后开始了初始阶段我们的 GCROOT也会指向新的页 ,这时候还没有进行指针标记,那么会通过一个转发表的数据结构,在新的页中,来指向我们旧页的对象,这样的话就会成功引用到我们即将保留的对象。
接下来就清空旧页的对象将被回收的对象
此时已经到达 第二轮GC标记 ,那么此次M0为红色,那么就会判断上次GC的绿色指针进行并发标记,此次标记为红色表示次轮GC的存活对象指针标识,最后清空旧页的指针以及转发表等数据
由此可以看出,ZGC是经历了两轮GC才会真正的将垃圾清除,通过颜色指针的M0与M1交替标记,来通过根可达算法标识存活对象,在整个过程可以看到,STW的时间点在初始标记,再标记,以及初始转移,这些动作仅仅与GCROOT的头节点的对象有关,所以标记以及转移动作特别快,然后大批量的标记和转移都是并发的,所以整体STW时间停顿特别少,而且回收的过程又是复制算法,所以非常高效。具体回收过程如下图所示:
3 关键技术3---读屏障
之前的GC都是采用写屏障(Write Barrier),而ZGC采用的是读屏障。读屏障(Load Barriers)类似于 Spring AOP 的前置通知。
在ZGC中,当读取处于重分配集的对象时,会被读屏障拦截,通过转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为叫做指针的「自愈能力 」。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW,类似JDK里的 CAS 自旋,读取的值发现已经失效了,需要重新读取。
好处是:第一次访问旧对象访问会变慢,但也只会有一次变慢,当「自愈」完成后,后续访问就不会变慢了。
正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。不过这点开销是值得的。
读屏障示例:
Java
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用
4 总结
相比G1、Shenandoah等先进的垃圾收集器,ZGC在实现细节上做了一些不同的权衡选择。
譬如G1需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。
ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。
可是,有优就有劣,ZGC的这种选择也限制了它能承受的对象分配速率不会太高。
因为ZGC四个阶段都支持并发,如果分配速率高,将创造大量的新对象,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。
目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建。所以分代算法有利有弊。