Java 基础体系 · 第 75/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

JVM ZGC 深入:并发回收、染色指针、分代模式和容量规划

ZGC 是 HotSpot 中面向大堆和低停顿场景的垃圾收集器。它的核心目标不是“完全没有停顿”,而是把绝大多数工作放到 Java 线程(mutator)运行期间并发完成,并将必须暂停 Java 线程的阶段压缩到很短的根处理和元数据切换操作。

需要先区分三个层次:

  1. JVMS 规范保证:Java 程序只能观察到符合 Java 内存模型和对象语义的结果;规范并不规定必须使用 ZGC,也不规定一次 GC 的具体阶段或停顿时间。
  2. HotSpot 的 ZGC 实现:包括并发标记、并发重定位、染色指针、读屏障、分代堆、记忆集等。
  3. 工程配置与经验:例如 -Xmx 如何设置、如何观察分配速率、如何判断堆是否过小。这些不是语言规范保证,而是部署和测量问题。

一、先建立 ZGC 要解决的问题

垃圾收集器通常需要完成三个逻辑任务:

  • 找出仍然可达的对象;
  • 回收不可达对象占用的空间;
  • 必要时移动存活对象,消除碎片并提高空间利用率。

如果这些任务都在 Stop-The-World(STW)停顿中完成,堆越大,扫描对象和移动对象所需的时间通常越长。ZGC 的设计是把这三个任务拆成多个阶段:

  • 标记主要并发完成;
  • 重定位主要并发完成;
  • 只在根处理、阶段切换等位置进行短暂停顿;
  • 通过屏障保证 Java 线程在 GC 并发修改对象关系时仍能看到正确对象。

这里的“并发”是指 GC 线程与 Java 线程同时执行,而不是所有工作都没有同步点。GC 仍然需要暂停 Java 线程执行某些瞬时操作,也可能因为分配压力、线程调度、操作系统或锁竞争而出现额外停顿。


二、ZGC 的堆、区域和对象生命周期

2.1 ZGC 使用区域而不是整堆连续压缩

ZGC 将 Java 堆划分为若干管理区域,HotSpot 源码和日志中通常称为 ZPage。不同大小的对象使用不同规格的页面:

  • 小对象放入小页面;
  • 中等对象放入中等页面;
  • 很大的对象可能使用连续页面。

这种组织方式使 ZGC 可以以页面为单位选择回收集合,而不必每次对整堆做全量压缩。

对象的生命周期可以抽象为:

分配到空闲页面
    │
    ▼
对象被应用引用,成为存活对象
    │
    ├── 标记周期发现仍然可达 ──► 保留或复制到新页面
    │
    └── 标记周期发现不可达 ───► 页面空间可回收

当页面中只有部分对象存活时,ZGC 可以将存活对象复制到新位置,再释放原页面。这个过程就是重定位(relocation)。它同时解决两个问题:

  1. 回收不可达对象;
  2. 把存活对象集中到新的页面,降低碎片。

2.2 ZGC 不要求所有对象都立即更新引用

传统压缩式收集器常常需要在移动对象后更新大量引用。ZGC 不采用“移动一个对象后立即扫描整堆并修复所有指针”的方式,而是允许一段时间内存在指向旧地址的引用。

假设对象 A 引用了对象 B

A.field ─────► B@旧地址

GC 将 B 移动到新地址:

B@旧地址 ──转发记录──► B@新地址
A.field ─────► B@旧地址

当 Java 线程再次读取 A.field 时,ZGC 的读屏障会发现该引用仍指向旧地址,通过转发表解析到新地址,并把修复后的引用返回给 Java 线程。这个过程通常被称为:

  • remap:重映射;
  • self-healing:自愈,即在引用被读取时顺便修复它。

因此,重定位可以和 Java 线程并发进行。


三、染色指针:把 GC 状态编码进引用

3.1 什么是染色指针

染色指针(colored pointer)是 ZGC 的关键机制。它不是给对象额外增加一个 Java 字段,而是利用 64 位对象引用中可以用于编码元数据的位,将 GC 状态附加到引用本身。

一个简化的引用可以表示为:

| GC 元数据位 | 对象地址位 |

实际位布局、可用地址范围和平台细节由 HotSpot 实现决定,不能把下面的示意图当作 JVMS 规定:

普通引用:   [地址]
染色引用:   [标记状态][重定位状态][地址]

染色指针可以表达类似以下状态:

  • 对象在当前标记周期中是否被标记;
  • 对象是否处于另一种标记视图;
  • 引用是否需要重映射;
  • 某些实现状态,例如与终结相关的状态。

不同 JDK 和平台上的具体位定义可能变化,因此应用代码不能通过位运算自行解释 Java 引用。

3.2 为什么元数据要放在引用中

如果 GC 状态保存在每个对象的额外字段中,就需要频繁访问对象头或对象本体。把状态放进引用后,GC 可以在处理引用时直接判断状态:

读取引用
  │
  ▼
检查引用中的 GC 状态位
  │
  ├── 状态正确 ─────► 直接返回对象地址
  │
  ├── 需要标记 ─────► 执行标记相关处理后返回
  │
  └── 需要重映射 ───► 查询转发表并返回新地址

这使得 ZGC 能够在不停止所有 Java 线程的情况下协调标记和重定位。

3.3 染色指针必须配合屏障使用

染色指针本身不能自动修复程序行为。因为 Java 线程执行的是普通字节码和机器指令,所以 HotSpot 必须在适当位置插入屏障。

ZGC 的核心是读屏障(load barrier)

Object x = holder.field;

在机器层面,读取 holder.field 后并不一定直接把结果交给后续代码,而是先经过与 GC 状态相关的检查:

读取引用
  → 检查染色状态
  → 必要时标记或重映射
  → 返回对 Java 线程有效的引用

读屏障的直接代价是引用读取路径增加了额外工作。JIT 编译器会尽可能优化屏障,但不能假设屏障永远为零成本。

在分代 ZGC 中,还需要处理跨代引用。因此写入引用时也需要维护代际关系,典型形式是写屏障(store barrier)

oldObject.field = youngObject;

这次写入可能需要记录“老年代对象指向年轻代对象”的关系,供后续年轻代收集时快速找到年轻代存活对象。


四、ZGC 的并发回收流程

下面先描述不依赖具体日志措辞的逻辑流程。实际日志中的阶段名称会随着 JDK 版本和分代实现有所变化。

4.1 初始标记:短暂停顿建立根集合

GC 首先需要处理 GC Roots。根包括但不限于:

  • Java 线程栈中的引用;
  • 静态字段;
  • JNI 句柄;
  • 类加载器相关结构;
  • JVM 内部保存的对象引用。

这一步不能简单地让 Java 线程同时修改所有根,因此通常会有一个短暂的初始标记停顿:

暂停 Java 线程
    │
    ├── 扫描线程栈和其他根
    ├── 设置本轮标记状态
    └── 记录并发标记的起点
恢复 Java 线程

初始标记的时间主要与根集合规模相关,而不是简单等于堆大小。拥有大量线程、很深的栈、很多 JNI 引用的进程,即使堆不大,也可能在根处理上付出更多时间。

4.2 并发标记:沿对象图遍历可达对象

从根开始,GC 遍历对象图。若对象 A 引用了 B,且 A 已经可达,那么 B 也应被访问:

GC Root
   │
   ▼
   A ───► B ───► C

并发标记期间,Java 线程仍然可以修改对象图。例如 Java 线程可能执行:

a.child = null;
d.child = b;

因此 GC 必须处理“标记线程与 mutator 同时修改引用”的情况。屏障和标记协议保证某些在并发过程中产生的引用不会被错误遗漏。

并发标记的工作量通常与存活对象图及其引用关系有关,而不只是与 -Xmx 有关。一个拥有 30 GB 空闲空间、但存活对象和引用关系很少的堆,不一定比一个 8 GB、存活对象复杂且引用密集的堆更难标记。

4.3 最终标记:完成本轮标记

并发标记结束时,GC 需要确认所有待处理的标记工作已经完成,并处理并发阶段留下的边界情况。这通常伴随另一个短暂停顿。

逻辑上可以表示为:

并发标记工作队列
  │
  ├── 仍有待处理对象 ─► 继续并发处理
  │
  └── 队列完成 ───────► 进入最终标记
                            │
                            ├── 处理剩余更新
                            └── 确认本轮存活集合

最终标记完成后,GC 已经知道哪些对象可以保留,哪些页面适合加入回收集合。

4.4 并发准备重定位:决定回收集合

GC 根据页面中的存活情况和当前分配压力选择回收集合。一个页面可能有三种典型状态:

页面 P1:几乎全是垃圾  ─► 直接释放更划算
页面 P2:一半存活      ─► 移动存活对象后释放
页面 P3:几乎全存活    ─► 暂不选择,避免复制成本

选择集合时不仅考虑垃圾比例,还要考虑:

  • 当前空闲空间;
  • 预计分配速率;
  • 重定位带宽;
  • 页面大小;
  • 分代回收策略;
  • 大对象和连续页面约束。

“垃圾最多的页面一定先回收”并不是完整规则,因为复制存活对象本身也需要 CPU 和内存带宽。

4.5 并发重定位:复制对象并建立转发表

重定位阶段将回收集合中的存活对象复制到新位置,并建立旧地址到新地址的转发关系:

旧页面
┌──────────────┐
│ A │ 垃圾 │ B │
└──────────────┘
      │
      ├── A 复制到新页面
      ├── B 复制到新页面
      └── 垃圾空间不复制

如果 Java 线程在复制前后读取某个旧引用,读屏障会根据转发表返回正确的新引用。不同线程可能同时尝试复制同一对象,ZGC 必须保证最终只有一个有效目标,并确保对象内容和引用关系符合 Java 语义。

重定位并不意味着所有旧引用会立刻被物理改写。部分引用会在后续被读取时自愈,这也是 ZGC 能够并发重定位的关键。

4.6 为什么仍可能出现长停顿

并发算法不等于停顿绝对有上限。以下情况可能使应用感受到较长停顿或分配阻塞:

  1. GC 线程抢不到 CPU:容器 CPU 限额过低或系统负载过高。
  2. 分配速率超过回收速度:空闲页面耗尽,Java 线程只能等待 GC 释放空间。
  3. 根集合过大:线程数量、JNI 引用或复杂运行时结构使根处理变慢。
  4. 系统内存压力:分页、NUMA、透明大页或内存回收造成额外延迟。
  5. 大对象分配:连续页面不足时,碎片和页面分配会放大压力。
  6. JIT、类加载和其他安全点操作:一次观测到的停顿不一定全由 ZGC 回收造成。

因此,ZGC 的低停顿能力必须与足够的 CPU、内存余量和合理的分配压力共同成立。


五、分代模式:为什么 ZGC 要区分年轻代和老年代

5.1 分代假说

分代 GC 建立在一个经验性假说上:

  • 大量新对象很快死亡;
  • 存活时间长的对象更可能继续存活。

假设应用持续分配对象:

时间 t0:新对象大量进入年轻代
时间 t1:大部分临时对象失效,少量对象存活
时间 t2:存活对象进入老年代

如果每次回收都扫描整个堆,刚刚创建的大量短命对象会反复参与全堆级别的处理。分代模式可以更频繁地处理年轻代,把大部分短命对象更快回收,同时降低老年代收集频率。

Java 25 中,ZGC 的分代模式是正常使用的模式;非分代模式在近期 JDK 中已被弃用或处于退出路径,具体命令行兼容性应以对应 JDK 25 构建版本的启动输出和发行说明为准。新部署不应把禁用分代模式作为默认方案。

5.2 年轻代和老年代的基本数据流

可以把对象流转简化为:

flowchart LR
    A[应用分配] --> Y[年轻代]
    Y -->|对象死亡| R[回收]
    Y -->|多次存活或晋升| O[老年代]
    O -->|老年代收集仍存活| O
    O -->|不可达| R
    O -->|引用年轻代| RS[记忆集/跨代记录]
    RS --> Y

关键路径是:

  1. 对象首先在年轻代分配;
  2. 年轻代收集识别其中的存活对象;
  3. 存活时间足够长或满足实现条件的对象晋升到老年代;
  4. 老年代对象指向年轻代对象时,写屏障记录跨代引用;
  5. 年轻代收集通过根、线程栈和跨代记录找到年轻代存活对象。

如果没有跨代记录,年轻代收集就必须扫描整个老年代来查找指向年轻代的引用,这会破坏分代带来的效率优势。

5.3 分代并不等于固定大小的两个物理堆

“年轻代”和“老年代”是 GC 的逻辑代际概念,不应简单理解为用户可以像某些收集器一样精确设置两个固定物理区间。

ZGC 仍然根据分配、存活、回收和系统压力动态管理页面。代际边界、晋升、集合选择以及页面使用受实现策略控制。应用不应假设:

  • 年轻代一定占 Xmx 的某个固定百分比;
  • 每次年轻代收集都只扫描年轻代;
  • 每个对象经过固定次数收集就必然晋升;
  • 老年代永远不会参与年轻代收集。

准确行为应通过 Java 25 的 GC 日志、JFR 和运行时指标验证,而不能仅凭其他收集器的经验推断。

5.4 分代模式增加了什么成本

分代模式降低了许多无效扫描,但也增加了维护代际关系的成本:

  • 引用写入需要额外屏障;
  • 需要维护记忆集或类似跨代记录;
  • 晋升和老年代收集增加复制、扫描与元数据工作;
  • 高频修改老年代对象的应用可能产生大量跨代更新。

例如,一个缓存对象长期存活,但其字段不断替换为新创建的请求对象:

class Cache {
    Object current;
}

Cache cache = ...;
cache.current = new RequestState();

cache 可能已经在老年代,而 RequestState 在年轻代。每次写入都可能需要记录这条跨代边。若这类写操作极其频繁,年轻代收集的记忆集处理成本会明显增加。

5.5 分代模式的适用边界

分代通常适合以下分配行为:

  • 请求对象、临时集合和短生命周期缓冲区很多;
  • 老对象相对稳定;
  • 存活对象比例随年龄明显下降。

但如果应用大多数对象都长期存活,或者老年代对象持续大量修改,分代收益可能减少。它仍可能改善回收调度,但不会凭空减少存活对象扫描、对象复制和引用更新的成本。


六、容量规划:堆大并不等于一定安全

6.1 规划的核心变量

容量规划至少需要区分以下变量:

  • L:稳定运行时的存活集(live set),即 GC 完成后仍被引用的对象大小;
  • A:应用分配速率,单位通常为 GB/s;
  • C:一次关键并发回收周期的耗时;
  • R:额外安全余量,用于应对分配尖峰、统计误差、晋升和碎片;
  • H:可供分配和回收使用的堆空间。

在最简模型中,GC 周期期间新分配的对象量为:

D=A×CD = A \times C

如果 GC 周期开始时存活集约为 L,那么至少需要容纳:

XminL+D+RX_{\min} \ge L + D + R

这里的直觉是:旧的存活对象不能被当作空闲空间,新周期期间应用仍会继续分配,而 GC 还需要一段安全余量完成复制和应对波动。

这个公式不是 ZGC 的实现公式,也不是 JVM 规范保证,而是用于建立容量估算的下界模型。

6.2 完整算例

假设测得:

  • 稳态存活集 L = 10 GB
  • 应用分配速率 A = 2 GB/s
  • 关键回收周期 P99 为 C = 1.8 s
  • 额外安全余量取 R = 3 GB

周期期间的分配量:

D=2×1.8=3.6 GBD = 2 \times 1.8 = 3.6\text{ GB}

因此:

Xmin10+3.6+3=16.6 GBX_{\min} \ge 10 + 3.6 + 3 = 16.6\text{ GB}

工程上不能把 -Xmx 精确设置为 16.6 GB 就认为足够。可以先选择约 20 GB 的堆上限,再用压测验证:

java \
  -XX:+UseZGC \
  -Xms20g \
  -Xmx20g \
  -Xlog:gc*,safepoint=info:file=gc.log:time,uptime,level,tags \
  -XX:StartFlightRecording=filename=app.jfr,duration=10m \
  -jar app.jar

这个命令的含义是:

  • -XX:+UseZGC:选择 ZGC;
  • -Xms20g:初始堆大小为 20 GB;
  • -Xmx20g:最大堆大小为 20 GB;
  • -Xlog:gc*...:记录 GC 相关日志;
  • safepoint=info:同时记录安全点信息,便于区分 GC 停顿和其他安全点停顿;
  • -XX:StartFlightRecording=...:启动 JFR 记录,结束后用 JDK Mission Control 分析。

固定 -Xms-Xmx 便于压测时减少堆动态扩张带来的变量,但生产环境是否固定,应结合容器内存、启动峰值和扩容策略决定。

6.3 必须使用尾延迟,而不是平均值

如果平均回收周期为 1 秒,但 P99 为 4 秒,且分配速率仍为 2 GB/s,则仅按平均值估算:

Davg=2×1=2 GBD_{\text{avg}} = 2 \times 1 = 2\text{ GB}

按 P99 估算:

Dp99=2×4=8 GBD_{\text{p99}} = 2 \times 4 = 8\text{ GB}

两者相差 6 GB。生产容量应至少关注高分位回收周期和分配速率,否则平均数据会掩盖偶发的“分配跑赢回收”问题。

6.4 分配速率为什么比“堆使用率”更重要

考虑两个应用:

应用 A:存活集 8 GB,分配速率 0.1 GB/s
应用 B:存活集 8 GB,分配速率 5 GB/s

两者当前堆使用量可能相同,但 B 对并发回收吞吐和空闲空间的要求远高于 A。若回收周期需要 2 秒:

A 在周期中新增约 0.2 GB
B 在周期中新增约 10 GB

因此容量规划不能只看某一时刻的堆占用百分比,还要观察:

  • 分配速率;
  • 回收周期耗时;
  • 回收期间的分配量;
  • 存活集大小;
  • 晋升速率;
  • 分配失败或等待事件。

七、-Xmx 不是进程可以使用的全部内存

容器或虚拟机的内存上限必须覆盖至少这些部分:

进程内存
├── Java 堆
├── Metaspace
├── Code Cache
├── Java 线程栈
├── GC 元数据和记忆集
├── JNI/native 分配
├── DirectByteBuffer
├── 类库和运行时本地结构
└── 线程、动态链接库及其他开销

例如容器限制为 24 GB,并不意味着可以安全设置 -Xmx24g。这样会把堆之外的内存挤到几乎没有空间,最终可能出现:

  • 容器被 OOM Killer 终止;
  • Native Memory Out Of Memory;
  • Direct buffer 分配失败;
  • 线程创建失败;
  • 操作系统回收或分页导致延迟突增。

可以使用 Native Memory Tracking 辅助观察 JVM 本地内存:

java \
  -XX:NativeMemoryTracking=summary \
  -XX:+UseZGC \
  -Xmx16g \
  -jar app.jar

运行期间:

jcmd <pid> VM.native_memory summary
jcmd <pid> GC.heap_info

前提是进程启动时启用了 NMT;VM.native_memory 不能事后补开。GC.heap_info 的输出字段属于 HotSpot 诊断信息,不是 JVMS 标准接口,不能把字段名称当作跨实现稳定 API。


八、ZGC 的关键配置如何理解

8.1 选择收集器

Java 25 中常见的启动方式是:

java -XX:+UseZGC -Xms16g -Xmx16g -jar app.jar

如果没有显式指定 -XX:+UseZGC,JVM 会根据默认策略选择收集器。不要因为堆较大就假设 JVM 自动使用 ZGC。

8.2 分代开关的版本敏感性

早期 JDK 中,分代 ZGC 曾需要显式启用,例如:

-XX:+UseZGC -XX:+ZGenerational

但在 Java 25 范围内,分代模式已经是 ZGC 的正常默认方向,旧教程中要求额外添加 -XX:+ZGenerational 的命令不一定仍然必要。相反,试图使用:

-XX:-ZGenerational

进入非分代模式,可能产生弃用警告,未来版本还可能不再接受。迁移旧脚本时应先用目标 JDK 验证:

java -XX:+UseZGC -XX:+PrintCommandLineFlags -version

这里的输出只能帮助确认当前实现采用了哪些默认标志,不能替代目标 JDK 的发行说明。

8.3 -Xms-Xmx

  • -Xmx 限制 Java 堆的最大大小;
  • -Xms 设置初始堆大小。

-Xmx 太小,最直接的后果是 GC 频率增加、分配失败等待增多,甚至抛出 OutOfMemoryError: Java heap space-Xmx 太大也不是免费资源:更大的地址空间和堆元数据、更多存活对象扫描,以及容器剩余内存不足,都可能造成问题。

8.4 Soft Max 不是硬上限

ZGC 支持软堆上限概念,用于在内存压力较低时限制堆增长,但硬上限仍由 -Xmx 决定。软上限适合希望 JVM 在通常情况下保持较小内存占用、但允许短时扩张的场景。

软上限不是“保证永远不超过该值”,也不是“超过后立即回收全部对象”。收集器仍必须遵守存活集、分配速率和回收吞吐的现实约束。

8.5 不要把延迟目标当成硬保证

ZGC 的设计目标是低停顿,但以下说法都不严谨:

“ZGC 保证停顿不超过 10 ms”
“使用 ZGC 后不会发生 STW”
“堆越大,延迟越低”

JVM 规范没有给出这类保证。真实停顿由根数量、CPU、内存带宽、分配速率、对象图、系统负载和实现版本共同决定。


九、如何验证一个 ZGC 配置是否真的有效

9.1 先看 GC 日志,而不是只看业务吞吐

启动时记录 GC 和安全点:

java \
  -XX:+UseZGC \
  -Xms16g \
  -Xmx16g \
  -Xlog:gc*,safepoint=info:file=gc.log:time,uptime,level,tags \
  -jar app.jar

日志中重点关注:

  • GC 周期是否频繁重叠或连续触发;
  • 并发阶段耗时是否逐渐增加;
  • 停顿阶段是否稳定;
  • 堆是否接近上限;
  • 是否出现分配等待、回收跟不上或内存不足;
  • 安全点停顿是否来自 GC 之外。

不能只看“单次停顿时间”。如果每次停顿都很短,但 GC 周期一直追不上分配,应用仍会在后续遇到分配阻塞。

9.2 用 JFR 和 JMC 对齐时间线

可以用 JFR 记录一段可复现的压测:

java \
  -XX:+UseZGC \
  -Xms16g \
  -Xmx16g \
  -XX:StartFlightRecording=filename=zgc.jfr,duration=15m,settings=profile \
  -jar app.jar

然后用 JDK Mission Control 打开 zgc.jfr,对齐观察:

  • GC 周期和 GC 阶段;
  • Java 线程 CPU 使用率;
  • 分配速率;
  • 对象分配热点;
  • 线程停顿;
  • CPU 抢占和系统负载;
  • 堆使用变化。

JFR 是诊断记录机制,不会自动告诉你“应该把 -Xmx 设置为多少”。它提供的是形成容量模型所需的观测数据。

9.3 用分配速率反推安全余量

压测时可按固定窗口统计:

窗口长度:60 秒
窗口起始存活集:10.0 GB
窗口结束存活集:10.2 GB
窗口总分配量:120 GB

虽然存活集只增加 0.2 GB,但平均分配速率为:

A=12060=2 GB/sA = \frac{120}{60} = 2\text{ GB/s}

这说明应用制造了大量短命对象。此时应重点检查年轻代回收周期和分配吞吐,而不是只依据 10.2 GB 的当前存活集判断堆大小。


十、典型故障路径与诊断方法

10.1 堆接近上限:回收尚未完成,分配先耗尽

故障因果链通常是:

分配速率上升
  → 空闲页面下降
  → GC 周期变频繁
  → GC 线程占用 CPU 上升
  → 并发回收速度下降
  → 分配线程等待或堆耗尽

表现可能包括:

  • GC 日志中周期密集出现;
  • 应用吞吐下降;
  • 延迟在分配峰值期间升高;
  • 堆占用长期接近 Xmx
  • 最终出现 OutOfMemoryError

增加 -Xmx 只有在物理内存确实允许、且根因是回收周期需要更多空间时才有效。如果根因是对象泄漏、缓存无界增长或分配速率异常,单纯扩大堆只是延迟故障。

10.2 “已回收很多,但堆仍然很满”

这通常不是矛盾。GC 回收的是不可达对象;如果存活集本身很大,回收后仍可能占据大部分堆。

应区分:

堆已提交/已使用
存活对象大小
本周期回收的垃圾大小
进程总内存

可以用类直方图和 JFR 分配信息寻找异常增长的对象类型:

jcmd <pid> GC.class_histogram

该命令可能触发额外开销,并且输出属于诊断结果,不适合在高延迟生产请求路径中频繁执行。大型堆上的全量类直方图应安排在可接受的诊断窗口。

10.3 ZGC 停顿很短,但接口延迟仍很高

这通常说明接口延迟并非由 STW GC 单独决定。还需要检查:

  • Java 线程是否因 CPU 配额被限速;
  • 锁竞争和线程池排队;
  • 网络和磁盘 I/O;
  • JIT 编译;
  • 安全点到达延迟;
  • 页错误和 NUMA 访问;
  • 业务本身的尾延迟。

因此,不能用“GC pause 很短”直接推出“应用 P99 一定很低”。

10.4 堆外内存耗尽

如果 GC 日志正常,但进程被杀或出现本地内存分配失败,应检查:

  • DirectByteBuffer
  • JNI 库;
  • 线程栈;
  • Metaspace;
  • Code Cache;
  • 内存映射文件;
  • 容器 RSS 与 Java 堆的差值。

这种故障增加 -Xmx 可能反而加速失败,因为堆会进一步挤压堆外空间。


十一、常见误解和反例

11.1 误解:ZGC 不需要暂停

反例是根扫描。只要某些根集合处理仍需要建立一致视图,就可能存在短暂停顿。正确说法是:ZGC 将主要回收工作并发化,并努力降低暂停时间,而不是消除所有暂停。

11.2 误解:染色指针就是压缩指针

染色指针和压缩普通对象指针不是同一个概念。

  • 压缩对象指针主要是用较小位宽表示堆内地址,减少引用大小;
  • 染色指针是在引用中编码 GC 元数据,使屏障可以识别标记和重定位状态。

两者都与指针表示有关,但解决的问题不同,不能互相替代。

11.3 误解:分代后老年代完全不参与年轻代收集

如果老年代对象引用年轻代对象,GC 必须通过记忆集或跨代记录找到这些引用。老年代虽然不一定被完整扫描,但它在逻辑上仍参与年轻代存活性判断。

11.4 误解:把 -Xmx 设成机器内存就是容量最大化

反例是 32 GB 机器上配置 -Xmx32g,同时应用有数百个线程、较大的直接内存和本地库。Java 堆可能尚未达到上限,进程就已经因为容器或操作系统内存压力被终止。

11.5 误解:堆越大,GC 一定越快

更大的堆提供了更多回收缓冲空间,但也可能意味着:

  • 存活对象更多;
  • 并发标记工作量更大;
  • 转发表、记忆集等元数据更多;
  • 工作集超出 CPU 缓存和内存带宽能力;
  • 操作系统内存压力更高。

堆大小应满足回收周期和分配速率的约束,而不是无限增大。

11.6 误解:对象移动后所有引用立即变成新地址

ZGC 可以在重定位后保留旧引用,并在读取时通过屏障完成重映射和自愈。应用只能通过 Java 语言和标准 API 观察对象身份与语义,不能依赖对象地址稳定,也不能通过非标准手段绕过屏障。


十二、容量规划的实际步骤

可以按以下顺序建立可验证的模型:

  1. 确定业务负载:包括正常流量、批处理、缓存预热和突发流量。

  2. 测量存活集:观察多次 GC 完成后的低谷或稳定区间,而不是取某个瞬时堆使用量。

  3. 测量分配速率:分别统计平均值、P95 和 P99。

  4. 测量回收周期:关注与业务最差延迟窗口对应的高分位周期耗时。

  5. 计算回收期间分配量

    D=Atail×CtailD = A_{\text{tail}} \times C_{\text{tail}}

  6. 加入安全余量:覆盖分配尖峰、晋升、记忆集、碎片、大对象和测量误差。

  7. 从容器内存反推堆上限:先预留堆外和系统空间,再决定 -Xmx

  8. 压测验证:确认堆没有持续逼近上限,GC 周期能跟上分配,业务尾延迟可接受。

  9. 故障演练:测试缓存增长、流量突增、CPU 降额和直接内存增长时的表现。

最终应同时满足两个条件:

可用堆空间>存活集+回收周期内分配量+安全余量\text{可用堆空间} > \text{存活集} + \text{回收周期内分配量} + \text{安全余量}

以及:

GC 并发吞吐应用分配带来的回收需求\text{GC 并发吞吐} \ge \text{应用分配带来的回收需求}

第一个条件解决“有没有足够空间等 GC 完成”,第二个条件解决“GC 是否能长期跟上应用”。只满足第一个而不满足第二个,堆最终仍会被耗尽。


十三、如何判断 ZGC 是否适合当前应用

ZGC 更适合以下约束同时存在的场景:

  • 堆较大,传统收集器的停顿不可接受;
  • 应用有较高的尾延迟要求;
  • 能够提供足够 CPU 让 GC 并发运行;
  • 能够测量并控制对象分配速率;
  • 运维系统可以采集 GC 日志、JFR 和进程内存数据。

如果应用堆很小、对象分配极低、停顿要求宽松,ZGC 的屏障和并发线程开销未必能带来实际收益。选择收集器不应只依据“低延迟”标签,而应根据存活集、分配速率、CPU 余量、内存预算和业务尾延迟进行压测比较。

ZGC 的完整工作机制可以归纳为:

染色指针携带 GC 状态
        +
读屏障处理标记与重定位
        +
并发标记和并发重定位
        +
分代模式利用对象年龄和跨代记录
        +
按存活集、分配速率和周期耗时规划容量

其中任何一环失配,都可能让“并发回收、低停顿”退化为高 CPU 消耗、频繁 GC、分配等待或进程级内存故障。真正可靠的 ZGC 配置,必须由机制理解、运行数据和故障验证共同决定。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。