1. 项目背景
某金融科技公司的实时风控系统,承载着每天数十亿笔交易的风控决策。每一笔交易都必须在50ms内完成所有规则匹配和风险评分,否则下游业务方就会超时降级——轻则影响用户体验,重则导致资金损失。这个系统基于Java 17构建,堆内存16GB,运行在一台32核、64GB内存的物理服务器上。团队最初的GC选型是Parallel GC,因为它有着最高的吞吐量,理论上能让CPU更多地花在业务计算而非垃圾回收上。然而,真实世界的流量从来不是均匀的——上午10点到11点、下午2点到3点是交易高峰,每秒需要处理的请求从平时的3000笔飙升到12000笔。Parallel GC在这种场景下暴露出致命缺陷:Full GC期间Stop-The-World时间长达3秒,直接导致P99延迟飙升到3000ms,风控决策因超时而被迫跳过,积累了数百笔未评估的风险交易。
团队意识到问题后,将GC切换为G1。G1的表现确实好了很多——通过分区回收和并发标记,P99延迟从3000ms降到了200ms,系统可用性大幅提升。但好景不长,随着业务规则从最初的200条增长到800多条,对象分配速率从500MB/s攀升到1GB/s,G1也开始力不从心。即使将-XX:MaxGCPauseMillis设置为50ms,Mixed GC仍然不时出现80ms甚至100ms以上的停顿尖刺。原因在于,G1的疏散暂停(Evacuation Pause)是内生的——要回收Region中的垃圾,就必须把存活对象拷贝到新的Region,这个过程中所有Java线程必须停下来。堆越大,Remembered Set(RSet)的扫描成本就越高;存活对象越多,拷贝耗时就越长。G1的核心设计哲学是"在吞吐量和延迟之间取一个折中",它从来没有承诺过亚毫秒级暂停。而风控系统的SLA明确要求P99 < 10ms,这意味着G1在架构上就无法满足。
团队的焦虑是可以理解的。他们在G1上投入了大量调优精力——调整过InitiatingHeapOccupancyPercent、G1MixedGCLiveThresholdPercent、G1HeapWastePercent等十几个参数,但每次压测都会发现一两个"超级对象"(比如HashMap扩容时产生的128MB巨型对象)触发Mixed GC的长时间停顿。G1的巨型对象分配路径是特殊的:超过Region大小50%的对象会直接在老年代的Humongous Region中分配,这些区域的回收只能在Full GC或者包含该区域的Mixed GC中触发,且一旦有碎片化问题,回收效率极低。风险控制团队意识到,他们需要一种从根本上消除暂停的GC算法。
这就是ZGC和Shenandoah登场的地方。这两款GC的设计目标都是"无论堆多大、存活对象多少,GC暂停时间都能控制在1ms以下"。它们不追求吞吐量最大化,而是把延迟作为第一优先级。对于风控系统这样的延迟敏感型应用,亚毫秒暂停意味着可以在任何时候安全地触发GC,而不必担心撑爆P99。
2. 项目设计
小胖挠着脑袋问:"大师,我听说ZGC和Shenandoah都能做到亚毫秒暂停,它们到底是怎么做到的?G1不是已经很厉害了吗,为什么G1做不到而它们可以?"
大师放下手中的保温杯,在白板上画了起来:"G1做不到亚毫秒的核心原因,在于它必须Stop-The-World来执行对象疏散。想象你在搬家:G1的做法是让所有工人同时停下手中的活计,用叉车把需要移动的物品一次性搬完。搬家规模小的时候,停个几十秒问题不大。但如果仓库巨大(16GB堆),物品成千上万,每次搬家都得让所有人等上几十到上百毫秒。ZGC和Shenandoah的共同思路是:能不能让搬家这件事和工人的正常工作同时进行? 工人继续取货、放货,叉车在边上慢慢搬——这就是'并发搬迁/并发压缩'。"
小白插话:"但是大师,工人正在使用的物品怎么搬?比如工人手里正拿着一个货物清单,叉车把它搬走了,工人回头再找不就找不到了吗?"
"好问题!这恰恰是ZGC和Shenandoah从两个不同方向解决的同一个核心难题。我们来看看它们的方案。"
ZGC:彩色指针(Colored Pointers)
大师首先讲解ZGC:"ZGC的思路叫彩色指针。在64位JVM中,指针理论上有64位地址空间,但目前x86-64和ARM64实际上只用了48位(用户空间)或更少。ZGC利用指针中未使用的几个bit来编码对象的状态信息,就像给每个指针贴上不同颜色的标签。"
ASCII图解——ZGC Colored Pointer结构(64位):
+--------------------------------------------------------------------+
| 63-48 | 47-44 | 43-42 | 41 | 40 | 39-36 | 35-0 |
| 未使用 | GC元数据 | 预留 | M2 | M1 | Remap| Marked | 对象地址(36位)|
| | (4 bits) | | | | | | |
+--------------------------------------------------------------------+
Meta bits: [Finalizable][Remap][M1][M2]
大师指着图说:"在x86-64上,Linux用户空间地址的高16位在用户态下不可用(非规范地址),ZGC巧妙地把GC状态信息编码到指针的第42-45位上。一个彩色指针可能处于以下状态之一:
- Marked0 / Marked1:标记位,表示对象已经被标记为存活。
- Remapped:表示指针已经更新到对象的最新地址(已完成重映射)。
- Finalizable:表示该对象需要执行finalize方法。
关键机制是加载屏障。每次Java线程从堆中读取一个引用时,都会经过一段叫做'Load Barrier'的检查代码。这段代码由JIT编译器插入在每次解引用操作之前。它会检查指针的颜色位:
- 如果颜色是'Remapped'(正常状态),直接使用地址,无额外开销。
- 如果颜色不对,说明这个对象还没有被GC处理过,Load Barrier会触发缓慢路径(Slow Path),完成对该对象的标记、重映射或搬迁,然后修复指针,再返回正确的引用。这个过程叫做'自愈'。
我们把三个GC阶段串联起来看:
阶段1:标记(Mark) —— GC线程并发遍历对象图,将所有可达对象标记为Marked。此时新的颜色位被写入。如果Java线程在标记期间读取到一个尚未标记的对象,Load Barrier会自动标记它,防止漏标。
阶段2:搬迁(Relocate) —— GC线程找出需要清理的Page(ZGC使用分页管理,类似但不同于G1的Region),将存活对象拷贝到新的Page中,并在旧位置上保留一个转发指针(Forwarding Table,而非嵌入在对象头中)。此时旧地址的对象指针处于'未Remapped'状态。
阶段3:重映射(Remap) —— 所有指向旧地址的指针都需要更新到新地址。ZGC不做一个专门的'更新所有指针'的STW阶段,而是懒惰地依赖Load Barrier:当Java线程下一次读取到旧指针时,Load Barrier会发现颜色不对,自动将指针更新到新地址,同时把该地址标记为Remapped。
整个过程中,只有最开始的'起始标记(Pause Mark Start)'和结束时的'同步点'需要短暂的STW——通常在10微秒到1毫秒之间。所有真正耗时的工作(遍历对象图、拷贝对象)都是并发的。"
Shenandoah:布鲁克斯指针(Brooks Pointer)
"那么Shenandoah呢?"小胖追问。
大师擦了擦白板,画了另一张图:"Shenandoah走了完全不同的路。它在每个对象前面多加了一个间接指针,叫做布鲁克斯指针。"
ASCII图解——Shenandoah Brooks Pointer对象结构:
+---------------------------+
| Brooks Pointer (8 bytes) | ← 指向对象的当前真实地址
+---------------------------+ (可能已经变了!)
| Mark Word / Klass ptr |
| (normal object header) |
+---------------------------+
| Instance fields... |
+---------------------------+
Java引用 → [Brooks Ptr: 0x2000]
↓
(转发指针)
↓
0x2000: [对象数据]
(搬迁后):
Java引用 → [Brooks Ptr: 0x3000] ← 旧地址
↓
0x3000: [对象数据] ← 新地址(已拷贝)
大师解释:"关键点在于:Java变量里存储的引用永远不改变,一直指向最初分配的那个地址。但那个地址上的第一个8字节(Brooks Pointer)可能会变。GC线程在并发搬迁对象时:
- 把对象的数据拷贝到新地址。
- 用CAS操作把旧地址上的Brooks Pointer从'指向旧地址'改为'指向新地址'。
- 然后,任何Java线程通过旧引用来访问这个对象时,都会先读到Brooks Pointer,自动跳转到新地址获取数据。
这相当于给每个对象加了一层间接寻址——有点像C++里的智能指针,或者操作系统里页表的概念。Shenandoah也需要屏障,但用的是引用读写屏障,拦截每次读引用和写引用的操作。"
小胖若有所思:"所以ZGC和Shenandoah的本质区别是:ZGC修改的是指针本身(颜色位),需要Load Barrier来自动修复;Shenandoah修改的是对象头前的转发表,需要Brooks Pointer来转发。一个改了路标的颜色,一个在路口加了个指示牌。"
大师赞许地点头:"对,而且这个区别导致了一系列深远的工程权衡。"
关键差异对比
大师继续展开:
1. 并发压实方式不同:
- ZGC是"懒惰重映射"——不急于修正所有旧指针,依赖Load Barrier一点点修正。优点是暂停极短,缺点是被加载屏障拦截的线程会有一点点微小的性能损耗(通常1-4%)。
- Shenandoah是"全量更新引用"——在并发搬迁阶段,有一个并发更新引用的步骤,需要扫描所有线程栈和静态变量来修正引用。这个阶段是并发的,但会消耗更多CPU。
2. 内存开销与指针压缩:
- ZGC使用彩色指针,需要在指针中占用几个bit。在x86-64上,ZGC禁用了一些指针压缩的优化路径——它的对象指针不能随意压缩,因为压缩指针(32-bit)没有空间存放颜色位。这意味着ZGC比G1有更高的内存占用(OpenJDK官方测试显示约多占用10-20%)。
- Shenandoah使用Brooks Pointer,在每个对象头前加了8字节的间接指针,同样增加了内存占用。但这与指针压缩兼容性更好。
3. 分代差异(2024年时点):
- ZGC在JDK 21之前仅支持单代(不分代),这意味着每次GC都要扫描整个堆,包括那些长期存活的老对象。对于存活对象很多的场景,CPU开销非常大。JDK 21引入了ZGC Generational(分代ZGC),将堆分为年轻代和老年代,大幅降低了CPU开销。
- Shenandoah从JDK 15开始就支持分代(通过ShenandoahGenerational模式),更早地将年轻代的快速回收和老年代的稀疏回收结合起来。
4. NUMA感知:
- ZGC原生支持NUMA架构,会尽量在本地内存节点上分配对象,减少跨NUMA节点访问的延迟。这对大型多路服务器很重要。
- Shenandoah的NUMA支持相对较弱,没有ZGC那样精密的分页本地化策略。
5. 平台支持:
- ZGC:支持Linux/x86-64、Linux/ARM64、macOS/ARM64、Windows/x86-64(JDK 15+)。
- Shenandoah:支持Linux/x86-64、Linux/aarch64、macOS/x86-64(有限),Windows平台不支持。
6. 吞吐量排名(近似):
- Parallel GC > G1 > Shenandoah ≈ ZGC
- Shenandoah略微领先ZGC(因为不需要遍历所有指针做颜色检查),但差距在5%以内。
7. CPU使用率:
- ZGC和Shenandoah都比G1消耗更多CPU(需要并发GC线程持续工作),约多10-30%的CPU。
- ZGC的Colored Pointer Load Barrier在每次对象读取时都有微小开销,高频率的对象访问场景下CPU开销更明显。
- Shenandoah的Brooks Pointer转发在读取时也多了一层间接访问的开销。
大师最后总结:"选型没有银弹,我们需要一个决策树:"
┌─────────────────────────────────────────────┐
│ 你的延迟要求是什么? │
└──────────────────┬──────────────────────────┘
│
┌───────────┴───────────┐
│ P99 < 100ms? │ P99 > 100ms
↓ ↓
┌──────────────────┐ ┌──────────────┐
│ P99 < 10ms? │ │ 用 G1 即可 │
└────┬─────────────┘ └──────────────┘
│
┌────┴────┐
│ 是 │ 否(10-100ms)
↓ ↓
┌──────┐ ┌──────────┐
│ ZGC │ │ G1微调 │
│ 或 │ │ 或 │
│Shen- │ │ ZGC生成代│
│andoah│ └──────────┘
└──┬───┘
│
┌──┴──────────────┐
│ 跑在什么平台上? │
└──┬──────────────┘
│
┌──┴──────────┐
│ Linux? │ Windows/macOS?
↓ ↓
┌──────────┐ ┌──────┐
│两者皆可 │ │ ZGC │
│看具体需求 │ └──────┘
└──────────┘
技术映射(全章汇总)
| 生活比喻 | 技术概念 | 源码位置 |
|---|---|---|
| 搬家工人不停工、叉车边搬 | ZGC/Shenandoah并发搬迁 | src/hotspot/share/gc/z/zRelocate.cpp / src/hotspot/share/gc/shenandoah/shenandoahConcurrentGC.cpp |
| 指针上贴"颜色标签" | ZGC Colored Pointer元数据位 | src/hotspot/share/gc/z/zGlobals.hpp: ZPointer*相关常量 |
| 每次看路标时自动识别颜色 | ZGC Load Barrier(自愈) | src/hotspot/share/gc/z/c2/zBarrierSetC2.cpp |
| 每个房间门口放"新地址指示牌" | Shenandoah Brooks Pointer(转发表) | src/hotspot/share/gc/shenandoah/shenandoahOop.hpp |
| 道路分"快速车道"和"慢速车道" | 年轻代/老年代分代回收 | src/hotspot/share/gc/z/zGeneration.cpp / shenandoahGeneration.cpp |
| 搬家工人在本地仓库搬运 | NUMA感知内存分配 | src/hotspot/share/gc/z/zPhysicalMemory.cpp |
| 每隔一段时间检查所有路标 | ZGC并发标记周期 | src/hotspot/share/gc/z/zDriver.cpp |
| 不在搬家的路口(只标记不搬) | G1的Mixed GC(部分STW搬迁) | src/hotspot/share/gc/g1/g1CollectedHeap.cpp |
| "超级大件"单独处理 | Humongous对象分配路径 | src/hotspot/share/gc/z/zPageAllocator.cpp / g1CollectedHeap.cpp: humongous_obj_allocate |
| 仓库内的标签系统记录有哪些路口 | Remembered Set(RSet) | src/hotspot/share/gc/g1/heapRegionRemSet.cpp |
3. 项目实战
3.1 环境准备
本实战演示搭建一个模拟风控系统的压测环境,在同一台机器上切换G1、ZGC、Shenandoah三款GC,对比分析它们的暂停时间分布。
| 环境项 | 配置 |
|---|---|
| JDK 版本 | JDK 21.0.5 (LTS) — 用于ZGC Generational模式 |
| JDK 版本(备选) | JDK 17.0.13 (LTS) — 用于Shenandoah(跨平台兼容性最好) |
| 操作系统 | Linux x86-64 (CentOS 8 / Ubuntu 22.04) |
| CPU | 32核 Intel Xeon Gold 处理器 |
| 内存 | 64GB RAM,堆设置为16GB |
| 压测工具 | Apache JMeter 5.6 + 自定义Java压测程序 |
| JFR 监控 | JDK Mission Control 9.x + jcmd |
| GC日志 | -Xlog:gc*=info 统一日志框架 |
3.2 分步实现
步骤一:同压测脚本下三款GC对比
首先编写一个模拟风控系统的Java程序,模拟以下内存特征:
- 高分配速率:每秒约1GB对象分配,模拟风险决策产生大量临时MatchResult、RiskScore对象
- 混合生命周期:50%的对象在100ms内死亡(典型请求级对象),30%存活1-5秒(规则编译缓存),20%存活超过30秒(规则定义、配置对象)
- 巨型对象(Humongous):每5秒产生一个约2MB的大对象(模拟风控规则中正则表达式的编译缓存、特征向量矩阵)
- 突发流量:从2000 TPS突然飙升至15000 TPS,持续30秒,然后回落
// RiskControlBenchmark.java
// 编译: javac RiskControlBenchmark.java
// 运行: java -XX:+UseZGC -Xms16g -Xmx16g -XX:+ZGenerational RiskControlBenchmark 300
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class RiskControlBenchmark {
private static final int THREAD_COUNT = 32;
private static final Map<String, RiskRule> RULE_CACHE = new ConcurrentHashMap<>(10000);
private static final List<byte[]> LONG_LIVED_STORE = new CopyOnWriteArrayList<>();
private static volatile boolean running = true;
private static long startTime;
static class RiskRule {
String ruleId;
byte[] compiledPattern; // ~200KB 编译后的规则逻辑
long lastUsed;
RiskRule(String id) {
this.ruleId = id;
this.compiledPattern = new byte[200_000];
new Random().nextBytes(this.compiledPattern);
this.lastUsed = System.currentTimeMillis();
}
}
static class MatchResult {
String ruleId;
double score;
Map<String, Object> context; // 请求级上下文,含大量临时对象
byte[] payload;
MatchResult(String id, double s) {
this.ruleId = id;
this.score = s;
this.context = new HashMap<>(64);
for (int i = 0; i < 64; i++) {
context.put("key_" + i, new byte[128]); // 8KB 临时上下文数据
}
this.payload = new byte[512]; // 512B 载荷
}
Map<String, Object> cloneContext() {
Map<String, Object> cloned = new HashMap<>(this.context);
return cloned;
}
}
static class RequestWorker implements Runnable {
private final Random rand = new Random();
private int reqCount;
@Override
public void run() {
while (running) {
reqCount++;
long now = System.currentTimeMillis();
List<MatchResult> results = new ArrayList<>(64);
// 模拟一次请求中执行64条规则的匹配
for (int j = 0; j < 64; j++) {
String ruleId = "RULE_" + rand.nextInt(2000);
double score = rand.nextDouble() * 100;
results.add(new MatchResult(ruleId, score));
}
// --- 模拟中型生命周期对象:把部分结果缓存到RULE_CACHE ---
if (reqCount % 10 == 0) {
for (MatchResult r : results) {
RULE_CACHE.put(r.ruleId + "_" + reqCount, new RiskRule(r.ruleId));
}
// 清理过期缓存
RULE_CACHE.entrySet().removeIf(e ->
now - e.getValue().lastUsed > 30_000);
}
// --- 模拟大型生命周期对象 ---
if (reqCount % 5000 == 0) {
// 每5000个请求产生一个大对象(约2MB),模拟特征向量矩阵
byte[] bigMatrix = new byte[2_000_000];
rand.nextBytes(bigMatrix);
LONG_LIVED_STORE.add(bigMatrix);
// 控制长寿对象总量不超过300个(600MB)
if (LONG_LIVED_STORE.size() > 300) {
LONG_LIVED_STORE.remove(0);
}
}
// --- 模拟对象克隆(触发频繁的小对象分配) ---
for (MatchResult r : results) {
Map<String, Object> cloned = r.cloneContext();
cloned.clear(); // 立即释放
}
// 微小的请求间延迟,但保持高吞吐
try {
Thread.sleep(0, rand.nextInt(100)); // 0-100纳秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
public static void main(String[] args) throws Exception {
int durationSeconds = args.length > 0 ? Integer.parseInt(args[0]) : 300;
startTime = System.currentTimeMillis();
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
System.out.println("=== 风险控制系统GC压测启动 ===");
System.out.println("线程数: " + THREAD_COUNT + ", 运行时长: " + durationSeconds + "秒");
System.out.println("当前GC: " + System.getProperty("java.vm.name"));
for (int i = 0; i < THREAD_COUNT; i++) {
executor.submit(new RequestWorker());
}
// 突发流量模拟器:每60秒触发一次30秒的突发
AtomicInteger burstLevel = new AtomicInteger(1);
ScheduledExecutorService burstScheduler = Executors.newScheduledThreadPool(1);
burstScheduler.scheduleAtFixedRate(() -> {
// 30秒突发期:增加额外线程
if (burstLevel.get() == 1) {
System.out.println("[突发] 流量峰值开始 @ " +
(System.currentTimeMillis() - startTime) / 1000 + "s");
// 突发期间产生大量对象分配到RULE_CACHE
for (int i = 0; i < 10000; i++) {
RULE_CACHE.put("BURST_" + i + "_" + System.nanoTime(),
new RiskRule("PEAK_RULE"));
}
burstLevel.set(2);
} else {
System.out.println("[突发] 流量峰值结束 @ " +
(System.currentTimeMillis() - startTime) / 1000 + "s");
// 清理突发对象
RULE_CACHE.entrySet().removeIf(e -> e.getKey().startsWith("BURST_"));
burstLevel.set(1);
}
}, 30, 30, TimeUnit.SECONDS);
// 运行一段时间后自动停止
Thread.sleep(durationSeconds * 1000L);
running = false;
executor.shutdownNow();
burstScheduler.shutdownNow();
System.out.println("=== 压测结束 ===");
System.out.println("缓存存活规则数: " + RULE_CACHE.size());
System.out.println("长寿对象数: " + LONG_LIVED_STORE.size());
}
}
针对三款GC,使用以下JVM参数分别启动程序:
G1 GC(基准):
# G1 — 低延迟配置
java \
-XX:+UseG1GC \
-Xms16g -Xmx16g \
-XX:MaxGCPauseMillis=50 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1MixedGCLiveThresholdPercent=70 \
-XX:G1HeapWastePercent=5 \
-XX:+ParallelRefProcEnabled \
-XX:+UnlockExperimentalVMOptions \
-XX:G1NewSizePercent=5 \
-XX:G1MaxNewSizePercent=60 \
-XX:+DisableExplicitGC \
-Xlog:gc*=info:file=gc-g1.log:time,level,tags:filecount=10,filesize=100M \
RiskControlBenchmark 300
ZGC(JDK 21+ Generational模式,推荐):
# ZGC — 分代模式 (JDK 21+)
# -XX:ZCollectionInterval=0 表示不设置GC间隔(由启发式自动决定)
java \
-XX:+UseZGC \
-Xms16g -Xmx16g \
-XX:+ZGenerational \
-XX:ZCollectionInterval=0 \
-XX:+UseDynamicNumberOfGCThreads \
-XX:ConcGCThreads=4 \
-XX:+ZUncommit \
-XX:ZUncommitDelay=300 \
-XX:+UnlockDiagnosticVMOptions \
-XX:+UseLargePages \
-Xlog:gc*=info:file=gc-zgc.log:time,level,tags:filecount=10,filesize=100M \
-XX:StartFlightRecording=disk=true,\
dumponexit=true,\
filename=zgc-profile.jfr,\
settings=profile \
RiskControlBenchmark 300
Shenandoah GC(JDK 17+):
# Shenandoah — 自适应启发式
# 注意:Shenandoah 在 Windows 上不支持,仅在 Linux 上可用
java \
-XX:+UseShenandoahGC \
-Xms16g -Xmx16g \
-XX:ShenandoahGCHeuristics=adaptive \
-XX:ShenandoahAllocSpikeFactor=5 \
-XX:ShenandoahGarbageThreshold=60 \
-XX:ConcGCThreads=4 \
-XX:+ShenandoahPacing \
-XX:ShenandoahPacingMaxDelay=1 \
-XX:+PrintGC \
-XX:+PrintGCDetails \
-Xlog:gc*=info:file=gc-shenandoah.log:time,level,tags:filecount=10,filesize=100M \
-XX:+ShenandoahOOMDuringEvacALot \
RiskControlBenchmark 300
预期结果(典型数据,实际因硬件而异):
| 指标 | G1 | ZGC Generational | Shenandoah |
|---|---|---|---|
| 平均GC暂停 | 28ms | 0.3ms | 0.8ms |
| P99 GC暂停 | 92ms | 0.7ms | 1.2ms |
| Max GC暂停 | 180ms | 1.5ms | 3.0ms |
| 吞吐量 | 92% | 87% | 86% |
| CPU使用率 | 60% | 72% | 75% |
| 内存占用(物理) | 16.5GB | 19.2GB | 18.1GB |
步骤二:GC日志对比分析
ZGC日志解析(带注释):
[2025-07-23T10:15:04.123+0800] ZGC(10) Pause Mark Start 1.234ms
# ↑ 标记起始暂停:1.234ms(所有GC线程准备好)
[2025-07-23T10:15:04.456+0800] ZGC(10) Concurrent Mark 332.891ms
# ↑ 并发标记阶段:遍历所有可达对象,332ms,不暂停应用
[2025-07-23T10:15:04.457+0800] ZGC(10) Pause Mark End 0.087ms
# ↑ 标记结束暂停:仅0.087ms(等待所有线程同步)
[2025-07-23T10:15:04.458+0800] ZGC(10) Concurrent Process Non-Strong References 12.345ms
# ↑ 并发处理弱引用、软引用、虚引用
[2025-07-23T10:15:04.459+0800] ZGC(10) Concurrent Reset Relocation Set 1.234ms
# ↑ 选择本次需要搬迁的Page集合
[2025-07-23T10:15:04.460+0800] ZGC(10) Pause Verify 0.012ms
# ↑ 验证暂停,基本可以忽略
[2025-07-23T10:15:04.461+0800] ZGC(10) Concurrent Select Relocation Set 23.456ms
# ↑ 分析每个Page中存活对象的分布,优选"存活率低"的Page(回收效率高)
[2025-07-23T10:15:04.462+0800] ZGC(10) Pause Relocate Start 0.045ms
# ↑ 搬迁阶段开始暂停
[2025-07-23T10:15:04.889+0800] ZGC(10) Concurrent Relocate 426.789ms
# ↑ 并发搬迁:拷贝存活对象到新Page。应用线程可以继续运行,
# 通过Load Barrier自动处理旧指针。
[2025-07-23T10:15:04.890+0800] ZGC(10) Load: 12.3GB (76.87%)
# ↑ 当前堆使用量(包含可回收垃圾)
ZGC日志快速查询命令:
# 提取所有暂停时间并按从大到小排序
grep "Pause" gc-zgc.log | awk '{print $NF}' | sort -rn | head -20
# 统计各阶段累计耗时
grep -E "(Pause|Concurrent)" gc-zgc.log | \
awk '{split($NF,a,"ms"); print $(NF-1), a[1]}' | \
awk '{sum[$1]+=$2; count[$1]++} END {for(k in sum) printf "%s: total=%dms, count=%d, avg=%.2fms\n", k, sum[k], count[k], sum[k]/count[k]}'
# 检查是否有超过1ms的暂停(ZGC正常应很少)
grep "Pause.*ms$" gc-zgc.log | awk '{val=$NF; gsub(/ms/,"",val); if(val+0>1) print}'
Shenandoah日志解析(带注释):
[2025-07-23T10:15:05.001+0800] GC(1) Pause Init Mark 1.201ms
# ↑ 初始标记暂停:扫描线程栈和根引用
[2025-07-23T10:15:05.002+0800] GC(1) Concurrent marking 345.678ms
# ↑ 并发标记阶段
[2025-07-23T10:15:05.003+0800] GC(1) Pause Final Mark 0.890ms
# ↑ 最终标记暂停:处理SATB队列中剩余引用
[2025-07-23T10:15:05.004+0800] GC(1) Concurrent evacuation 189.012ms
# ↑ 并发搬迁(通过Brooks指针):GC线程拷贝对象,应用线程继续读写
[2025-07-23T10:15:05.005+0800] GC(1) Concurrent update references 234.567ms
# ↑ 并发更新引用:扫描所有指针,将其从旧地址更新到新地址
# 这是Shenandoah独有的阶段,ZGC不需要(用Load Barrier懒惰更新)
[2025-07-23T10:15:05.006+0800] GC(1) Pause Final Update Refs 0.345ms
# ↑ 最终更新引用暂停:确保所有根引用都更新完毕
[2025-07-23T10:15:05.007+0800] GC(1) Concurrent cleanup 12.345ms
# ↑ 并发清理:回收旧Region
三款GC暂停时间分布对比表:
| 百分位 | G1 | ZGC Generational | Shenandoah |
|---|---|---|---|
| P50 | 12ms | 0.15ms | 0.35ms |
| P90 | 35ms | 0.40ms | 0.90ms |
| P99 | 92ms | 0.70ms | 1.50ms |
| P99.9 | 145ms | 1.10ms | 2.80ms |
| P99.99 | 200ms | 1.50ms | 3.50ms |
| Max | 220ms | 2.00ms | 5.00ms |
| STW总时间占比 | 8.5% | 0.03% | 0.12% |
步骤三:JFR事件分析
启用JFR收集ZGC专用事件,对比不同GC下的线程停顿时间:
# 使用 jcmd 在运行时动态开启 JFR
jcmd <pid> JFR.start \
name=gc-profiling \
settings=profile \
filename=gc-events.jfr \
duration=300s
# 分析ZGC关键JFR事件
jfr print --events jdk.ZAllocationStall,jdk.ZPageAllocation,jdk.ZThreadRoots \
gc-events.jfr
# 提取所有ZGC事件的线程停顿信息
jfr summary --event jdk.GarbageCollection gc-events.jfr
# 使用jq风格的查询(需要JDK 21+的jfr命令行增强)
jfr print --categories GC gc-events.jfr
JFR对ZGC的专用事件枚举:
| JFR事件名 | 含义 | 典型值 |
|---|---|---|
| jdk.ZAllocationStall | 应用线程因等待GC释放内存而停顿 | 0-500μs |
| jdk.ZPageAllocation | ZPage的创建和销毁 | 每周期约500个 |
| jdk.ZThreadRoots | 线程根扫描耗时 | 10-100μs |
| jdk.ZRelocationSet | 搬迁集选择 | 20-50ms(并发阶段,不暂停) |
| jdk.ZStatisticsCounter | ZGC统计计数器 | N/A |
| jdk.G1GarbageCollection | G1 GC事件 | 10-200ms |
| jdk.ShenandoahHeapRegion | Shenandoah Region状态变化 | N/A |
# 快速对比三款GC的JFR暂停统计数据
echo "=== G1 GC 暂停汇总 ==="
jfr print --events jdk.G1GarbageCollection gc-g1.jfr 2>/dev/null | \
grep -E "sumOfPauses|longestPause" | head -5
echo "=== ZGC 分配停顿 ==="
jfr print --events jdk.ZAllocationStall gc-zgc.jfr 2>/dev/null | \
awk '/duration/ {print $NF}'
echo "=== Shenandoah GC 周期 ==="
jfr print --events jdk.ShenandoahHeapRegion gc-shenandoah.jfr 2>/dev/null | \
grep -E "Pause|Concurrent"
步骤四:选型决策树实战
在实际风险场景中,根据以下维度选择GC:
制作决策矩阵:
┌──────────────────┬──────────┬───────────────┬────────────────┐
│ 场景维度 │ G1 │ ZGC (分代) │ Shenandoah │
├──────────────────┼──────────┼───────────────┼────────────────┤
│ 堆大小 < 4GB │ ★★★★☆ │ ★★★☆☆ │ ★★★☆☆ │
│ 堆大小 4-32GB │ ★★★★☆ │ ★★★★★ │ ★★★★☆ │
│ 堆大小 > 32GB │ ★★★☆☆ │ ★★★★★ │ ★★★★☆ │
│ 分配速率 <500MB/s│ ★★★★☆ │ ★★★★☆ │ ★★★★☆ │
│ 分配速率 500M-2G/s│ ★★★☆☆ │ ★★★★★ │ ★★★★☆ │
│ 分配速率 > 2GB/s │ ★★☆☆☆ │ ★★★★☆ │ ★★★☆☆ │
│ P99暂停 < 50ms │ ★★★☆☆ │ ★★★★★ │ ★★★★☆ │
│ P99暂停 < 10ms │ ★★☆☆☆ │ ★★★★★ │ ★★★★☆ │
│ P99暂停 < 1ms │ ★☆☆☆☆ │ ★★★★★ │ ★★★★☆ │
│ CPU预算充足 │ ★★★★☆ │ ★★★★★ │ ★★★★★ │
│ CPU预算紧张 │ ★★★★☆ │ ★★★☆☆ │ ★★★☆☆ │
│ 内存预算紧张 │ ★★★★★ │ ★★★☆☆ │ ★★★★☆ │
│ Windows平台 │ ★★★★☆ │ ★★★★★ │ ☆☆☆☆☆ │
│ ARM64平台 │ ★★★★☆ │ ★★★★★ │ ★★★★☆ │
│ NUMA大机器 │ ★★★☆☆ │ ★★★★★ │ ★★★☆☆ │
│ 极高压场景 │ ★★★☆☆ │ ★★★★☆ │ ★★★☆☆ │
└──────────────────┴──────────┴───────────────┴────────────────┘
具体推荐决策规则:
-
P99 < 1ms 且 Linux 平台:首选ZGC Generational(JDK 21+),次选Shenandoah。ZGC的分代模式结合了低暂停和可接受的CPU开销,是目前在亚毫秒延迟目标下最成熟的选择。
-
P99 < 10ms 且 Windows 平台:只能选ZGC(Shenandoah不支持Windows)。使用JDK 21+的ZGenerational模式。注意Windows上的大页(Large Pages)需要额外的操作系统配置。
-
P99 < 50ms 且 CPU 预算紧张:继续使用G1,调优而非替换。G1经过多年打磨,在50ms这个延迟档位上有最优的CPU效率。尝试降低-XX:MaxGCPauseMillis但不要低于20ms(否则目标过于激进,G1频繁触发并发标记反而降低效率)。
-
内存极度紧张(总内存 < 堆的120%):三思而后行。ZGC需要额外的头部空间(彩色指针阻止压缩,约多占用10-20%),Shenandoah每个对象多8字节。在内存受限的环境下,低延迟GC的内存代价可能得不偿失。
从G1迁移到ZGC的清单(5项):
-
JDK升级:至少升级到JDK 17(ZGC单代)或JDK 21(ZGC分代,强烈推荐)。检查所有第三方库是否兼容目标JDK版本。
-
移除G1专用调优参数:
-XX:MaxGCPauseMillis、-XX:G1*系列参数在ZGC下无效且可能导致警告,需要全部移除。同时检查是否硬编码了-XX:+UseG1GC。 -
预留额外内存:将堆设置为比G1配置多15-20%(例如G1用16GB,ZGC设19GB)。监控物理内存中堆之外的部分(Metaspace、线程栈、直接内存、CodeCache等)。
-
检查依赖指针压缩的代码:某些JNI代码或Agent可能依赖对象的指针压缩行为,ZGC的限制可能与G1不同。检查第三方Agent(如APM探针)是否兼容ZGC。
-
渐进式灰度上线:先在预发环境运行至少一周,确认GC没有任何退化行为。再选择3-5%的生产流量切换到ZGC节点,观察P99延迟和CPU使用率变化。确认无问题后逐步放大到100%。
3.3 测试验证
| 验收项 | 测试方法 | G1基线 | ZGC目标 | Shenandoah目标 |
|---|---|---|---|---|
| P99 GC暂停 | gc.log独立分析 | < 100ms | < 1ms | < 2ms |
| Max GC暂停 | gc.log独立分析 | < 200ms | < 2ms | < 5ms |
| 吞吐量降幅 | 与Parallel GC对比 | 基准92% | > 85% | > 84% |
| CPU开销增幅 | top/htop监控 | 基准60% | < 75% | < 78% |
| 300秒无Full GC | gc.log排查 | 偶发 | 0次 | 0次 |
| 大对象分配无退化 | 2MB对象持续分配 | 5ms波动 | < 1ms波动 | < 2ms波动 |
| 突发流量恢复时间 | 突发后30秒内指标正常 | 60-90s | < 20s | < 30s |
| 无内存泄漏 | 运行1小时后堆稳定 | 稳定 | 稳定 | 稳定 |
| OOM Kill无风险 | 堆使用率< 85% | 75% | 80% | 78% |
验收标准: 所有ZGC/Shenandoah目标项必须100%达标,G1退化项(如CPU开销升幅)允许有10%的安全裕量。若任何一项超标,需要进一步调优GC参数或增加机器资源。
4. 项目总结
4.1 优点与缺点
| 维度 | ZGC (Generational) | Shenandoah | G1 |
|---|---|---|---|
| 暂停时间 | ★★★★★ 亚毫秒级,P99<1ms(分代模式) | ★★★★☆ 毫秒级,P99<3ms | ★★☆☆☆ P99 50-200ms |
| 吞吐量 | ★★★☆☆ 约为Parallel的85-90% | ★★★☆☆ 约为Parallel的84-88% | ★★★★☆ 约为Parallel的90-95% |
| 内存额外开销 | ★★★☆☆ 多占用10-20%(禁用部分压缩) | ★★★☆☆ 每对象多8字节 + 额外管理开销 | ★★★★★ 除RSet外几乎无额外开销 |
| CPU开销(vs G1) | ★★★☆☆ 多10-30% | ★★★☆☆ 多10-30% | ★★★★★ 基准 |
| 超大堆支持(>1TB) | ★★★★★ 设计上支持16TB堆 | ★★★★☆ 支持TB级,但实测数据较少 | ★★★☆☆ 随着堆增大RSet开销显著 |
| Windows平台支持 | ★★★★★ JDK 15+ | ☆☆☆☆☆ 不支持 | ★★★★★ JDK 7+ |
| ARM64支持 | ★★★★★ JDK 17+ | ★★★★☆ 部分支持 | ★★★★★ JDK 9+ |
| 分代支持 | ★★★★★ JDK 21+(推荐) | ★★★★☆ JDK 15+ | ★★★★★ 原生分代 |
| NUMA感知 | ★★★★★ 精细的本地化策略 | ★★★☆☆ 较弱 | ★★★☆☆ 中等 |
| 成熟度 | ★★★★☆ 5年生产验证 | ★★★☆☆ 社区主要维护 | ★★★★★ 15年生产验证 |
4.2 适用场景
ZGC 最合适的 5 大场景:
- 金融交易系统:订单匹配引擎,P99 < 1ms 的刚性要求,堆32GB+。
- 实时广告竞价:每次展示须在50ms内返回出价,GC暂停不可感知。
- 高频数据处理:Kafka Streams / Flink实时流处理,堆16-64GB,分配速率高。
- 游戏服务器:帧同步逻辑,最大暂停 < 5ms(玩家可感知延迟阈值)。
- 异地多活数据库代理:SQL解析与路由,暂停导致超过50ms即触发故障转移。
ZGC 不适合的 2 大场景:
- 内存极度受限的微服务(堆 < 2GB):ZGC的内存额外开销在此场景下占比过高。
- 批处理任务(如MapReduce):吞吐量优先,暂停时间不敏感,Parallel GC更合适。
Shenandoah 最合适的 5 大场景:
- 大堆Web服务(16-128GB堆):长期运行的后端服务,P99 < 10ms 需求,Linux环境。
- 弹性伸缩的云原生应用:堆大小动态变化,Shenandoah的启发式自适应调整快。
- 分析型OLAP系统(如Presto/Trino Worker节点):大量短生命周期对象,分代回收效率高。
- 规则引擎(如Drools):规则库加载产生大量老年代对象,Shenandoah并发压缩可平滑处理。
- 对Red Hat生态有依赖的企业:Shenandoah由Red Hat主导,在RHEL/OpenShift上有最佳集成。
Shenandoah 不适合的 2 大场景:
- Windows部署环境:完全不支持。
- 要求极致低暂停(P99 < 500μs):ZGC在此区间更加稳定。
4.3 注意事项
| 注意事项 | 详细说明 |
|---|---|
| JDK版本要求 | ZGC Generational 需要 JDK 21+(生产版本)。JDK 17 中的ZGC是单代版本,CPU开销明显高于分代版本。Shenandoah在JDK 11+中可用但JDK 17+最稳定。 |
| 平台约束 | ZGC在JDK 15之前不支持Windows/macOS;Shenandoah完全不支持Windows。跨平台部署项目需特别注意。 |
| 内存陷阱1:堆外内存 | ZGC使用mmap分配堆内存,Java堆常驻内存可能高于-Xmx设置,因为ZGC多页管理的碎片不能及时归还OS(可通过-XX:ZUncommitDelay配置归还延迟)。 |
| 内存陷阱2:对象膨胀 | Shenandoah的Brooks Pointer每个对象多8字节。对于百万级别的小对象集合,额外内存可能高达数十MB。 |
| CPU陷阱:过度GC | 如果分配速率超过GC回收速率(Allocation Stall),ZGC会触发"分配停顿"——这是其最严重的停顿场景(可达毫秒级)。调优时应监控jdk.ZAllocationStall事件。 |
| 监控盲区 | 传统监控工具(如jstat)对ZGC的支持不完全,部分指标为0或显示不正常。必须依赖-Xlog:gc日志和JFR进行分析。 |
| 大页配置 | ZGC和Shenandoah都能受益于透明大页(THP)或显式大页(-XX:+UseLargePages),但配置不当会导致内存浪费或分配失败。建议先在测试环境验证大页配置。 |
| JIT编译开销感知 | Load Barrier是JIT编译器插入的额外指令,ZGC的barrier比Shenandoah更轻量(仅读屏障),但都会增加编译后的机器码体积,可能导致CodeCache压力增大。 |
4.4 常见踩坑经验
踩坑案例1:ZGC分配停顿导致的P99毛刺
某支付系统从G1迁移到ZGC后,P99延迟从80ms稳定降到2ms,但偶尔出现100ms+的毛刺。排查发现:压测期间分配速率超过4GB/s,ZGC的并发回收线程跟不上分配速度,触发了Allocation Stall。根因是ZGC单代模式下每次都要扫描整个堆(16GB),并发标记时长高达500ms+。解决方案:(1) 升级到JDK 21启用分代模式,年轻代的快速回收将并发标记时间缩短到50ms;(2) 增加ConcGCThreads从默认4个到8个;(3) 优化业务代码减少不必要的对象分配。最终毛刺完全消除。
踩坑案例2:Shenandoah的"假低延迟"陷阱
某在线教育平台在Kubernetes上部署Spring Boot应用,使用Shenandoah GC。单节点测试时暂停时间很理想(<2ms),但生产环境中容器被OOM Kill反复重启。根因排查:Kubernetes的Limit设置为16GB,但Shenandoah的额外内存开销(Brooks Pointer + Region管理)加上堆外内存(Netty的直接缓冲区、Metaspace),实际物理内存使用峰值达到22GB,触发了Cgroup的OOM Kill。解决方案:(1) 降低-Xmx到12GB;(2) 限制Netty直接内存使用-Dio.netty.maxDirectMemory=0;(3) 增加Pod Limit到24GB。
踩坑案例3:ZGC和JVM Agent的兼容性冲突
某企业使用商业APM Agent(Java Agent + JVMTI)做全链路追踪,切换到ZGC后应用启动即崩溃,报错"ShouldNotReachHere() in ZBarrierSetAssembler"。根因:商业APM Agent使用字节码增强(Bytecode Instrumentation),在每次对象访问前插入了自己的hook逻辑,与ZGC的Load Barrier发生了冲突——Agent插入的代码破坏了ZGC对指针颜色的期待。解决方案:联系APM厂商确认ZGC兼容版本,或切换到不依赖字节码增强的OpenTelemetry Java Agent。
4.5 思考题
思考题1(难度:中高级):若在NUMA架构(4个NUMA Node,每个Node 16核 + 64GB内存)上运行ZGC,堆大小为128GB。当应用线程分配内存时,ZGC如何决定对象应该放在哪个NUMA Node的Page中?如果应用线程属于NUMA Node 0但频繁访问Node 2上的对象,ZGC能否感知并调整?请结合zPhysicalMemory.cpp中的内存管理策略分析。
提示思路:ZGC维护了每个NUMA节点的空闲Page池,ZPageAllocator在分配新Page时会优先选择当前线程所在NUMA节点的内存。ZPhysicalMemoryManager中通过numa_get_membind函数判断亲和性。跨NUMA访问的感知是间接的:ZGC不主动追踪访问模式,但线程迁移到不同NUMA Node时(由操作系统调度),后续的本地分配会自动切换到新Node的内存池。此外,ZGC的搬迁策略也会尽量将对象搬迁到访问频率更高的NUMA Node上(通过搬迁算法的启发式选择)。但这依赖于操作系统的libnuma支持是否启用。
思考题2(难度:高级):某项目使用ZGC Generational模式,堆16GB,存活对象约8GB(50%存活率)。如果业务代码中频繁写入HashMap并对HashMap进行resize(例如每次扩容产生新的Node数组),ZGC的Load Barrier会不会因此产生级联效应——即同样一批指针因为HashMap的内部重组而反复触发Slow Path?这种场景下,Shenandoah的并发更新引用机制是否更有优势?
提示思路:HashMap扩容时,旧的Node[]数组中的所有元素需要重新Hash到新的Node[]数组中。在这个过程中,JVM需要读取每个Entry的key和value对象的引用。如果这些对象恰好处于"未Remapped"状态(刚被ZGC搬迁过),每个读操作都会触发Load Barrier的Slow Path——这确实会形成一个"级联自愈"效应。不过,ZGC的Load Barrier的Slow Path是单次操作(修好后该指针变为Remapped状态,后续访问直接走Fast Path)。因此级联效应仅在扩容这一瞬间(通常数微妙到几毫秒)发生,对整体暂停影响有限。Shenandoah的优势在于:它的并发更新引用阶段已经将所有指向旧地址的指针全部修正,HashMap扩容时读到的是已修正的引用,不需要任何特殊处理。但Shenandoah为此付出了更多的并发CPU时间。结论:在"频繁对象搬迁 + 大量指针遍历"的场景下,Shenandoah比ZGC有细微优势,但两款的差距在5%以内。
下一章预告:第22章将深入JIT编译分层——理解为什么Java程序"越跑越快",以及如何定位"越跑越慢"的反优化问题。
延伸阅读与资源
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
MySQL Server 9从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号