JVM之_14_ZGC
前言
Github:https://github.com/HealerJean
一、面向未来的低延迟垃圾收集器
Z Garbage Collector(ZGC) 是一款为 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、 ZGC 各 JDK 版本
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 个 颜色标签
- 在
64位JVM中,无论是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 利用这个位来快速识别哪些对象需要额外处理,避免遍历所有对象。
- JVM 需要确保具有
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 == 0且M0 == 1→ 说明上轮活、这轮死 → 可回收 - 如果
M1 == 1→ 这轮活 → 保留 - 如果
M0 == 0且M1 == 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。- 它执行以下操作:
- 在 0x8000 处复制整个
Data对象; - 在 原地址 0x1000 的对象头中写入一个 转发指针(forwarding pointer),值为
0x8000; - 但注意:此时所有引用(比如
RemapExample.shared这个静态字段)仍然是 0x1000!
- 在 0x8000 处复制整个
- 此时对象指针的元数据(colored pointer)中:
- R = 0(尚未 remap)
b、第二步:应用线程继续运行(并发!)
-
线程
t1正在循环执行shared.value++。 -
下一次它加载
shared时,拿到的还是 0x1000。 -
但在
ZGC的load barrier(读屏障) 机制下,JVM会自动拦截这次读取,检查指针的 R 位。 -
检测到 R=0 → 触发懒重映射(on-the-fly remapping):
-
读取地址
0x1000的对象头,发现里面不是正常对象,而是一个forwarding pointer = 0x8000; -
获取真实地址 0x8000;
-
原子地更新
shared字段的值:从0x1000改为0x8000; -
设置新指针的
R位为 1(即新指针的第44位打上标记); -
继续执行
value++,操作的是 0x8000 处的对象。
-
c、第三步:后续访问直接命中
- 下一次
t1再执行shared.value++:- 读到的指针已经是 0x8000;
- 指针的 R=1;
JVM看到 R=1,知道“这已经是最新地址”,跳过读屏障逻辑,直接访问;- 性能无损!
d、第 2 次 GC 发生(比如几秒后)
-
ZGC 再次进行并发压缩
-
决定把
shared从 0x8000 移到 0x9000 -
ZGC 执行:
- 在 0x9000 复制对象;
-
在 0x8000 的对象头写入
forwarding pointer→ 0x9000; -
注意:此时
shared字段仍然是 0x8000(R=1)
e、应用线程再次访问
线程执行 shared.value++:
- 读取
shared字段 → 得到 0x8000 - 检查指针:R=1,所以
JVM认为“这是有效地址”,直接去访问 0x8000
但!当它尝试加载对象头时,发现:
- 0x8000 处不再是正常对象,而是一个 forwarding pointer → 0x9000
这时 ZGC 的 load barrier 会介入(即使 R=1,也要验证对象是否真的在那):
- 发现 0x8000 是 forwarding pointer;
- 获取新地址 0x9000;
- 原子地将
shared字段更新为 0x9000; - 设置新指针的 R=1(覆盖原来的 R=1,但地址变了);
- 继续访问 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 周期包含几个并发阶段:
- 并发标记(Concurrent Mark):找出所有存活对象(打 M0/M1 标记)
- 并发准备重定位(Concurrent Prepare Relocation):选择要压缩的内存区域(称为 relocation set)
- 并发重定位(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 自动插入一段“检查代码”:
- 看
obj指针的Remapped位是不是0? - 如果是 → 说明对象已经被
GC搬家了! - 立刻去查新地址,把指针更新成新地址,再返回给你。
- 这个过程对应用程序是完全透明的。
2、读屏障带来的核心优势是什么
1)无需 STW 扫全堆更新引用
1)传统 GC(如 G1)在 Evacuation Pause 中必须 STW 扫描 Survivor/Eden 区所有引用并修正
- → 停顿随堆增大而增长。
- 转移时同步更新: 利用
RSet找到incoming引用 , 在Young/Mixed GC的STW中立即更新
2)ZGC 完全跳过这一步,STW 仅做根扫描(线程栈、寄存器等),通常 <0.5ms。
2)工作分散到应用线程,无集中瓶颈
修正工作由多个应用线程并发完成,而非单一线程在 STW 中处理。
- 更好利用多核,避免“GC 线程成为瓶颈”。
3) 冷对象零开销
如果一个对象被移动了,但从未被再次访问(比如是临时对象),
- 永远不会触发读屏障,省下转发表查询 + 指针更新的
CPU。
4) 与彩色指针完美配合
彩色指针告诉读屏障:“这个指针是否需要修正?”
- →
M0/M1:需要;R:已修正。 - → 判断成本极低(位运算),无额外内存查找。
四、内存多重映射-一物三用的魔法
ZGC把同一块物理内存,在操作系统眼里“假装”成三份不同的地址,就像给同一个房间装了三个门牌号。程序通过不同“门牌”看到的是不同阶段的对象状态(标记中、重定位后等),但其实底层用的还是同一块物理内存——既省空间,又切换飞快!
1、原理:虚拟内存 + 多重映射
1)虚拟内存基础
现代操作系统使用 虚拟内存:程序看到的地址(虚拟地址) ≠ 实际物理内存地址。操作系统通过 页表(Page Table) 做映射:
虚拟地址 → 物理地址
2)多重映射
ZGC将整个Java堆划分为小的、固定大小的页面(Page),而不是G1的Region。- 每个物理页面在虚拟地址空间中被映射到三个不同的地址范围,三份虚拟地址,一份物理内存::
| 虚拟地址 | 用途 | 指针颜色 |
|---|---|---|
0x40_1000 |
M0 视图 | 标记阶段使用 |
0x80_1000 |
M1 视图 | 下一轮标记用 |
0x00_1000 |
R(Remapped)视图 | 重定位后使用 |
想象你要寄信给住在“幸福小区 101 室”的人:
- 物理地址
0x1000= 幸福小区 101 室(真实住址)
2、GC 的三套“视图”是干嘛的?
ZGC的垃圾回收分为几个阶段,其中两个关键阶段需要“看到”对象的不同状态:
| 阶段 | 用途 | 对应视图 |
|---|---|---|
| 并发标记 | 找出哪些对象还活着 | 在 Marked0 或 Marked1 空间中标记 |
并发重定位(Relocation) |
把存活对象挪到新位置(压缩堆) | 在 Remapped 空间访问新对象 |
1)工作流程
a、初始状态:对象刚分配
- 物理地址:
0x1000 - 应用持有的指针:
0x40_1000(M0颜色) - 此时
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); // ← 这里插入读屏障!
读屏障执行:
- 看到指针是
0x40_1000→ 高位是0x40→ 是M0颜色 → 过期了! - 查页状态:发现该页已 “
remapped”(重映射完成),且是in-place - 计算新指针:把颜色去掉 →
0x00_1000 - 把
someRef变量的值从0x40_1000改成0x00_1000 - 继续访问
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 垃圾回收过程
ZGC的GC周期主要由三个并发阶段组成,仅在开始和结束时有非常短暂的 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)。
- 从
GCRoots(线程栈、静态变量、JNI引用等)出发,直接标记第一层对象。 - 翻转标记位:如果上一轮用的是
M0,这轮就用M1(避免混淆)
案例:
static Node head = new Node(); // GC Root
head.next = new Node(); // 第二层
- 初始标记只标记
head(Root直接引用) 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)
原理:
- 再次短暂暂停
- 处理并发期间漏掉的引用变更(类似
G1的Remark) - 统计每个页面的存活对象数量,决定哪些页要回收(垃圾多的优先)
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、ZGC 与 G1 的核心差异
| 维度 | ZGC | G1 GC |
| —————————- | ———————————————————— | ———————————————————— |
| 设计目标 | 超低延迟(<1ms STW),支持 TB 级堆 | 可预测停顿(通常 <10~50ms),兼顾吞吐与延迟 |
| 最大堆支持 | 数 TB(实测 16TB+) | 推荐 ≤ 几百 GB(过大时停顿显著增加) |
| STW 停顿来源 | 仅 Initial Mark + Final Mark(各 <1ms) | Young GC(STW)、Mixed GC(STW)、Remark(STW)、Full GC(长停顿) |
| 对象转移 | 完全并发,应用线程运行时搬 | STW 中进行(Young/Mixed GC 期间暂停应用) |
| 引用更新 | 无独立阶段: 堆内引用 → 读屏障懒更新 , GC Roots → 下轮 Initial Mark 批量修正 | 转移时同步更新: 利用 RSet 找到 incoming 引用 , 在 Young/Mixed GC 的 STW 中立即更新 |
| 是否需要扫全堆更新引用? | 完全不需要 | 不需要(靠 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还是16TB,STW都 <1ms。G1:相关。堆越大,RSet越大,Young/Mixed GC和Remark停顿越长。
2)对象转移是否并发?
ZGC:转移和应用线程完全并发,“边跑边搬”。G1:转移必须STW,虽然只搬部分Region,但仍需暂停。
3)引用更新成本在哪?
ZGC:成本分散到应用线程(读屏障),无集中开销。G1:成本集中在Young/Mixed GC的STW中,且依赖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% | 观察 | 降低 ConcGCThreads 或 ZProactive |
| CPU 尖峰持续时间 > 30s | 观察 | 检查堆大小、分配速率 |
| 业务响应延迟变高 | 必须处理 | 优先调优业务代码 |
c、那如何降低 CPU 波动
-
降低
ConcGCThreads -
调整
ZAllocationSpikeTolerance -
关闭
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(年轻代)
- 包含 Eden 和 Survivor 区域(逻辑划分,物理上仍是 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 大页),堆越大,优势越明显。
- ZGC 强烈建议固定堆大小(
- 案例:
- 小堆(< 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的停顿
- 小堆(<8GB)
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”(分配阻塞)
- 该参数影响系统吞吐,如果
-
案例:
8C16G纯CPU秘籍型高并发,建议配置6ZGC的并发线程 只在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)); 16核CPU→ 默认 2 个ZGC核心线程;64核CPU→ 默认 8 个ZGC核心线程
- 根据
- 建议:不需要配置
ParallelGCThreads在ZGC中“无事可做JVM在启用ZGC时,直接忽略ParallelGCThreads参数- 因为
STW阶段很快,根本不需要多线程并行加速,通常 单线程就足够
3)-XX:+UseLargePages(启用大页)
- 原理:
ZGC默认使用2MB大页(Huge Pages) 管理堆- 启用
OS大页(Linux需配置hugepages)可减少TLB缺失,提升访存性能TLB(Translation 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内部保留策略:至少保留~1GB或10%的堆内存(取较大值),避免过度归还。 - 空闲时间达标:需满足
-XX:ZUncommitDelay(默认 300 秒 / 5 分钟),即内存空闲超过该时间才会归还, - 内存页未被标记为 “活跃”:ZGC 基于 2MB 大页管理内存
- 仅归还 “完全空闲” 的大页(页内无任何存活对象);
- 若大页内有少量存活对象,即使整体堆空闲率高,也不会归还该页
- 堆空闲率足够高:
- 默认值:
true - 建议:
- 适合容器化环境(如
Kubernetes),避免资源浪费; - 配合
-XX:ZUncommitDelay(默认300秒=5分钟)设置更长延迟(如600秒=10分钟)避免短时低谷误释放
- 适合容器化环境(如
- 案例:
- 应用峰值堆使用 64GB,日常仅用10GB→开启后RSS从64GB降至约12GB,避免资源浪费
5) -XX:+UseNUMA(NUMA 感知)
- 原理:
- 在多
CPU插槽(NUMA架构)服务器上,内存访问有“本地/远程”之分 - 堆初始化阶段:开启
-XX:+UseNUMA后,JVM不再随机分配堆内存,而是将堆的各个区域(如ZGC 的页组、G1的Region)平均拆分并绑定到所有可用NUMA节点 - 运行时分配:
JVM会优先让运行在某NUMA节点CPU上的线程,从该节点绑定的堆区域分配内存,最大化 “本地内存访问” 比例,减少跨节点访问延迟。
- 在多
- 默认值:
false - 案例:
- 2 路
Intel服务器(2 个 NUMA node),开启后吞吐提升 10%+
- 2 路
- 建议:只要机器是
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):堆内存只在首次访问时才真正分配物理页- 开启
AlwaysPreTouch后在JVM启动时- 主动“摸一遍”整个堆内存(从
-Xms到-Xmx的全部空间) - 强制操作系统(
OS)立即为这些虚拟地址分配真实的物理内存页。
- 主动“摸一遍”整个堆内存(从
- 避免运行时因首次访问大块内存而产生 不可预测的延迟毛刺
- 默认值:false
- 案例:
- 未启用:应用启动快,但高峰期首次分配大对象时触发 page fault → 延迟突增至几毫秒
- 启用后:启动时间略长(多 1~3 秒),但运行期无内存分配毛刺,TP99 延迟更平稳
- 尤其适合 低延迟服务(如交易、游戏、
API网关) 和 大堆(≥16GB)场景
- 建议:
- 物理机 / 独占
VM必开(内存确定可用,牺牲启动速度换运行稳定) - 容器环境谨慎开启(需确保
memory limit≥Xmx,否则可能OOM) - 通常与
-Xms = -Xmx配合使用,效果最佳
- 物理机 / 独占
8)-XX:+ZProactive(主动 GC 触发)
- 原理:
ZGC默认是 被动触发:仅当分配速率高或堆使用达到阈值时才启动 GC- 提前回收垃圾,降低高峰期
GC压力,避免突发Allocation Stall
- 默认值:
true - 案例:
- 未启用:流量突增时
GC跟不上分配速度 → 出现Allocation Stall(应用线程暂停) - 启用后:低峰期自动“打扫内存”,高峰期
GC进度始终领先 →TPS更稳,无卡顿 - 对 周期性流量(如白天高峰/夜间低谷) 效果显著
- 未启用:流量突增时
- 建议:
ZGC场景下保持默认开启即可(JDK21中默认为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 过于频繁 通常不推荐 - 值太小(如2.0)→
-
案例说明
-
场景:电商秒杀活动,正常分配速率 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>无法监控GCjconsole/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,直接内存可能持续增长直到系统崩溃。
- 默认行为:
- 若不设置
MaxDirectMemorySize,JVM默认将其设为与-Xmx(最大堆内存)相同。 - 应用可能无限制分配
DirectByteBuffer,导致:- 物理内存耗尽 → 触发
Linux OOM Killer杀死进程; - 或抛出
java.lang.OutOfMemoryError: Direct buffer memory。
- 物理内存耗尽 → 触发
- 若不设置
- 使用建议:
- 不设上限的直接内存,就像没装刹车的跑车——快,但随时可能翻车。
13)-XX:ZCollectionInterval
- 作用:
ZGC发生的最小时间间隔,单位秒 - 生效条件:
ZCollectionInterval依赖ZProactive才能生效 - 建议:
- 一般不建议配置,因为破坏
ZGC的自适应优势 - 适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到
95%以上才触发GC。 - 内存占用有周期性波动(如你的内存刷新任务)、需要「可控的
GC触发时机」(避免突发 GC 导致 RT 毛刺)的低延迟场景。
- 一般不建议配置,因为破坏
- 案例:
- 仅靠
ZProactive(主动内存管理)+ 被动GC,ZGC会「按需触发」,在内存刷新任务的峰值期,可能集中触发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开销较高,适合延迟极度敏感场景 - 分代
ZGC:CPU和内存开销显著降低,吞吐接近G1,同时保持 <1ms 停顿,推荐大多数新项目使用。
- 关注吞吐 vs 延迟,
- 监控:使用
-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 < 17→ZGC不成熟或不可用


