第一部分:ZGC 的核心黑科技(底层原理)
要看懂流程,必须先看懂这三个概念的内部实现。
1. 染色指针 (Colored Pointers) ------ 指针不仅仅是地址
在传统的 Java 堆中,一个引用(Reference)就是一个纯粹的内存地址。
但在 ZGC 中,引用被改造了。ZGC 利用了 64 位指针中未使用的"高位"来存储垃圾回收的状态。
ZGC 指针结构(64位):
+-------------------+-+----+-----------------------------------------+
| Unused (18) | | | |
| |R|Mark| Object Address (42) |
+-------------------+-+----+-----------------------------------------+
^ ^
| |
Remapped --+ +-- Marked1 / Marked0
-
0-41位 (42 bits): 对象的实际物理地址(支持 4TB,后来扩展到 16TB)。
-
42-45位 (4 bits):颜色标志位(重点!)
-
Marked0 / Marked1: 标记位。用于标记对象是否是"活"的。为什么有两个?为了区分是"上一轮 GC 的标记"还是"这一轮 GC 的标记"。
-
Remapped: 重映射位。表示这个对象是否已经(或不需要)搬家,指针指向的是正确的现址。
-
Finalizable: 表示对象是否需要执行 finalize 方法(现在很少用了)。
-
核心逻辑:
ZGC 不去对象头里看状态,而是看你在持有这个对象的"引用"时,手里拿的这个指针是什么颜色。
2. 多重映射 (Multi-Mapping) ------ 骗过 CPU
你可能会问:"指针里改了几个 bit,那这个地址不就变了吗?CPU 还能找到内存里的对象吗?"
ZGC 利用了操作系统的虚拟内存映射技术。它在虚拟内存层面,把同一个物理内存地址,映射到了三个不同的虚拟地址上(分别对应 Marked0、Marked1、Remapped)。
-
也就是说,无论指针是
Marked0 + 地址A,还是Remapped + 地址A,CPU 最终访问的都是同一个物理内存 A。 -
作用: 这样 Java 程序就能正常访问对象,而 GC 线程可以通过修改指针的高位来标记状态,互不干扰。
3. 读屏障 (Load Barrier) ------ 守门员
这是 ZGC 能并发工作的绝对核心。写屏障(Write Barrier)是在"赋值"时触发,而读屏障是在"读取"时触发。
每当你的代码执行类似 Object p = obj.field;(读取堆里的引用)时,JVM 注入的读屏障逻辑就会执行:
-
检查颜色(Test): 拿
p的指针颜色和当前 GC 阶段的"正确颜色(Good Color)"对比。 -
快速路径(Fast Path): 如果颜色对上了,直接返回,几乎没有性能损耗。
-
慢速路径(Slow Path): 如果颜色不对(Bad Color),说明这个对象可能被移动了,或者还没被标记。
-
自愈(Self-Healing): 读屏障会根据情况,去查"转发表",找到对象的新地址,然后立刻更新 你手里的引用
p。 -
返回: 更新完后,返回正确的对象给业务线程。
-
第二部分:ZGC 的超详细全流程解析
假设我们现在开始一次完整的 GC 循环。
阶段 0:准备工作
-
全堆的指针颜色通常处于
Remapped状态。 -
GC 选定本轮使用的标记位,假设是 Marked0。
阶段 1:初始标记 (Pause Mark Start) ------ 【STW,极短】
-
目标: 标记 GC Roots(线程栈变量、静态变量等)直接引用的对象。
-
操作:
-
暂停所有线程。
-
扫描 Roots。
-
关键动作: 把 Roots 指向的对象的指针,从
Remapped改为Marked0。 -
切换全局视图: 告诉所有读屏障,"从现在开始,Marked0 才是好颜色"。
-
-
耗时: < 1ms。
阶段 2:并发标记 (Concurrent Mark) ------ 【并发,耗时最长】
-
目标: 遍历对象图,找出所有存活对象。
-
操作:
-
GC 线程从 Roots 开始,顺藤摸瓜。
-
凡是摸到的对象,就把指向它的指针改为
Marked0。 -
读屏障的作用: 此时应用线程也在跑。如果应用线程加载了一个还没被标记的对象(颜色是旧的),读屏障会捕获它,帮它染成
Marked0,并加入标记队列。这保证了用户线程拿到的永远是"活"的对象。
-
阶段 3:再标记 (Pause Mark End) ------ 【STW,极短】
-
目标: 处理并发阶段遗留的少量边缘情况(比如线程本地缓冲区里的引用)。
-
操作: 彻底完成标记。此时,全堆所有存活对象的指针理论上都应该是
Marked0。
阶段 4:并发准备重分配 (Concurrent Prepare for Relocate) ------ 【并发】
-
目标: 选出哪些页面(ZPage)需要回收。
-
操作:
-
分析各个页面的垃圾比例。
-
选出一组垃圾最多的页面,放入重分配集 (Relocation Set)。
-
注意:此时对象还没动。
-
阶段 5:初始重分配 (Pause Relocate Start) ------ 【STW,极短】
-
目标: 仅仅处理 Roots 指向且位于"重分配集"里的对象。
-
操作:
-
切换全局视图: 告诉所有读屏障,"从现在开始,Remapped 才是好颜色"。
-
扫描 Roots。如果某个 Root 指向的对象在"重分配集"里,GC 会把这个对象复制到新的空闲页面,并更新 Root 指针为新地址(带
Remapped颜色)。 -
如果 Root 指向的对象不在 重分配集里,仅仅把指针颜色改为
Remapped。
-
阶段 6:并发重分配 (Concurrent Relocate) ------ 【并发,核心难点】
-
目标: 把"重分配集"里剩余的存活对象搬走,并利用读屏障实现"自愈"。
-
操作:
-
GC 线程遍历"重分配集"里的所有对象。
-
搬家: 发现存活对象,就把它复制到新页面。
-
记录: 在旧页面的内存里建立一个转发表 (Forwarding Table) ,记录
旧地址 -> 新地址。 -
销毁: 搬完后,旧对象其实还在,只是成了垃圾。转发表保留,直到所有指向旧地址的指针都修正。
-
【高能场景演示:并发重分配时的"自愈"】
-
对象
A在重分配集里,GC 线程把它搬到了新地址A',并写了转发表A -> A'。 -
但是,堆里还有一个对象
B,它的字段B.field仍然指向旧地址A(因为还没来得及修)。 -
用户线程 执行代码
Object x = B.field;。 -
触发读屏障:
-
检测到
x的指针颜色是Marked0(坏颜色,因为阶段 5 已经把好颜色改成 Remapped 了)。 -
进入慢速路径。
-
-
查表: 读屏障去查转发表,发现
A已经搬到了A'。 -
修正(自愈):
-
读屏障把
B.field的引用更新为A'(带 Remapped 颜色)。 -
返回
A'给用户线程。
-
-
后续: 下次再访问
B.field,颜色是对的,直接走快速路径。
第三部分:总结 ZGC 与 G1 的本质区别
为了方便记忆,请看这个对比:
| 特性 | G1 (Garbage First) | ZGC (Z Garbage Collector) |
|---|---|---|
| 内存管理 | Region:物理不连续,大小固定。 | ZPage:物理不连续,大小动态(小/中/大)。 |
| 存活判断 | BitMap:用额外的位图记录存活状态。 | Colored Pointers:状态直接写在指针里。 |
| 并发保障 | SATB + 写屏障:在写入时记录旧引用,防止漏标。 | 读屏障:在读取时检查指针颜色,实现自愈。 |
| 对象移动 | STW 期间移动:必须暂停所有线程才能搬对象。 | 并发移动:线程一边跑,对象一边搬(靠转发表和读屏障)。 |
| 性能损耗 | 写屏障开销较小,但 STW 时间随堆增大而增加。 | 读屏障开销较大(吞吐量低 10%),但 STW 几乎恒定且极低。 |
一句话总结 ZGC 的流程:
利用染色指针 标记状态,利用多重映射 欺骗 CPU,利用读屏障 在对象移动后实现引用的懒加载式修复(自愈),从而将停顿时间从"搬家时间"缩减为"改个路牌的时间"。