前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

一、面向未来的低延迟垃圾收集器

Z Garbage CollectorZGC 是一款为 TB 级堆内存亚毫秒级停顿 而生的现代垃圾回收器。

ZGC 的核心价值:超低延迟 + 超大堆支持 + 几乎不停顿

想象你开了一家快递公司:

  • 以前用的是“老式分拣员”(比如 CMS、G1),每次整理包裹(GC)都要暂停营业几分钟(Stop-The-World),客户投诉多。
  • 后来 G1 改进了一些,能边营业边整理,但堆内存一大(比如 50GB+),还是偶尔卡住几十毫秒。
  • ZGC 就像请了一支“隐形机器人团队”:它们在后台悄无声息地整理包裹,前台照常收发快递,用户完全感觉不到停顿

1、ZGC 的设计目标

ZGC 是一种几乎不停顿的垃圾回收器,目标是让 Java 应用在 TB 级堆内存下,停顿时间始终 ≤ 10 毫秒,且对吞吐量影响极小。

| 目标 | 说明 | | —————- | ———————————————————— | | 停顿 ≤ 1ms | GC 停顿时间严格控制在 10 毫秒以内**,且与堆大小、存活对象数量、根集合大小无关。 | | **支持 TB 级堆** | 能够高效地管理从几百 MB 到数 TB 的堆内存。 | | **高吞吐** | 在低延迟前提下,尽量减少 CPU 开销 | | **全并发** | 几乎所有 GC` 阶段(包括标记、转移、重定位)都与应用线程并发执行。 |

2、 ZGCJDK 版本

1)关键时间节点

  • JDK 11:ZGC 首次作为实验特性引入(Linux only)
  • JDK 14:扩展到 Windows/macOS
  • JDK 15:正式可用于生产环境
  • JDK 17(LTS):稳定、低延迟(<1ms STW),广泛推荐用于生产
  • JDK 21(LTS)重大升级,支持分代回收,性能更优
JDK 版本 默认 GC 推荐低延迟 GC 是否支持 ZGC 是否支持 Shenandoah
JDK 8 Parallel CMS(不推荐)
JDK 11 G1 ZGC(实验) ✅(需 unlock) ✅(OpenJDK)
JDK 17 G1 ZGC / Shenandoah ✅(OpenJDK)
JDK 21 G1 ZGC(分代) ✅(OpenJDK)

2)推荐使用版本

  • JDK 17:成熟稳定,社区支持广泛
  • JDK 21:若需更高吞吐 + 低延迟,且可接受新特性,建议启用 分代 ZGC(需显式开启)

3、ZGC 的三大核心技术

技术 作用 大白话解释
染色指针 把对象状态存在指针里 指针不只是地址,还带“颜色标签”(比如:正在移动、已移动),不用额外查表
读屏障 自动修正指针 当程序读一个对象时,ZGC 自动检查指针是否过期,如果是,立刻更新到新地址
并发处理一切 几乎所有阶段都并发 标记、转移、重定位……全都在应用线程运行时完成,只在极短时间暂停

二、彩色指针-—— 把对象状态存在指针里

1、为什么需要彩色指针

1)问题背景

Java 对象在堆里,JVM 要知道:

  • 这个对象还活着吗?(标记)
  • 它被 GC 搬过家吗?(重定位)

传统做法(比如 G1):

  • 额外建一张“户口本”(Card Table / Remembered Set
  • 每次查对象状态,先翻户口本 → 慢 + 占内存

ZGC 说:我不建户口本!我把状态直接写在“身份证号”(指针)上!

2)什么是“彩色指针”?

  • 传统 GC:对象状态(是否存活、是否移动过)存在额外表里(如 Card Table),查一次要多跳一步。
  • ZGC:直接在 64 位指针的高位塞进 4 个“颜色标签”

  • 为什么能这么做?想象你有一个快递单号(这就是对象的地址/指针):

    • 64 位地址空间理论是 16EB,但实际 CPU 只用低 48 位(256TB)。
    • 高位全是“空闲”的!ZGC 就把这“废地”变成了“元数据仓库”。

    • 想象你有一个快递单号(这就是对象的地址/指针):
    • 在 64 位系统中,这个单号有 64 位(二进制),但真正用到的只有低 48 位
    • 那高 16 位呢?→ 全是 0,浪费了!
    • ZGC 说:我拿其中 4 位来“染色”!

2、4 个 颜色标签

  • 64JVM 中,无论是 G1 还是 ZGC,一个对象引用(reference)在 Java 堆中都只占 8 字节(64 位)。
  • ZGC 利用了这 64 位中的高位来存储元数据(M0/M1/R/F
名称 含义 GC 阶段
42 Marked0 (M0) 第一轮标记时,它是活的! 并发标记阶段
43 Marked1 (M1) 第二轮标记时,它是活的! 并发标记阶段
44 Remapped (R) 对象已被重定位,指针已更新 并发重定位/压缩
45 Finalizable (F) 对象需执行 finalize() 方法 Finalization 处理

举个例子:一个指针原本是 0x00007f8b12345678

  • ZGC 把第 44 位设为 1(表示已搬家),变成: 0x00027f8b12345678
  • 注意前面多了 2,这就是“染色”!
class Node {
    Object ref;
    @Override
    protected void finalize() throws Throwable {
        System.out.println("Finalizing " + this);
    }
}

public class ZGCTest {
    public static void main(String[] args) {
        Node a = new Node();      // 对象 A
        Node b = new Node();      // 对象 B
        a.ref = b;                // A 引用 B
        b = null;                 // 断开局部变量对 B 的引用
        a = null;                 // 断开对 A 的引用 → A 和 B 都可能被回收
        System.gc();              // 触发 GC(ZGC)
    }
}

1)Marked0 (M0) — 第42位-本轮标记存活

  • 作用:表示该对象在第一轮并发标记阶段是否被标记为“存活”。
  • 关键:M1 和 M0 交替使用,避免 STW 清理旧标记。无需遍历全堆清零 M0,只需切换“当前位”。
  • 细节
    • M1 配合使用,实现交替标记(flip marking)
    • 每次 GC 周期只使用其中一个(M0 或 M1)作为“当前标记位”,另一个保留上一轮的信息,便于快速清理或判断跨周期引用。
    • 例如:如果 M0 = 1,说明该对象在当前使用的标记位(比如本轮使用 M0)中标记为活的。

2)Marked1 (M1) — 第43位-上一轮或下一轮的存活标记

  • 作用:表示该对象在第二轮并发标记阶段是否被标记为“存活”。
  • 关键:M1 和 M0 交替使用,避免 STW 清理旧标记。无需遍历全堆清零 M0,只需切换“当前位”。
  • 细节
    • M0 配合使用,实现交替标记(flip marking)
    • 每次 GC 周期只使用其中一个(M0 或 M1)作为“当前标记位”,另一个保留上一轮的信息,便于快速清理或判断跨周期引用。
    • 例如:如果本轮使用 M1 标记,则 M0 中的值代表上一轮的结果。
  • 为什么需要两个标记位?
  • 因为 ZGC 是并发的,必须支持在标记过程中继续分配新对象,并能区分“本轮新发现的活对象”和“上一轮残留的活对象”。
  • 双标记位避免了 Stop-The-World 清理旧标记。

3)Remapped (R) — 第44位

  • 作用:表示该对象已经被重定位(relocated),其指针已经更新指向新的地址。
  • 细节
    • ZGC 在并发压缩(Compaction)阶段会将对象从旧地址复制到新地址。
    • 所有指向该对象的指针都需要被“修正”(remap)到新地址。
    • R=1 表示这个对象头或指针已经完成了重映射,后续访问无需再查转发表(forwarding table)。
    • 这是 ZGC 实现低延迟的关键:通过指针着色,线程可以在访问对象时懒惰地(on-the-fly)完成重映射

4)Finalizable (F) — 第45位

  • 作用:表示该对象已进入 finalization 阶段,即它重写了 finalize() 方法且尚未被 finalize。
  • 细节
    • JVM 需要确保具有 finalize() 方法的对象在真正回收前先执行该方法。
    • F=1 表示该对象需要被特殊处理:即使没有其他引用,也不能立即回收,必须先加入 finalization 队列。
    • ZGC 利用这个位来快速识别哪些对象需要额外处理,避免遍历所有对象。

4、FAQ

1)彩色指针带来的核心优势是是什么?

唯一代价:每次读指针都有轻微 CPU 开销(读屏障)

a、零额外内存开销:

是彩色指针最直接、最独特的价值

  • GC 状态(M0/M1/R/F)编码在指针高位,
  • 无需 Remembered Set / Card Table

b、引用自动修复->间接依赖

没有彩色指针,引用自动修复无法高效实现

  • 读屏障需要知道“这个指针是否指向已移动的对象
  • 彩色指针的颜色(R)告诉读屏障:是否需要查转发表
  • 没有颜色,读屏障无法判断指针状态,就无法懒更新。

c、 支持完全并发:彩色指针让并发状态管理变得无锁、轻量:

  • 并发转移时,多个线程可能同时访问同一个旧地址。
  • 彩色指针 + 原子 CAS 操作 可确保指针状态转换是线程安全的。

2)M0/M1 交替机制

a、并发 GC 中,如何安全地“清空”上一轮的标记?

  • 传统 GC在新一轮开始前,需要 STW 遍历整个堆,把所有对象的“存活标记”清零
  • ZGC 不想停顿!
    • 如果当前轮用 M0,那么 M1 就是上一轮的存活标记;
    • 如果当前轮用 M1,那么 M0 就是上一轮的存活标记。

b、ZGC 解法:不清理!用两个标记位“翻转”!

  • 上一轮的标记(比如 M0=1)不用清零,直接保留。
  • 这一轮用 M1 标记新存活对象。
  • 回收时:
    • 如果 M1 == 0M0 == 1 → 说明上轮活、这轮死 → 可回收
    • 如果 M1 == 1 → 这轮活 → 保留
    • 如果 M0 == 0M1 == 0 → 可能是新分配的对象(还没被标记),需特殊处理(通常视为活的,或由分配器保证)

2)Remapped (R) 在 ZGC 中工作

场景设定:ZGC 在后台启动了一次 并发 GC 周期,并进入 重定位(Relocation)阶段。

class Data {
    int value;
}

public class RemapExample {
    static Data shared = new Data(); // 对象 A,地址假设为 0x1000

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                shared.value++; // 频繁访问 shared
            }
        });

        t1.start();

        // 主线程触发 GC
        System.gc(); // 假设此时 ZGC 启动并发回收

        t1.join();
    }
}

a、第一步:ZGC 决定移动对象

  • 对象 shared 当前位于堆地址 0x1000
  • ZGC 为了压缩内存碎片,决定把它移到新地址 0x8000
  • 它执行以下操作:
    1. 0x8000 处复制整个 Data 对象;
    2. 原地址 0x1000 的对象头中写入一个 转发指针(forwarding pointer),值为 0x8000
    3. 但注意:此时所有引用(比如 RemapExample.shared 这个静态字段)仍然是 0x1000
  • 此时对象指针的元数据(colored pointer)中:
    • R = 0(尚未 remap)

b、第二步:应用线程继续运行(并发!)

  • 线程 t1 正在循环执行 shared.value++

  • 下一次它加载 shared 时,拿到的还是 0x1000

  • 但在 ZGCload barrier(读屏障) 机制下,JVM自动拦截这次读取,检查指针的 R 位。

  • 检测到 R=0 → 触发懒重映射(on-the-fly remapping):

    1. 读取地址 0x1000 的对象头,发现里面不是正常对象,而是一个 forwarding pointer = 0x8000

    2. 获取真实地址 0x8000

    3. 原子地更新 shared 字段的值:从 0x1000 改为 0x8000

    4. 设置新指针的 R 位为 1(即新指针的第44位打上标记);

    5. 继续执行 value++,操作的是 0x8000 处的对象。

c、第三步:后续访问直接命中

  • 下一次 t1 再执行 shared.value++
    • 读到的指针已经是 0x8000
    • 指针的 R=1
    • JVM 看到 R=1,知道“这已经是最新地址”,跳过读屏障逻辑,直接访问;
    • 性能无损!

d、第 2 次 GC 发生(比如几秒后)

  • ZGC 再次进行并发压缩

  • 决定把 shared0x8000 移到 0x9000

  • ZGC 执行:

    • 0x9000 复制对象;
    1. 0x8000 的对象头写入 forwarding pointer0x9000

    2. 注意:此时 shared 字段仍然是 0x8000(R=1)

e、应用线程再次访问

线程执行 shared.value++

  • 读取 shared 字段 → 得到 0x8000
  • 检查指针:R=1,所以 JVM 认为“这是有效地址”,直接去访问 0x8000

但!当它尝试加载对象头时,发现:

  • 0x8000 处不再是正常对象,而是一个 forwarding pointer → 0x9000

这时 ZGC 的 load barrier 会介入(即使 R=1,也要验证对象是否真的在那):

  1. 发现 0x8000 是 forwarding pointer;
  2. 获取新地址 0x9000
  3. 原子地将 shared 字段更新为 0x9000
  4. 设置新指针的 R=1(覆盖原来的 R=1,但地址变了);
  5. 继续访问 value。

5)为什么不能去掉 R位,每次都查 forwarding pointer

  • forwarding pointer 需要访问内存(0x8000 处)
    • R=0 → 肯定没 remap 过 → 必须查
    • R=1 → 很可能有效 → 先信一次,但要验一下
  • 现代 CPU 访问内存很慢(~100ns),而寄存器/缓存很快(~1ns)
  • 如果每次访问对象都要多一次内存读(哪怕只是看是不是 forwarding),性能会显著下降

有了 R 位:

  • 大多数情况下(对象没再被移动),只需要检查指针自身的元数据(R 位在指针高位,无需访存)
  • 只有极少数情况(对象再次被移动),才多一次内存读

6)对象为什么会搬家— 为了内存压缩

a、根本原因:

  • 目标:消除堆内存碎片,保证大对象分配效率,支持超大堆(TB 级)且停顿时间极短(<1ms)
    • 把存活对象从碎片化的旧区域复制到连续的新区域
    • 释放出一大块干净的空闲内存,供后续分配使用。

b、搬家条件

条件 说明
GC 进入 Relocation 阶段 必要前提
对象所在区域被选入 Relocation Set 决定是否搬
对象是 存活的(被标记) 只搬活对象
堆使用率高 / 大对象分配失败 / 显式 GC 触发 GC 的原因

条件·1 :GC 周期进入 “Relocation 阶段”

ZGC 的一次完整 GC 周期包含几个并发阶段:

  1. 并发标记(Concurrent Mark):找出所有存活对象(打 M0/M1 标记)
  2. 并发准备重定位(Concurrent Prepare Relocation):选择要压缩的内存区域(称为 relocation set
  3. 并发重定位(Concurrent Relocation)真正执行对象搬家!

👉 只有进入第 3 步时,才会实际移动对象。

条件 2:对象所在的内存区域被选入 “Relocation Set

ZGC 不会搬动所有存活对象,而是只搬动部分区域中的存活对象,这些区域称为 Relocation Set

  • 增量压缩(Incremental Compaction:每次 GC 只压缩一部分,避免一次性搬太多导致延迟升高

  • 哪些区域会被选中?ZGC 优先选择:

    • 高垃圾密度的区域(比如 80% 是垃圾,只搬 20% 的活对象,性价比高)

    • 长期未被使用的区域

    • 需要腾出连续空间以满足大对象分配请求的区域

条件 3:对象是“存活”的(Marked)

  • 在标记阶段被标记为活的对象(M0=1 或 M1=1)→ 会被搬
  • 未被标记的对象(垃圾)→ 直接丢弃,不搬

6)finalize 是如何工作的

关键点:F=1 是“别急着杀我,我还有遗言(finalize)要说!”finalize

注意:只有第一次发现不可达且 F=1 时才进队列;之后 F 会被清零或特殊处理,避免重复 finalize。

  • 对象 B 重写了 finalize()JVM 在分配它时就将其 F 位设为 1
  • GC 标记阶段,即使 B 不可达(没有引用),ZGC 发现 F=1,于是:
    • 不会立即回收 B
    • 而是将 B 加入 finalization 队列
    • 同时启动一个低优先级线程调用 B.finalize()
  • finalize() 执行期间,B 又可能被“复活”(比如在 finalize 中把自己赋给一个静态变量)。
  • 如果没复活,下一轮 GC 才真正回收 B。

三、 读屏障-—— 谁用谁改,懒更新引用

这是 ZGC 实现并发性的关键技术,与G1 的写屏障(Write Barrier)形成鲜明对比。

GC 类型 屏障类型 触发时机 用途
G1/CMS 写屏障 修改引用时 记录跨代引用、SATB 快照
ZGC 读屏障 读取引用时 自动修正已搬家的对象地址

1、 工作流程-每次读引用都“安检”

ZGC 在 每次从堆中加载一个对象引用时,都会插入一小段代码(由 JIT 编译器自动加),这就是 读屏障

当你写代码:

Object obj = user.getName(); // 这里会触发读屏障!

JVM 自动插入一段“检查代码”:

  1. obj 指针的 Remapped是不是 0
  2. 如果是 → 说明对象已经被 GC 搬家了
  3. 立刻去查新地址,把指针更新成新地址,再返回给你。
  4. 这个过程对应用程序是完全透明的。

2、读屏障带来的核心优势是什么

1)无需 STW 扫全堆更新引用

1)传统 GC(如 G1)在 Evacuation Pause 中必须 STW 扫描 Survivor/Eden 区所有引用并修正

  • → 停顿随堆增大而增长。
  • 转移时同步更新: 利用 RSet 找到 incoming 引用 , 在 Young/Mixed GCSTW立即更新

2)ZGC 完全跳过这一步STW 仅做根扫描(线程栈、寄存器等),通常 <0.5ms

2)工作分散到应用线程,无集中瓶颈

修正工作由多个应用线程并发完成,而非单一线程在 STW 中处理。

  • 更好利用多核,避免“GC 线程成为瓶颈”。

3) 冷对象零开销

如果一个对象被移动了,但从未被再次访问(比如是临时对象),

  • 永远不会触发读屏障,省下转发表查询 + 指针更新的 CPU

4) 与彩色指针完美配合

彩色指针告诉读屏障:“这个指针是否需要修正?”

  • M0/M1:需要;R:已修正。
  • 判断成本极低(位运算),无额外内存查找。

四、内存多重映射-一物三用的魔法

ZGC 把同一块物理内存,在操作系统眼里“假装”成三份不同的地址,就像给同一个房间装了三个门牌号。程序通过不同“门牌”看到的是不同阶段的对象状态(标记中、重定位后等),但其实底层用的还是同一块物理内存——既省空间,又切换飞快!

1、原理:虚拟内存 + 多重映射

1)虚拟内存基础

现代操作系统使用 虚拟内存:程序看到的地址(虚拟地址) ≠ 实际物理内存地址。操作系统通过 页表(Page Table 做映射:

 虚拟地址 → 物理地址

2)多重映射

  • ZGC 将整个 Java 堆划分为小的、固定大小的页面Page),而不是 G1Region
  • 每个物理页面在虚拟地址空间中被映射到三个不同的地址范围,三份虚拟地址,一份物理内存::
虚拟地址 用途 指针颜色
0x40_1000 M0 视图 标记阶段使用
0x80_1000 M1 视图 下一轮标记用
0x00_1000 R(Remapped)视图 重定位后使用

想象你要寄信给住在“幸福小区 101 室”的人:

  • 物理地址 0x1000 = 幸福小区 101 室(真实住址)

2、GC 的三套“视图”是干嘛的?

ZGC 的垃圾回收分为几个阶段,其中两个关键阶段需要“看到”对象的不同状态:

阶段 用途 对应视图
并发标记 找出哪些对象还活着 Marked0Marked1 空间中标记
并发重定位Relocation 把存活对象挪到新位置(压缩堆) Remapped 空间访问新对象

1)工作流程

a、初始状态:对象刚分配

  • 物理地址:0x1000
  • 应用持有的指针:0x40_1000M0 颜色)
  • 此时 GC 还没开始

b、阶段1:并发标记(GC 线程跑,应用也跑)

  • GC 通过 0x40_1000 访问对象,标记为存活
  • 应用也通过 0x40_1000 读写对象

c、阶段2:标记结束,进入重定位

ZGC 决定:对象 A 不用搬(in-place,但以后要用 R 视图访问。

于是:

  • M0/M1 视图被“冻结”(不再用于新访问)
  • R 视图 0x00_1000 成为唯一合法入口

但!应用线程里可能还存着一堆 0x40_1000 指针(比如在变量、栈、寄存器里),ZGC 不会主动去改它们(太慢、要 STW)。

d、阶段3:应用再次读取 A(触发读屏障)

Object a = someRef; // someRef 还是 0x40_1000
System.out.println(a.field); // ← 这里插入读屏障!

读屏障执行:

  1. 看到指针是 0x40_1000 → 高位是 0x40 → 是 M0 颜色 → 过期了!
  2. 查页状态:发现该页已 “remapped”(重映射完成),且是 in-place
  3. 计算新指针:把颜色去掉 → 0x00_1000
  4. someRef 变量的值从 0x40_1000 改成 0x00_1000
  5. 继续访问 a.field,这次用 0x00_1000,一切正常

2)为啥这么干?

问题 ZGC 的解法
如何知道指针是否过期? 彩色指针(高位标记)
如何避免 STW 更新所有引用? 懒更新:谁读到旧指针,谁负责修(读屏障)
如何支持 in-place 重定位? 多重虚拟映射:同一物理页有多个“入口”
如何处理真搬迁? 转发表(forwarding pointer 记录新地址
如何保证性能? 所有操作在用户态完成,无系统调用、无锁、无拷贝

3、FAQ

1)为什么不能直接用物理地址?非要搞虚拟映射

因为:

  • Java 指针必须是 虚拟地址(用户态程序不能直接操作物理地址)
  • 如果不用多重映射,就无法实现 “in-place 重定位时零拷贝切换”
    • 否则每次重定位都要改页表,成本高
    • 或者必须等所有指针更新完才能继续,导致 STW

ZGC 的方案:

  • 页表只在 GC 初始化时设置一次M0/M1/R 三份映射)
  • 后续所有“切换”都通过 改指针值 完成,页表不动!

2)内存多重映射 核心优势

a、标记和重定位共享物理内存,节省空间

  • 没有复制数据:标记时读的是 M0 地址,重定位后读的是 R 地址,但底层都是同一份对象数据。
  • 无需双倍堆内存:不像某些 GC(如 Copying GC)需要 From/To 两个半区,ZGC 始终只用一份物理内存。
  • in-place 重定位成为可能90%+ 的对象根本不用搬,直接“换门牌”即可。

b、切换‘视图’只需改指针,速度极快

  • 指针修改是 纯用户态操作(几条 CPU 指令)
  • 无锁、无系统调用、无内存拷贝
  • 平均延迟增加 <10 纳秒(对应用几乎无感)

五、ZGC 垃圾回收过程

ZGCGC 周期主要由三个并发阶段组成,仅在开始和结束时有非常短暂的 STW。

阶段 是否 STW 时长 关键动作
Initial Mark <1ms 暂停,从 Roots 标记第一层对象,翻转 M0/M1
Concurrent Mark 秒级 并发遍历对象图,读屏障防漏标
Final Mark <1ms 处理并发期间变更,统计存活率
Relocate Start 微秒级 选垃圾密集页(Relocation Set)
Concurrent Relocate 秒级 并发复制活对象到新页
Remapping 无独立阶段 溶解到读屏障 + 下轮 Initial Mark

1、标记(Marking)阶段—— 找出谁还活着

目标:从根出发,找出所有可达对象(翻转 M0/M1),其他都是垃圾。

1)初始标记(Initial Mark) - STW ( < 1ms)

原理:

  • 暂停所有应用线程(必须暂停,否则根会变)(通常 < 1ms)。
  • GC Roots(线程栈、静态变量、JNI 引用等)出发,直接标记第一层对象。
  • 翻转标记位:如果上一轮用的是 M0,这轮就用 M1(避免混淆)

案例:

static Node head = new Node(); // GC Root
head.next = new Node();        // 第二层
  • 初始标记只标记 headRoot 直接引用)
  • head.next 留给并发阶段处理

2)**并发标记(Concurrent Mark) **— 并发(无停顿!)

关键:M0/M1 交替使用,保证本轮标记不会污染上一轮结果。

原理:

  • 应用线程恢复运行
  • GC 线程在后台递归遍历对象图,标记所有从 Root 可达的对象
  • 使用 读屏障 捕获应用线程修改的引用(防止漏标)
    • 例如:应用线程执行 a.ref = b,读屏障会确保 b 被加入标记队列

案例:假设并发期间,应用线程执行:

Node newNode = new Node();
head.next.next = newNode; // 新增一个对象
  • 读屏障检测到 head.next.next 被写入
  • 自动将 newNode 加入待标记队列
  • GC 线程稍后会标记它 → 不会漏标!

3)**最终标记(Final Mark) **— STW(<1ms

原理:

  • 再次短暂暂停
  • 处理并发期间漏掉的引用变更(类似 G1Remark
  • 统计每个页面的存活对象数量,决定哪些页要回收(垃圾多的优先)

2、转移阶段(Relocation)—— 搬家不打烊

目标:将存活对象从垃圾密集的页面转移到新的页面,实现内存整理,消除碎片。

1)转移准备(Relocate Start)— STW(极短)

原理:

  • 暂停应用线程(微秒级)
  • 根据标记阶段的统计,选出要回收的页面(比如存活率 < 20%
  • 初始化转移所需的元数据。

2)并发转移(Concurrent Relocate)— 并发(核心魔法!)

原理:

  • GC 线程开始复制存活对象到新的“Remapped”页面,应用线程可以同时运行
  • 但!不立即更新所有引用(传统 GC 这里要 STW 扫全堆)
  • 而是靠 读屏障 + 彩色指针 实现 “谁用谁搬,谁用谁改”
    • 重点:对象转移是懒加载的!只有当某个线程第一次访问一个已转移对象时,才触发复制和指针更新。

3、重定位(Remapping)—— 没有“阶段”的阶段!

目标:确保所有指向已转移对象的引用都被更新。

  • 这是 ZGC 最革命性的一点:重定位不是一个独立阶段,而是被“溶解”到读屏障和下一轮初始标记中!

原理分解:

  • 二者互补,100% 保证所有活对象的引用最终都会被修正
场景 如何重定位
普通对象引用 读屏障 在访问时按需更新(懒更新)
GC Roots 中的引用(如静态变量、线程栈) 下一轮初始标记STW 中批量更新

为什么可以这样?

  • 因为 ZGC 保证:在下一轮 GC 开始前,所有活对象的引用最终都会被更新

    • Node a = new Node();
      a.ref = X;  // X 已被转移,但 a.ref 从未被读取
      
    • 但如果 a 是活对象(被其他路径可达),未来某天只要有人读 a.ref,读屏障就会触发更新

    • 如果 a 本身是垃圾(不可达),那么 a.ref 这个引用根本不重要,随 a 一起被回收
  • 即使某个对象一直没被访问,它的引用也会在下一轮 Initial Mark 时被修正

    • 扫描根(如 ref 是根变量)
      • 发现 ref 指向一个 M0 色地址此时会触发读屏障!(因为根扫描本质也是“读取引用”)
      • 读屏障查转发表 → 得到新地址 → 将 ref 更新为 R

六、ZGC 的强大

1、ZGCG1 的核心差异

| 维度 | ZGC | G1 GC | | —————————- | ———————————————————— | ———————————————————— | | 设计目标 | 超低延迟(<1ms STW),支持 TB 级堆 | 可预测停顿(通常 <10~50ms,兼顾吞吐与延迟 | | 最大堆支持 | 数 TB(实测 16TB+) | 推荐 ≤ 几百 GB(过大时停顿显著增加) | | STW 停顿来源 | 仅 Initial Mark + Final Mark(各 <1ms) | Young GCSTW)、Mixed GCSTW)、RemarkSTW)、Full GC(长停顿) | | 对象转移 | 完全并发,应用线程运行时搬 | STW 中进行Young/Mixed GC 期间暂停应用) | | 引用更新 | 无独立阶段: 堆内引用 → 读屏障懒更新 , GC Roots → 下轮 Initial Mark 批量修正 | 转移时同步更新: 利用 RSet 找到 incoming 引用 , 在 Young/Mixed GCSTW立即更新 | | 是否需要扫全堆更新引用? | 完全不需要 | 不需要(靠 RSet 局部更新),但 Full GC 时会 | | 标记阶段 | 并发标记 + M0/M1 双色指针 + 读屏障防漏标 | 并发标记(SATB)+ Remark STW 处理变更 | | 内存布局 | 基于 虚拟内存多重映射Marked0/Marked1/Remapped) | 基于 固定 Region + ``RSet | | **指针表示** | **彩色指针**(高位存元数据) | 普通指针 + 外部 RSet / Card Table 记录引用关系 | | **屏障机制** | **读屏障(Load Barrier)为主** | **写屏障(Write Barrier)为主**(维护 RSet) | | **碎片整理** | **始终压缩**(每次 GC 都整理) | **部分压缩**(只 compact 被回收的 Region`) | | 适用场景 | 超低延迟要求 | 通用场景,平衡吞吐与延迟(Web 服务、中等堆应用) |

1)停顿是否与堆大小相关

  • ZGC无关。无论 1GB 还是 16TBSTW 都 <1ms
  • G1相关。堆越大,RSet 越大,Young/Mixed GCRemark 停顿越长。

2)对象转移是否并发?

  • ZGC:转移和应用线程完全并发,“边跑边搬”。
  • G1:转移必须 STW,虽然只搬部分 Region,但仍需暂停。

3)引用更新成本在哪?

  • ZGC:成本分散到应用线程(读屏障),无集中开销。
  • G1:成本集中在 Young/Mixed GCSTW 中,且依赖 RSet 精度(RSet 脏 → 扫更多)

4)CPU 开销

垃圾回收器 额外开销占比 CPU 开销来源
**G1 ** 5%–10% 主要在 Mixed GC 阶段有 STW,但并发阶段轻量;写屏障(触发频率低)
ZGC 15%–25% 读屏障 在每次对象访问时触发,带来持续 CPU 开销
ZGC(分代) 18%–25% 新增写屏障处理分代元数据,但也做了大量针对性优化。

5)内存开销

GC 类型 额外开销占比 主要开销来源
G1 GC 8% ~ 15% Remembered Sets (RSet)Card Table、并发标记缓冲区
ZGC 15% ~ 25% 转发表、并发操作缓冲区、浮动垃圾预留
分代 ZGC 6% ~ 12% 转发表(仅移动对象)、Young 区缓冲、无 RSet

a、G1 GC

  • Remembered Sets (RSet)
    • 每个 Region 维护一个 RSet,记录其他 Region 对它的引用。
    • 占堆 5%~10%,引用越复杂(如大量跨 Region 对象图),开销越大。
  • Card Table
    • 用于 Young GC 扫描老年代脏卡,额外 ~100~300MB(固定开销)。
  • 并发标记位图
    • 标记阶段需位图,约 N / 64 字节(16GB 堆 ≈ 256MB)。
  • Survivor/Eden 管理
    • 固定比例(默认 Eden 占 60%,Survivor 动态调整)。

b、ZGC(不分代)

  • 转发表(Forwarding Table
    • 并发压缩时每个移动对象需记录新地址,约 1%~3% 堆大小
  • 浮动垃圾预留
    • 在并发标记/转移期间,新分配的对象不能覆盖正在被回收的区域。
    • 必须保留空闲页供并发分配,通常预留 10%~20% 堆
  • 并发线程工作区
    • 标记、重定位、引用处理等阶段需临时结构,固定开销 ~200~500MB
  • 虚拟内存多重映射
    • 不增加物理内存,但消耗虚拟地址空间(对 64 位系统无影响)。

c、分代 ZGC

  • 转发表仅用于存活对象
    • Young GC 中大部分对象死亡,无需转发表;
    • Old 区增量回收,转发表更少。
  • 浮动垃圾预留大幅减少
    • Young 区小(通常 <20% 堆)
      • 并发期间产生的浮动垃圾 几乎全部集中在 Young 区(因为新对象都在 Eden)
      • 因此只需为 Young 预留缓冲。
    • Old 区回收是增量的,无需全堆预留
      • Old 区对象 存活率高、分配少,大部分时间很“安静”
      • 每次只选择 一部分 Old Region 进行并发标记和回收(类似 G1 的 Mixed GC)
  • 并发工作量降低
    • 多数回收是轻量 Young GC(STW <0.5ms),Mixed GC 频率低。

6)大对象处理

ZGC 时代,请彻底抛弃 “大对象要拆分” 的 G1 思维!

  • 大对象 = 更少的对象头 = 更少的 GC 元数据 = 更高的缓存局部性 = 更好的性能
  • ZGC 是目前JVM 中对大对象最友好的垃圾回收器,没有之一。

a、G1 ZGC 比较

维度 G1 ZGC
大对象处理 差(Humongous 问题) 极佳(无复制、无碎片)
是否推荐拆分
拆分后影响 减少 Full GC 风险 增加对象数量 + GC 开销
内存效率 拆分后更优 原生大对象更优
适用场景 堆 < 32GB,对象 < 1MB 堆任意大小,对象任意大小

b、ZGC 为什么 不推荐拆分?反而 鼓励用大对象

特性 对大对象的影响
无分代、无 Region 概念 不区分 Small/Humongous
指针着色 + Multi-Mapping 大对象 无需复制,只更新 forwarding pointer
并发重定位 移动大对象的成本 ≈ 移动小对象
无内存碎片 回收后整块释放,地址空间虚拟化
  • 分配 100MB 的 byte[]耗时 < 1ms

  • 回收 100MB 对象:STW,CPU 开销极低

    • public class ClickLogBatch {
          private final int[] userIds;      // 4 bytes * 1M = 4MB
          private final long[] itemIds;     // 8 bytes * 1M = 8MB
          private final long[] timestamps;  // 8 bytes * 1M = 8MB
      }
      
  • 拆成 100 个 1MB 小对象:

    • public class ClickEvent {
          private int userId;
          private long itemId;
          private long timestamp;
      }
          
      // 使用
      List<ClickEvent> events = new ArrayList<>(1_000_000);
      for (...) {
          events.add(new ClickEvent(...));
      }
      
    • 增加对象头开销(每个对象 12~16 字节)

    • 增加 GC 标记工作量(100 个对象 vs 1 个)

    • 增加内存碎片风险(虽然 ZGC 无物理碎片,但逻辑上更复杂)

c、真实业务

我当前的机器配置是 8 核 16G。系统中存在一个常驻老年代的大对象,长期占用约 3GB 内存。同时,有一个全量刷新任务会在夜间执行:该任务会逐步构建一套新的 3GB 大对象,在此过程中,老年代内存会持续增长(因为新旧对象都处于存活状态,无法被回收)。只有当全量刷新完成并完成引用切换后,旧对象才会变为可回收状态。 补充说明:

  • 刷新任务通常在夜间低峰期执行,此时单机 QPS 约为 500;
  • 高峰期无定时任务,但流量较高,单机 QPS 可达 8000/s 耗时 5ms。
export maxParameterCount="1000"
export acceptCount="200"
export maxThreads="200"
export minSpareThreads="50"
export maxSpareThreads="100"
export URIEncoding="UTF-8"
export JAVA_OPTS="-Xms12g -Xmx12g \
-XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=512m \
-XX:MaxDirectMemorySize=1024m \
-XX:+UseZGC -XX:+ZGenerational \
-XX:ConcGCThreads=3 \
-XX:ZAllocationSpikeTolerance=6 \
-XX:+DisableExplicitGC \
-XX:+ZProactive -XX:ZCollectionInterval=5 \
-XX:+AlwaysPreTouch \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/export/Logs \
-Xlog:gc*:file=/export/Logs/gc.log:time,tags:filecount=5,filesize=10M \
-Djava.library.path=/usr/local/lib \
-server \
-Djava.awt.headless=true \
-Dsun.net.client.defaultConnectTimeout=60000 \
-Dsun.net.client.defaultReadTimeout=60000 \
-Djmagick.systemclassloader=no \
--add-opens java.base/sun.security.action=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.math=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.nio=ALL-UNNAMED \
--add-opens java.base/java.time=ALL-UNNAMED \
--add-opens java.base/sun.util.calendar=ALL-UNNAMED \
--add-opens java.base/java.util.concurrent=ALL-UNNAMED \
--add-opens java.base/java.util.concurrent.locks=ALL-UNNAMED \
--add-opens java.base/java.util.concurrent.atomic=ALL-UNNAMED \
--add-opens java.base/java.security=ALL-UNNAMED \
--add-opens java.base/jdk.internal.loader=ALL-UNNAMED \
--add-opens java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED \
--add-opens java.base/java.net=ALL-UNNAMED \
--add-opens java.base/sun.nio.ch=ALL-UNNAMED \
--add-opens java.management/java.lang.management=ALL-UNNAMED \
--add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED \
--add-opens java.management/sun.management=ALL-UNNAMED \
--add-opens java.base/sun.net.util=ALL-UNNAMED \
--add-opens java.base/jdk.internal.misc=ALL-UNNAMED"

7)ZGC 回收 CPU 有规律变动比较明显

这些 CPU 尖峰是 ZGC 并发执行的标志,说明你的 GC 正在高效工作。只要应用延迟稳定,这种波动就是 可接受的代价

a、现象:

  • 每隔约 1 分钟出现一次 25%~27% 的 CPU 占用尖峰
  • 持续时间约 10~15 秒
  • 其他时间 CPU < 3%

关键判断:

  • 不是“全核满载” → 不会阻塞业务线程
  • 不是持续高负载 → 不会导致系统过热或 OOM
  • 有规律 → 是 ZGC 定期触发的并发阶段

b、什么时候才需要关注

ZGC 的 CPU 周期性波动是其“用 CPU 换低延迟”的体现,只要 P99 延迟达标,这种波动就是正常的、可接受的。

场景 是否需要干预? 建议
CPU 尖峰 ≤ 30% 不需要 放心运行
CPU 尖峰 > 50% 观察 降低 ConcGCThreadsZProactive
CPU 尖峰持续时间 > 30s 观察 检查堆大小、分配速率
业务响应延迟变高 必须处理 优先调优业务代码

c、那如何降低 CPU 波动

  1. 降低 ConcGCThreads

  2. 调整 ZAllocationSpikeTolerance

  3. 关闭 ZProactive(仅测试)

2、ZGC 的革命性在哪

传统 GC ZGC
“先停机,再打扫” “边营业,边打扫,顾客无感”
堆越大,停顿越长 堆再大,停顿不变
需要大量辅助数据结构 元数据藏在指针里
搬家后要暂停更新所有引用 谁访问谁更新(读屏障)

3、ZGC 为何能“零停顿

技术 解决的问题 效果
彩色指针 指针自带状态 无需查表,快速判断
读屏障 按需更新引用 + 防漏标 消除 STW 引用更新
多重映射 三视图共享物理内存 省空间 + 快切换
懒转移 谁用谁搬 分摊 GC 成本
M0/M1 交替 安全并发标记 避免标记污染

4、 ZGC 分代变更

1)为什么早期 ZGC 不分代

答案:“如果我能把全堆 GC 的停顿压到 <1ms,那还分什么代?直接全堆并发回收更简单、更可预测!”

JDK 版本 分代 ZGC 状态 启用参数(核心区别) 生产环境是否可用
JDK 17 实验特性 解锁实验参数:-XX:+UnlockExperimentalVMOptions -XX:+UseGenerationalZGC 不推荐
JDK 19 实验特性 同上 不推荐
JDK 21 正式特性 直接启用:-XX:+ZGenerational(无需解锁) 推荐(生产级)
JDK 22+ 正式特性 同 JDK 21,新增少量调优参数(如 ZNurseryCollectionInterval  
特性 不分代 ZGC(默认) 分代 ZGC(需开启)
堆结构 单一代(Unified Heap Young + Old 两代
GC 类型 只有一种并发 GC Young GC + Mixed GC
STW 停顿 <1ms <1ms
CPU 开销 较高(全堆扫描) 较低(年轻代局部回收)
吞吐量 中等 更高(尤其短命对象多时)
适用场景 超大堆、延迟极度敏感 中小堆、高吞吐、Web 服务等

2) 为什么 JDK 21 要加回分代

答案:尽管不分代 ZGC 很强大,但在某些场景下仍有优化空间:

场景 不分代 ZGC 的痛点
大量短命对象(如 Web 请求) 每次 GC 都要扫描全堆,浪费 CPU
吞吐量敏感型应用 并发 GC 线程持续运行,CPU 开销比 G1 高 10%~20%
小堆应用(<8GB 全堆并发反而“杀鸡用牛刀”

4)分代 ZGC 架构

  • 默认 不启用
  • 需显式加 -XX:+ZGenerational

a、 分代 ZGC 架构图

┌───────────────────────────────────────────────────────┐
│                  Java 堆(ZGC Heap)                   │
│                                                       │
│  ┌───────────────┐      ┌─────────────────────────┐   │
│  │  Young Space  │      │       Old Space         │   │
│  │  (Eden +      │      │                         │   │
│  │   Survivor)   │      │                         │   │
│  └───────┬───────┘      └────────────┬────────────┘   │
│          │                           │                │
│          │ 分配新对象                 │ 长期存活对象     │
│          ▼                           ▼                │
│  ┌───────────────┐      ┌─────────────────────────┐   │
│  │ Eden Region   │      │ Old Region (多个)       │   │
│  │ (大量小对象)  │      │ - 高存活率              │   │
│  └───────────────┘      │ - 大对象直接分配于此    │   │
│                         └─────────────────────────┘   │
└───────────────────────────────────────────────────────┘
          ▲                                   ▲
          │                                   │
          │                                   │
   ┌──────┴───────┐                 ┌────────┴─────────┐
   │ Young GC     │                 │ Mixed GC         │
   │ (仅扫描Young)│                 │ (并发扫描Old子集)│
   │ STW < 0.5ms  │                 │ STW < 1ms        │
   └──────┬───────┘                 └────────┬─────────┘
          │                                   │
          └───────────────┬───────────────────┘
                          ▼
             ┌───────────────────────────────┐
             │       全局并发基础设施         │
             ├───────────────────────────────┤
             │ • 彩色指针(M0/M1/R/F)        │
             │ • 读屏障(Load Barrier)       │
             │ • 虚拟内存多重映射             │
             │ • 转发表(Forwarding Table)   │
             └───────────────────────────────┘
                          ▲
                          │
             ┌────────────┴────────────┐
             │      GC Roots           │
             │ (线程栈、静态变量等)     │
             └─────────────────────────┘

b、 堆分区:两代结构

  • Young Space(年轻代)
    • 包含 EdenSurvivor 区域(逻辑划分,物理上仍是 ZPages)
    • 所有新对象首先分配在 Eden
    • 短命对象在此被快速回收
  • Old Space(老年代)
    • 存放从 Young 晋升的长期存活对象
    • 大对象(>RegionSize/2)直接分配到 Old(避免复制开销)

c、GC类型

GC 类型 触发条件 回收范围 STW 时间 并发阶段
Young GC Eden 满 仅 Young Space < 0.5ms 无(纯 STW,但极短)
Mixed GC Old 占用高 / 自适应触发 Young + 部分 Old < 1ms 标记 & 转移并发

6、 ZGC 回收的条件是什么呢

ZGC 的“自适应阈值”没有公开标准公式,也不依赖固定百分比,而是基于实时分配行为和回收历史动态决策。

传统 GC(如 CMS、G1)依赖硬编码阈值:

  • CMS:老年代 > 70% 触发并发标记(可通过 CMSInitiatingOccupancyFraction 调)
  • G1:IHOP(Initial Heap Occupancy Percent)默认 45%

但这些阈值在以下场景失效:

  • 分配速率突变(如秒杀)
  • 堆大小变化(容器弹性伸缩)
  • 对象生命周期分布不均

ZGC 为了解决这些问题,抛弃了“堆使用率百分比”作为主要触发依据,转而采用 基于分配速率和回收效率的预测模型

五、ZGC 关键参数与调优

启用 ZGC 非常简单:

-XX:+UseZGC

1、常用调优参数

参数 作用 默认值 建议
-XX:+UseZGC 启用 ZGC    
-Xmx 设置最大堆大小 由系统决定 由系统决定
-XX:SoftMaxHeapSize 软堆上限(非硬限制) 等于 -Xmx 设为物理内存的 70%~80%
-XX:ZCollectionInterval 强制 GC 间隔(秒) 0 (禁用) 一般不用设,除非有内存泄漏风险
-XX:ZUncommitDelay 释放内存回 OS 的延迟 300 (5分钟) 可调小以节省资源

1)-Xmx / -Xms(堆大小)

  • 原理
    • ZGC 强烈建议固定堆大小-Xms = -Xmx),避免动态扩容带来的延迟毛刺。
    • ZGC 使用 基于页的内存管理(默认 2MB 大页),堆越大,优势越明显。
  • 案例
    • 小堆(< 8GB):ZGC 优势不明显,G1 可能更节省 CPU 资源。
    • 大堆(≥ 16GB):ZGC 停顿时间稳定在 0.5~1ms;而 G1 在大堆下可能产生 10~100ms 的停顿
  • 建议:生产环境务必设置-Xms = -Xmx,例如-Xms32g -Xmx32g
  • 案例
    • 小堆(<8GB)ZGC 优势不明显,G1可能更节省CPU资源;
    • 大堆(≥16GB)ZGC 停顿时间稳定在0.5~1ms,而G1在大堆下可能产生10~100ms的停顿

2)-XX:ConcGCThreads

  • 原理

    • ZGC 的标记、重定位都是并发线程执行
    • 线程太少 → GC 跑得慢,可能跟不上分配速度 → 触发 Full GC(灾难!)
    • 线程太多 → 抢占应用 CPU,吞吐下降
  • 默认值:≈ max(1, CPU_COUNT / 8)

  • 案例

    • 32 核机器,默认 ConcGCThreads=4
    • 如果应用分配速率高(如每秒几 GB),可调到 8~12
  • 建议:

    • 该参数影响系统吞吐,如果 GC 间隔时间大于 GC 周期,不建议调整该参数。可能导致占用 CPU
    • 监控 jstat -gc <pid>JFR,看 “Allocation Stall”(分配阻塞)
  • 案例8C16GCPU秘籍型高并发,建议配置6

    • ZGC 的并发线程 只在 GC 周期内活跃(比如每几秒一次,每次几百毫秒)
    • 应用线程和 GC 线程 共享 CPU 时间片OS 会调度,一般不用担心 6 会过高
    • 如果 GC 线程太少(比如=2),GC 跑不完 → 触发 Allocation Stall应用线程被强制暂停(这才是真卡顿!)
  • GC 对比

      G1 (-XX:ConcGCThreads) ZGC (-XX:ConcGCThreads)
    作用阶段 仅用于 并发标记(Concurrent Mark) 用于 并发标记 + 并发重定位(Relocation)
    工作量 相对轻(只标记) 极重(要搬对象、更新指针、处理读屏障)
    默认值 ≈ CPU/4 ≈ CPU/8(但往往偏小)
    建议: 一般无需调整 建议适当调高

3)-XX:ParallelGCThreads

  • 说明:
    • ZGC 没有传统的“Stop-The-World 并行 GC 阶段”,其工作几乎全部并发完成;
    • 因此 ZGC 主流程完全不使用 ParallelGCThreads
    • 仅在极少数辅助操作(如 VM 初始化、某些元数据扫描)中可能间接引用,不影响 GC 停顿或吞吐
  • 默认值:
    • 根据 CPU 核心数自动计算(公式:ceil(CPU核心数 * 0.125));
    • 16CPU → 默认 2 个 ZGC 核心线程;
    • 64CPU → 默认 8 个 ZGC 核心线程
  • 建议:不需要配置
    • ParallelGCThreadsZGC 中“无事可做
      • JVM 在启用 ZGC 时,直接忽略 ParallelGCThreads 参数
      • 因为 STW 阶段很快,根本不需要多线程并行加速,通常 单线程就足够

3)-XX:+UseLargePages(启用大页)

  • 原理
    • ZGC 默认使用 2MB 大页(Huge Pages 管理堆
    • 启用 OS 大页(Linux 需配置 hugepages)可减少 TLB 缺失,提升访存性能
      • TLBTranslation Lookaside Buffer)是 CPU 里的一个“虚拟地址 → 物理地址”缓存。
  • 默认值:false

  • 建议:

    • 核心原则:只有在你能完全控制 OS 资源、且追求极致性能时,才值得开
  • 案例

    • 未启用:TLB 缺失率高→CPU访存变慢;
    • 启用后:吞吐提升 `5%~15%,尤其对内存密集型应用(如大数据、缓存)
    场景 UseLargePages 设置 说明
    默认 / 开发环境 不设置(保持默认 false 避免因 OS 未配大页导致启动失败
    生产物理机(高性能要求) 显式开启 -XX:+UseLargePages 并提前配置静态大页
    容器 / K8s 不要开 容器通常无权限使用大页,会启动失败
    不确定 OS 支持情况 先不开,观察性能 可通过 perf/proc/meminfo 评估是否需要

4)-XX:+ZUncommit(内存归还)

  • 原理
    • 开启后,ZGC 会在堆使用率较低时,将未使用的物理内存页归还给操作系统,降低进程 RSS
      • 重新分配物理页的过程是一次 minor page fault,耗时通常在 微秒级(< 10μs)
    • 若关闭,则堆一旦分配,即使空闲也不会释放回 OS。
  • ZGC 内存归还的触发条件
    • 堆空闲率足够高ZGC 内部保留策略:至少保留 ~1GB10% 的堆内存(取较大值),避免过度归还。
    • 空闲时间达标:需满足 -XX:ZUncommitDelay(默认 300 秒 / 5 分钟),即内存空闲超过该时间才会归还,
    • 内存页未被标记为 “活跃”:ZGC 基于 2MB 大页管理内存
      • 仅归还 “完全空闲” 的大页(页内无任何存活对象);
      • 若大页内有少量存活对象,即使整体堆空闲率高,也不会归还该页
  • 默认值true
  • 建议:
    • 适合容器化环境(如 Kubernetes),避免资源浪费;
    • 配合-XX:ZUncommitDelay(默认300秒=5分钟)设置更长延迟(如600秒=10分钟)避免短时低谷误释放
  • 案例
    • 应用峰值堆使用 64GB,日常仅用10GB→开启后RSS从64GB降至约12GB,避免资源浪费

5) -XX:+UseNUMANUMA 感知)

  • 原理
    • 在多 CPU 插槽(NUMA 架构)服务器上,内存访问有“本地/远程”之分
    • 堆初始化阶段:开启 -XX:+UseNUMA 后,JVM 不再随机分配堆内存,而是将堆的各个区域(如 ZGC 的页组、G1Region)平均拆分并绑定到所有可用 NUMA 节点
    • 运行时分配JVM 会优先让运行在某 NUMA 节点 CPU 上的线程,从该节点绑定的堆区域分配内存,最大化 “本地内存访问” 比例,减少跨节点访问延迟。
  • 默认值false
  • 案例
    • 2 路 Intel 服务器(2 个 NUMA node),开启后吞吐提升 10%+
  • 建议:只要机器是 NUMA架构(lscpu 查看),就加上。

6)-XX:SoftMaxHeapSize(软上限,Java 17+)

  • 原理
    • 用于在 硬上限(-Xmx)与容器内存限制之间建立安全缓冲
    • SoftMaxHeapSize=32g 是“软目标”:ZGC尽量在 32GB 内完成回收
    • 超过 32GB 时,GC 会更激进地回收,避免逼近 64GB 导致 Full GC
  • 默认值:等于-Xmx
    • 堆大小固定为 4GB-Xms == -Xmx),没有动态伸缩空间。
    • 在这种情况下,SoftMaxHeapSize 几乎没有作用,因为堆已经固定了。
  • 案例:容器内存 limit=40GB,设 Xmx=36g, SoftMax=30g → 留出安全缓冲
  • 建议:
    • SoftMaxHeapSize ≈ Xmx × 0.8 ~ 0.85
    • 避免 Xmx 接近容器 limit,否则 OOM 风险极高。

7)-XX:+AlwaysPreTouch(启动时预触内存)

  • 原理
    • JVM 默认采用 惰性分配(lazy commit:堆内存只在首次访问时才真正分配物理页
    • 开启 AlwaysPreTouchJVM 启动时
      • 主动“摸一遍”整个堆内存(从 -Xms-Xmx 的全部空间)
      • 强制操作系统(OS)立即为这些虚拟地址分配真实的物理内存页。
    • 避免运行时因首次访问大块内存而产生 不可预测的延迟毛刺
  • 默认值:false
  • 案例
    • 未启用:应用启动快,但高峰期首次分配大对象时触发 page fault → 延迟突增至几毫秒
    • 启用后:启动时间略长(多 1~3 秒),但运行期无内存分配毛刺,TP99 延迟更平稳
    • 尤其适合 低延迟服务(如交易、游戏、API 网关)大堆(≥16GB)场景
  • 建议
    • 物理机 / 独占 VM 必开(内存确定可用,牺牲启动速度换运行稳定)
    • 容器环境谨慎开启(需确保 memory limitXmx,否则可能 OOM
    • 通常与 -Xms = -Xmx 配合使用,效果最佳

8)-XX:+ZProactive(主动 GC 触发)

  • 原理
    • ZGC 默认是 被动触发:仅当分配速率高或堆使用达到阈值时才启动 GC
    • 提前回收垃圾,降低高峰期 GC 压力,避免突发 Allocation Stall
  • 默认值true
  • 案例
    • 未启用:流量突增时 GC 跟不上分配速度 → 出现 Allocation Stall(应用线程暂停)
    • 启用后:低峰期自动“打扫内存”,高峰期 GC 进度始终领先 → TPS 更稳,无卡顿
    • 周期性流量(如白天高峰/夜间低谷) 效果显著
  • 建议
    • ZGC 场景下保持默认开启即可(JDK 21 中默认为 true
    • 无需显式配置,但可加上以强调意图(尤其在脚本中)
    • 若追求极致 CPU 节省(如批处理任务),可关闭;在线服务强烈建议开启

9)-XX:ZAllocationSpikeTolerance

  • 核心作用:控制 ZGC内存分配速率突然增加(分配尖峰)的容忍度和响应策略

  • 工作原理

    • ZGC 需要预测应用的内存分配速率,提前启动垃圾回收
    • 当分配速率突然增加(如流量突增、批量任务开始),ZGC 需要额外的”安全边际”内存
    • 该参数是一个乘数因子,决定 ZGC 保留多少额外空闲内存应对尖峰
    • 公式:GC 触发阈值 = 当前分配速率 × ZAllocationSpikeTolerance × 预估 GC 耗时
  • 参数

    • 值太小(如2.0)→ GC 触发太晚 → Allocation Stall 风险高
    • 值太大(如8.0)→ GC 触发太早 → CPU 使用率升高,吞吐下降
    • 推荐值:对 8C16G 容器+严格延迟要求,5.0 是最优平衡点
    值范围 行为特点 适用场景
    1.0-2.0 极其激进,GC触发晚 稳定负载、资源极度受限环境
    3.0-4.0 默认范围(JDK21+默认3) 一般业务场景
    5.0-8.0 保守策略,GC 触发早 流量波动大、低延迟要求严格场景
    >8.0 过度保守,GC 过于频繁 通常不推荐
  • 案例说明

    • 场景:电商秒杀活动,正常分配速率 1GB/s,秒杀开始瞬间跳到 5GB/s

    • ZAllocationSpikeTolerance=3.0
      • ZGC只准备应对3GB/s的尖峰
      • 5GB/s > 3GB/s → 触发Allocation Stall → 应用线程暂停2-3ms
    • ZAllocationSpikeTolerance=5.0
      • ZGC准备应对 5GB/s 尖峰
      • 完美匹配实际需求 → 零Allocation Stall

10)-XX:+PerfDisableSharedMem

  • 原理

    • 核心作用禁用 JVM 的性能数据共享内存文件,消除相关 I/O 开销
  • 默认行为(未设置此参数时):

    • JVM/tmp/hsperfdata_<user>/<pid>创建共享内存文件
    • 文件包含:GC 次数、编译时间、线程状态等性能计数器
    • jps/jstat/jconsole 等工具通过读取此文件监控 JVM
    • 每毫秒级更新文件内容,产生频繁 I/O
  • 性能影响

    操作 默认行为开销 启用PerfDisableSharedMem后
    每次GC后更新性能计数器 10-50μs I/O延迟 零开销
    每次方法JIT编译 5-20μs I/O延迟 零开销
    高频监控工具轮询 可能阻塞应用线程 完全解耦
    容器/tmp目录压力 持续写入,消耗内存 无/tmp写入
  • 代价与权衡

    • 失去的能力
      • jps 无法看到此 JVM进程
      • jstat -gc <pid> 无法监控GC
      • jconsole/VisualVM 无法连接
    • 替代监控方案

      • GC日志分析:通过-Xlog:gc*获取详细GC数据

      • JFR (Java Flight Recorder):生产级低开销监控

        -XX:+FlightRecorder -XX:StartFlightRecording=disk=true,maxsize=1g
        

11)-XX:+DisableExplicitGC 详:ZGC 环境下的关键防护

  • 作用完全禁用 System.gc() 调用触发的 Full GC

  • 默认行为(未设置时):

    • 任何代码调用 System.gc() → JVM 执行 同步 Full GC
    • ZGC 而言,这会触发 完全 STW 的 ZFull GC,停顿时间 = 标记整个堆的时间
  • 建议:必须开启

    GC 类型 停顿时间 (10GB 堆) 对低延迟系统影响
    ZGC 100-500ms 灾难性
    G1 50-200ms 严重
    Parallel 300-1000ms 严重

12)-XX:MaxDirectMemorySiz

  • 原理:
    • ZGC 不管理堆外内存,且默认不触发 Full GC(早期版本甚至无法及时回收 Cleaner
    • 如果不设 MaxDirectMemorySize,直接内存可能持续增长直到系统崩溃
  • 默认行为:
    • 若不设置 MaxDirectMemorySizeJVM 默认将其设为与 -Xmx(最大堆内存)相同。
    • 应用可能无限制分配 DirectByteBuffer,导致:
      • 物理内存耗尽 → 触发 Linux OOM Killer 杀死进程;
      • 或抛出 java.lang.OutOfMemoryError: Direct buffer memory
  • 使用建议:
    • 不设上限的直接内存,就像没装刹车的跑车——快,但随时可能翻车。

13)-XX:ZCollectionInterval

  • 作用:ZGC 发生的最小时间间隔,单位秒
  • 生效条件:ZCollectionInterval 依赖 ZProactive 才能生效
  • 建议
    • 一般不建议配置,因为破坏 ZGC 的自适应优势
    • 适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到 95% 以上才触发 GC
    • 内存占用有周期性波动(如你的内存刷新任务)、需要「可控的 GC 触发时机」(避免突发 GC 导致 RT 毛刺)的低延迟场景。
  • 案例:
    • 仅靠 ZProactive(主动内存管理)+ 被动 GCZGC 会「按需触发」,在内存刷新任务的峰值期,可能集中触发 GC,导致短时间内 GC 频率升高,偶发影响 RT 稳定性

14)-XX:ZHighUsageThresholdPercent

  • 作用:
    • 设置堆使用率达到多少百分比时,ZGC 认为“内存压力高”
    • 从而更积极地触发 GC(包括提高并发线程数、缩短 GC 间隔等)
  • 默认值::85%(JDK15+ ZGC)

  • 建议:-XX:ZHighUsageThresholdPercent=60 ~ 70

    堆使用率 含义 风险
    ≤65% ZGC 认为“安全”,按正常节奏 GC 安全
    >65% ZGC 进入“高压模式”,更激进回收 提前释放空间,避免后续 Stall
    >85%(默认) 已接近极限,GC 可能来不及 高概率 Allocation Stall

2、GC 的“雷区”参数

参数 风险 建议
-XX:ZProactive 主动 GC 开关 Java 16+ 默认开,别关
手动设置 -XX:NewSize ZGC 无分代! 无效参数,忽略

3、调优建议

  • 堆大小
    • ZGC 对大堆非常友好,可以根据应用需求大胆设置(如 -Xmx64g)。
    • 若使用 分代 ZGC,中小堆(8–32GB)也能获得极佳性价比
  • 关注吞吐量
    • 关注吞吐 vs 延迟,CPU 开销较高,适合延迟极度敏感场景
    • 分代 ZGCCPU 和内存开销显著降低,吞吐接近 G1,同时保持 <1ms 停顿,推荐大多数新项目使用
  • 监控:使用 -Xlog:gc*JFR (Java Flight Recorder) 来监控 ZGC 的行为和性能。

3、典型应用场景

场景 推荐 GC CPU 敏感性说明
CPU 资源紧张 / 批处理 / 吞吐优先 G1 CPU 开销最低,适合 8–32GB 堆
超低延迟 + 大堆(>64GB) ZGC(不分代) CPU 开销高但可接受
超低延迟 + 大量短命对象(如 Web 请求) ZGC(分代) JDK 21+ 强烈推荐启用,虽然 CPU 略高,但整体响应更优
容器化 / 云原生(内存受限) 谨慎使用 ZGC 小堆(<8GB)下 ZGC 内存/CPU 开销性价比低

1)适合 ZGC 的业务(尤其推荐 分代 ZGC):

  • 任何希望摆脱“ Stop-The-World”困扰的应用

  • 实时系统:如金融交易、高频竞价、游戏服务器等,对延迟极度敏感。
  • 大数据/内存数据库:需要处理海量数据,堆内存通常在数十 GB 甚至 TB 级别。
  • 云原生/微服务:追求一致、可预测的响应时间,避免因 GC 导致的服务抖动。
  • 当应用需要提供0/1级别服务:TP99/TP999 低于 100ms,此类应用无论堆大小,均推荐采用低停顿的 ZGC

2)不适合 ZGC 的场景

  • 吞吐量优先的批处理任务(用 Parallel GC 更快)
  • JDK < 17ZGC 不成熟或不可用

ContactAuthor