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线程在并发搬迁对象时:

  1. 把对象的数据拷贝到新地址。
  2. 用CAS操作把旧地址上的Brooks Pointer从'指向旧地址'改为'指向新地址'。
  3. 然后,任何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大机器       │  ★★★☆☆  │  ★★★★★        │  ★★★☆☆         │
│ 极高压场景       │  ★★★☆☆  │  ★★★★☆        │  ★★★☆☆         │
└──────────────────┴──────────┴───────────────┴────────────────┘

具体推荐决策规则:

  1. P99 < 1ms 且 Linux 平台:首选ZGC Generational(JDK 21+),次选Shenandoah。ZGC的分代模式结合了低暂停和可接受的CPU开销,是目前在亚毫秒延迟目标下最成熟的选择。

  2. P99 < 10ms 且 Windows 平台:只能选ZGC(Shenandoah不支持Windows)。使用JDK 21+的ZGenerational模式。注意Windows上的大页(Large Pages)需要额外的操作系统配置。

  3. P99 < 50ms 且 CPU 预算紧张:继续使用G1,调优而非替换。G1经过多年打磨,在50ms这个延迟档位上有最优的CPU效率。尝试降低-XX:MaxGCPauseMillis但不要低于20ms(否则目标过于激进,G1频繁触发并发标记反而降低效率)。

  4. 内存极度紧张(总内存 < 堆的120%):三思而后行。ZGC需要额外的头部空间(彩色指针阻止压缩,约多占用10-20%),Shenandoah每个对象多8字节。在内存受限的环境下,低延迟GC的内存代价可能得不偿失。

从G1迁移到ZGC的清单(5项):

  1. JDK升级:至少升级到JDK 17(ZGC单代)或JDK 21(ZGC分代,强烈推荐)。检查所有第三方库是否兼容目标JDK版本。

  2. 移除G1专用调优参数:-XX:MaxGCPauseMillis、-XX:G1* 系列参数在ZGC下无效且可能导致警告,需要全部移除。同时检查是否硬编码了-XX:+UseG1GC。

  3. 预留额外内存:将堆设置为比G1配置多15-20%(例如G1用16GB,ZGC设19GB)。监控物理内存中堆之外的部分(Metaspace、线程栈、直接内存、CodeCache等)。

  4. 检查依赖指针压缩的代码:某些JNI代码或Agent可能依赖对象的指针压缩行为,ZGC的限制可能与G1不同。检查第三方Agent(如APM探针)是否兼容ZGC。

  5. 渐进式灰度上线:先在预发环境运行至少一周,确认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 大场景:

  1. 金融交易系统:订单匹配引擎,P99 < 1ms 的刚性要求,堆32GB+。
  2. 实时广告竞价:每次展示须在50ms内返回出价,GC暂停不可感知。
  3. 高频数据处理:Kafka Streams / Flink实时流处理,堆16-64GB,分配速率高。
  4. 游戏服务器:帧同步逻辑,最大暂停 < 5ms(玩家可感知延迟阈值)。
  5. 异地多活数据库代理:SQL解析与路由,暂停导致超过50ms即触发故障转移。

ZGC 不适合的 2 大场景:

  1. 内存极度受限的微服务(堆 < 2GB):ZGC的内存额外开销在此场景下占比过高。
  2. 批处理任务(如MapReduce):吞吐量优先,暂停时间不敏感,Parallel GC更合适。

Shenandoah 最合适的 5 大场景:

  1. 大堆Web服务(16-128GB堆):长期运行的后端服务,P99 < 10ms 需求,Linux环境。
  2. 弹性伸缩的云原生应用:堆大小动态变化,Shenandoah的启发式自适应调整快。
  3. 分析型OLAP系统(如Presto/Trino Worker节点):大量短生命周期对象,分代回收效率高。
  4. 规则引擎(如Drools):规则库加载产生大量老年代对象,Shenandoah并发压缩可平滑处理。
  5. 对Red Hat生态有依赖的企业:Shenandoah由Red Hat主导,在RHEL/OpenShift上有最佳集成。

Shenandoah 不适合的 2 大场景:

  1. Windows部署环境:完全不支持。
  2. 要求极致低暂停(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的网络实战圣经

posted on 2026-10-04 14:33  一天不进步,就是退步  阅读(7)  评论(0)    收藏  举报