04. JVM 性能调优与 GC 优化

系统对比 G1/ZGC/Shenandoah 垃圾回收器,掌握 GC 日志分析、JVM 参数调优与内存问题排查方法

JVM 调优不是堆砌参数的艺术,而是在吞吐量、延迟与内存占用之间找到业务场景的最优平衡点。本文从垃圾回收器原理出发,结合实战案例,建立系统化的调优方法论。

1. 调优三元论

         吞吐量 (Throughput)
              △
             /|\
            / | \           三者不可兼得
           /  |  \          只能根据场景侧重优化
          /   |   \
         /────┼────\
  延迟  ←─────┼─────→ 内存占用
  (Latency)   |
场景优先级推荐回收器
批处理/大数据吞吐量 > 延迟G1 / ParallelGC
Web 服务/API延迟 < 100msG1 / ZGC
交易/支付系统延迟 < 10msZGC / Shenandoah
嵌入式/容器内存占用优先SerialGC / Epsilon

2. 垃圾回收器演进

JDK 1.3 ── Serial + Serial Old
    │
JDK 1.4 ── Parallel Scavenge + Parallel Old (吞吐量优先)
    │
JDK 5  ── CMS (低延迟,已废弃)
    │
JDK 7  ── G1 (分区回收)
    │
JDK 11 ── ZGC (亚毫秒级,<10ms)
    │
JDK 15 ── Shenandoah (红帽贡献,并发压缩)
    │
JDK 21 ── Generational ZGC, EA Liberica Prime (Continuous Concurrent Marking)

3. G1 GC 深度解析

3.1 设计思想:分区回收 (Region-Based)

┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ E   │ E   │ S   │ S   │ O   │ O   │ O   │ H   │
│     │     │     │     │     │     │     │ 1M  │  ← Humongous (大对象)
│ 1M  │ 1M  │ 1M  │ 1M  │ 1M  │ 1M  │ 1M  │     │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
 Eden   Eden  Survivor     Old            Humongous

-XX:G1HeapRegionSize=1m (默认根据堆大小自动计算)

3.2 G1 回收过程

Young GC(暂停所有线程,STW)
  → 并行复制 Eden + Survivor → 新 Survivor 或 Old
    
Mixed GC(并发标记 + 筛选回收):
  1. 初始标记 (Initial Mark)       — STW,极短
  2. 并发标记 (Concurrent Mark)   — 与用户线程并发
  3. 最终标记 (Remark)            — STW
  4. 筛选回收 (Cleanup)           — STW,只回收价值最高的 Region
  5. 并发清理 (Concurrent Cleanup) — 清理空 Region

Full GC(单线程 STW,避免):堆空间不足时触发,Serial Old 回退

3.3 G1 关键参数

# 目标最大暂停时间(默认 200ms)
-XX:MaxGCPauseMillis=200

# 每次 Mixed GC 回收老年代 Region 数上限
-XX:G1MixedGCCountTarget=8

# 触发全局并发标记的老年代占用阈值(默认 45%)
-XX:InitiatingHeapOccupancyPercent=45

# 回收停顿预测模型置信度
-XX:G1ConfidencePercent=50

# 避免大对象分配断裂
-XX:G1HeapWastePercent=5

4. ZGC:低延迟革命

4.1 设计目标

  • 亚毫秒级 STW(通常 < 1ms)
  • 堆大小无关性:10MB ~ 10TB 堆暂停时间相同
  • 并发整理:回收过程与用户线程并行

4.2 核心技术:染色指针 (Colored Pointers)

64 位指针结构(Linux x86_64,使用 4 位元数据):

63 ────────────────── 47 ── 43 ── 42 ── 41 ── 40 ── 39 ──── 0
│   未使用(17位)       │ Final │ Remap │ Mark1 │ Mark0 │  实际地址(42位)  │
│                     │ izable│       │       │       │              │
└─────────────────────┴───────┴───────┴───────┴───────┴──────────────┘
           ZGC 元数据位      →     影响指针的染色状态

三色标记:

  • Marked0 / Marked1:并发标记,交替使用
  • Remapped:已重映射到新地址
  • Finalizable:仅 Finalizer 对象可达

4.3 ZGC 回收周期

1. 暂停标记起始 (Pause Mark Start)        STW ~0.05ms
     └─ 根枚举(线程栈、寄存器、全局变量)
     
2. 并发标记 (Concurrent Mark)             并发
     └─ 遍历对象图,染色指针标记可达
     
3. 暂停标记结束 (Pause Mark End)           STW ~0.05ms
     └─ 处理弱引用、类卸载
     
4. 并发预备重分配 (Concurrent Prepare)      并发
     └─ 选择需要清理的 Region
     
5. 暂停重分配起始 (Pause Relocate Start)    STW ~0.05ms
     └─ 根枚举 + 设置转发表
     
6. 并发重分配 (Concurrent Relocate)         并发
     └─ 移动存活对象,更新引用(读屏障拦截)
     
7. 并发重映射 (Concurrent Remap)            并发
     └─ 清理旧地址引用,转移表回收

4.4 读屏障 (Load Barrier)

Object obj = field;  // 字节码: getfield

// 伪代码:CPU 自动插入,无感知
if (obj & bad_mask) {   // 指针染色位不正确
    obj = slow_path(obj); // 修复指针,可能触发重映射
}

性能代价:读屏障成本约 4% CPU 开销,但换来极低延迟。

4.5 ZGC 参数

# JDK 21+:分代 ZGC(推荐)
-XX:+UseZGC
-XX:+ZGenerational         # 分代模式(JDK 21 默认开启)

# 关键参数
-XX:ZCollectionInterval=5  # 强制 GC 间隔(seconds)
-XX:ZAllocationSpikeTolerance=2  # 分配速率容忍倍数
-XX:ZProactive=true        # 主动触发 GC

# JDK 15-20:非分代 ZGC
-XX:+UseZGC
-XX:MaxGCPauseMillis=<target>

5. Shenandoah

与 ZGC 类似的目标和设计,JDK 12 GA(Red Hat 开发)。

独特点:

  • 使用 Brooks Pointer(转发指针)而非染色指针
  • 对象头中增加一个 forwarding 指针字段
  • 读操作 O(1) 直接获取正确地址

适用场景:与 ZGC 几乎重叠,优先看 JDK 发行版支持(OpenJDK vs. Red Hat OpenJDK)。

6. GC 日志分析

6.1 JDK 9+ 统一日志格式

-Xlog:gc*:file=gc.log:time,uptime,pid,tid,level,tags:filecount=10,filesize=100m

6.2 日志解读:G1

[2023-01-15T10:23:45.123+0800][gc,start    ] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
[2023-01-15T10:23:45.124+0800][gc,task     ] GC(42) Using 8 workers of 8 for evacuation
[...]
[2023-01-15T10:23:45.145+0800][gc,heap     ] GC(42) Eden regions: 24->0(24)
[2023-01-15T10:23:45.145+0800][gc,heap     ] GC(42) Survivor regions: 3->4(4)
[2023-01-15T10:23:45.145+0800][gc,heap     ] GC(42) Old regions: 45->45
[2023-01-15T10:23:45.145+0800][gc,heap     ] GC(42) Humongous regions: 1->1
[2023-01-15T10:23:45.145+0800][gc,metaspace] GC(42) Metaspace: 128M->128M(256M)
[2023-01-15T10:23:45.145+0800][gc          ] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 24M->8M(512M) 21.023ms
                                       ─────────────────────────────────────────────────────────
                                       回收前   回收后 堆容量   暂停时间

6.3 分析工具

工具用法
gcviewerGUI 分析 GC 日志,生成吞吐/暂停图表
gceasy.io在线 GC 日志分析
jconsoleJMX 实时观察堆、GC、线程
jvisualvmVisual GC 插件查看各区内存曲线

7. 常见内存问题排查

7.1 OOM 诊断矩阵

异常信息原因排查
Java heap space堆内存不足-Xmx 设置太小 / 内存泄漏
GC overhead limit exceeded98% 时间花在 GC,回收 < 2%检查 Full GC 频率 / 对象生命周期
Metaspace类元数据区溢出动态生成类(CGLIB / Groovy)
Direct buffer memory堆外内存泄漏NIO ByteBuffer / Netty 未释放
Unable to create new native thread线程数超限-Xss 太大 / ulimit -u 限制

7.2 内存泄漏排查步骤

1. -XX:+HeapDumpOnOutOfMemoryError
   ↓
2. jmap -dump:format=b,file=heap.hprof <pid>
   ↓
3. MAT (Memory Analyzer Tool) / JProfiler / VisualVM
   ↓
4. 查找 Dominator Tree 中 Retained Heap 最大的对象
   ↓
5. 追踪 GC Roots,定位泄露引用链
   ↓
6. 分析代码:缓存未设置 TTL / 监听未移除 / ThreadLocal 未清理

7.3 平响抖动案例:Young GC 频繁

现象:Young GC 每 5s 触发一次,每次 50ms,平响飙高。

分析:

# gc.log 频繁出现 [gc,start] Pause Young
# Eden 区域分配速率高 → 对象生命周期极短

方案:

  1. 增大 Eden(-Xmn 或 -XX:G1NewSizePercent)
  2. 优化代码:避免短生命周期大对象
  3. 对象池化(Netty ByteBuf、线程本地缓存)

7.4 平响飙升案例:Full GC

现象:每小时 Full GC,每次 3s。

分析:

# [gc              ] GC(XX) Pause Full (System.gc())
# 或元空间 / 老年代晋升过多

方案:

  1. 排查 System.gc() 调用(-XX:+DisableExplicitGC 或使用 -XX:+ExplicitGCInvokesConcurrent)
  2. 增加堆大小,优化对象晋升阈值
  3. 检查大对象直接进入老年代(-XX:PretenureSizeThreshold)

8. 容器环境下的 JVM

# JDK 8u191+ / JDK 9+ 原生支持容器内存限制
-XX:+UseContainerSupport  # 默认开启(JDK 10+)

# 堆占容器内存的比例(默认 25%)
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0

# JDK 8u191 之前的版本:
# 设置堆大小,不要超过容器 limit(否则被 OOM Killer)
-Xmx512m -Xms512m

# Kubernetes 资源请求示例
resources:
  requests:
    memory: "1Gi"
    cpu: "500m"
  limits:
    memory: "2Gi"      # JVM 读取 cgroup limit
    cpu: "2000m"

cgroup v1 vs v2 JDK 限制读取:JDK 15+ 完整支持 cgroup v2。容器平台的 JVM 版本不得低于此基线。

8.4 容器环境中 GC 调优注意事项

容器中的 JVM 面临两个核心问题:内存受限和CPU 受限。在 Kubernetes 中,limits.memory 是 JVM 堆的上限,limits.cpu 会影响 GC 线程数。

G1 GC 的 ParallelGCThreads 自动计算:
  threads = max( (cpu_limit × 5/8), 1 )

例如:
  - CPU limit = 2 → threads = max(2 × 5/8, 1) = max(1.25, 1) = 1
  - CPU limit = 4 → threads = max(4 × 5/8, 1) = max(2.5, 1) = 2

如果容器 CPU limit 设置过低(如 1 核),G1 的并发标记只有 1 个线程,
GC 周期显著拉长,可能导致并发标记期间产生的新垃圾超过老年代容量,
触发 Full GC。

容器化 JVM 调优参数:

# 显式指定 ParallelGCThreads(避免自动计算值过低)
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2

# 启用 UseContainerSupport(JDK 10+ 默认开启)
-XX:+UseContainerSupport

# 设置堆占容器内存比例(默认 25%,建议调整到 75%)
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0

# 保留一部分容器内存给堆外内存(DirectBuffer、线程栈、Metaspace 等)
# 如果堆占了 75%,剩余 25% 应对并发请求、网络 I/O 和本地缓存

# 对于 CPU 密集但延迟不敏感的批处理:
-XX:+UseParallelGC
-XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=1000  # 放宽暂停时间,换取吞吐量

# 对于延迟敏感的微服务:
-XX:+UseZGC
-XX:MaxGCPauseMillis=10
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2

9. 现代 GC 演进:分代 ZGC 与 JDK 21

JDK 21(LTS)带来了 分代 ZGC(Generational ZGC),这是延迟敏感应用的最推荐方案。

9.1 什么是分代 ZGC

早期 ZGC(JDK 15-20)是不分代的,即不区分年轻代和老年代,所有对象在同一个堆中管理。这简化了实现,但存在一个问题:大部分对象朝生夕死,如果所有对象都需要经过完整的并发标记和重分配,年轻对象的回收效率较低。

分代 ZGC(JDK 21+,默认开启)将堆划分为年轻代(Young Generation)和老年代(Old Generation),分别采用不同的回收策略:

分代 ZGC 堆布局:

┌────────────────────┬─────────────────────────────────────┐
│   年轻代 (Young)    │           老年代 (Old)              │
├────────────────────┼─────────────────────────────────────┤
│ 对象存活时间短      │ 对象存活时间长                       │
│ GC 频率高           │ GC 频率低                            │
│ 标记-复制算法       │ 并发标记 + 重分配                    │
│ 暂停时间 ~0.1ms     │ 暂停时间 ~0.5ms                      │
└────────────────────┴─────────────────────────────────────┘

年轻代优势:
  - 大部分对象分配后很快死亡,年轻代 GC 只需处理少量存活对象
  - 标记-复制(Mark-Copy)算法只需复制存活对象到新区域
  - 比全堆 ZGC 的并发重分配更高效
  - Young GC 的典型频率:每 10-30 秒触发一次

老年代特点:
  - 老年代对象存活率高,不适合标记-复制
  - 保留 ZGC 经典的并发标记 + 染色指针 + 读屏障
  -  young GC 时也会顺带回收部分老年代垃圾(部分回收)

性能对比(JDK 21,相同负载):
  ┌─────────────────┬──────────────┬──────────────┐
  │     指标        │  非分代 ZGC   │  分代 ZGC     │
  ├─────────────────┼──────────────┼──────────────┤
  │ 平均延迟         │   ~1-3 ms    │   ~0.1 ms    │
  │ P99 延迟         │   ~5-10 ms   │   ~0.5 ms    │
  │ 吞吐量         │    95%       │    98%       │
  │ CPU 开销        │    ~15%      │    ~8%       │
  └─────────────────┴──────────────┴──────────────┘

9.2 启用分代 ZGC

# JDK 21+,分代 ZGC 默认识别并启用
java -XX:+UseZGC -jar myapp.jar

# 显式启用 / 禁用分代模式
java -XX:+UseZGC -XX:+ZGenerational -jar myapp.jar   # 启用(JDK 21 默认已开)
java -XX:+UseZGC -XX:-ZGenerational -jar myapp.jar   # 禁用(回退到非分代)

# 分代 ZGC 特有参数
-XX:ZYoungCompactionLimit=25    # 年轻代回收时最多复制 25% 的存活数据
-XX:ZCollectionInterval=5       # 两次 GC 之间最小间隔(秒)
-XX:ZAllocationSpikeTolerance=2 # 分配速率突增容忍倍数

分代 ZGC 的适用场景:

  • 金融交易系统(延迟 < 1ms)
  • 游戏服务器(帧率稳定,避免 GC 卡顿)
  • 微服务网关(P99 延迟要求严格)
  • 推荐系统实时推理(实时性要求)

10.1 按场景选择回收器速查表

业务场景推荐回收器JDK 版本关键参数预期效果
批处理/大数据量ParallelGCJDK 8+-XX:+UseParallelGC -XX:MaxGCPauseMillis=1000吞吐量最优
通用微服务G1JDK 8+-XX:+UseG1GC -XX:MaxGCPauseMillis=200均衡吞吐与延迟
延迟敏感 (<10ms)ZGC (分代)JDK 21+-XX:+UseZGC -XX:MaxGCPauseMillis=10P99 < 1ms
延迟敏感 (JDK 11-17)ZGC / ShenandoahJDK 15+-XX:+UseZGCP99 < 5ms
低内存/嵌入式SerialGC / EpsilonJDK 8+-XX:+UseSerialGC / -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC最小开销
容器环境G1 / ZGC + 容器感知JDK 10+-XX:+UseContainerSupport -XX:MaxRAMPercentage=75适应 cgroup 限制

10.2 常见问题速查卡

# 问题 1:服务启动后 OOM
排查:
  1. 检查 -Xmx 是否过小(jmap -heap <pid>)
  2. 检查堆外内存泄漏(DirectBuffer:jcmd <pid> VM.native_memory summary)
  3. 检查类加载泄漏(Metaspace:jstat -gcmetacapacity <pid>)

# 问题 2:GC 日志显示 Full GC 频繁
排查:
  1. 增大老年代(-Xmn 太小导致过早晋升)
  2. 检查 System.gc() 调用(-XX:+DisableExplicitGC)
  3. 检查大对象分配(-XX:PretenureSizeThreshold)

# 问题 3:ZGC 暂停时间偶尔 > 10ms
排查:
  1. 检查是否触发了 ZGC 的 Concurrent Relocate 阶段的 I/O 等待
  2. 检查是否内存分配速率突然飙升(jstat -gc -t <pid>)
  3. 考虑开启 -XX:ZAllocationSpikeTolerance 增大突发容忍度

# 问题 4:容器内 JVM OOM,但宿主机内存充足
原因:JDK 版本过旧,未开启容器感知(JDK < 10)
解决:升级 JDK 或显式指定 -Xmx(不要超过容器 limit)

11. 基础调优清单

# 1. 选择目标
-XX:MaxGCPauseMillis=50

# 2. 选择回收器
-XX:+UseZGC                # 延迟敏感
-XX:+UseG1GC               # 均衡
-XX:+UseParallelGC         # 吞吐量

# 3. 堆内存
-Xms4g -Xmx4g              # 固定堆,避免动态调整开销
-Xmn1g                     # 年轻代

# 4. GC 日志
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=10,filesize=100m

# 5. OOM 诊断
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/

# 6. 调试(仅开发环境)
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintFlagsFinal       # 输出所有 JVM 参数默认值

延伸阅读

12. 对象分配策略补充

理解对象在 JVM 中的分配路径有助于精准诊断内存问题。对象首先尝试在栈上分配(通过逃逸分析),如果对象不逃逸出方法则在栈帧中分配,随方法返回自动销毁。无法栈分配的对象进入 TLAB(线程本地分配缓冲区),TLAB 是 Eden 中为每个线程预留的私有区域,避免多线程竞争全局指针。当 TLAB 满或大对象超过阈值时,直接在 Eden 或老年代分配。

对于 G1 回收器,对象晋升不再依赖固定年龄阈值,而是采用动态年龄判定:当 Survivor 区中同龄对象总大小超过 Survivor 空间一半时,大于等于该年龄的对象直接晋升老年代。这种策略比固定阈值更适应实际存活分布。

生产环境常见陷阱是 ThreadLocal 未清理导致老年代泄漏,尤其在使用线程池时:线程不会退出,ThreadLocalMap 中的 Entry 一直存在,强引用阻碍 GC。解决方案是在 finally 块中调用 remove(),或使用线程池的 hook 机制定期清理。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java-enterprise」更多文章

  1. JPMS 模块化:module-info 与 JLink 精简运行时
  2. CDC 数据同步:Debezium 与 Kafka 架构实战
  3. 可观测性工程:Micrometer 指标模型与 OTLP 导出