JVM 调优不是堆砌参数的艺术,而是在吞吐量、延迟与内存占用之间找到业务场景的最优平衡点。本文从垃圾回收器原理出发,结合实战案例,建立系统化的调优方法论。
1. 调优三元论
吞吐量 (Throughput)
△
/|\
/ | \ 三者不可兼得
/ | \ 只能根据场景侧重优化
/ | \
/────┼────\
延迟 ←─────┼─────→ 内存占用
(Latency) |
| 场景 | 优先级 | 推荐回收器 |
|---|---|---|
| 批处理/大数据 | 吞吐量 > 延迟 | G1 / ParallelGC |
| Web 服务/API | 延迟 < 100ms | G1 / ZGC |
| 交易/支付系统 | 延迟 < 10ms | ZGC / 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 分析工具
| 工具 | 用法 |
|---|---|
gcviewer | GUI 分析 GC 日志,生成吞吐/暂停图表 |
gceasy.io | 在线 GC 日志分析 |
jconsole | JMX 实时观察堆、GC、线程 |
jvisualvm | Visual GC 插件查看各区内存曲线 |
7. 常见内存问题排查
7.1 OOM 诊断矩阵
| 异常信息 | 原因 | 排查 |
|---|---|---|
Java heap space | 堆内存不足 | -Xmx 设置太小 / 内存泄漏 |
GC overhead limit exceeded | 98% 时间花在 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 区域分配速率高 → 对象生命周期极短
方案:
- 增大 Eden(
-Xmn或-XX:G1NewSizePercent) - 优化代码:避免短生命周期大对象
- 对象池化(Netty ByteBuf、线程本地缓存)
7.4 平响飙升案例:Full GC
现象:每小时 Full GC,每次 3s。
分析:
# [gc ] GC(XX) Pause Full (System.gc())
# 或元空间 / 老年代晋升过多
方案:
- 排查
System.gc()调用(-XX:+DisableExplicitGC 或使用 -XX:+ExplicitGCInvokesConcurrent) - 增加堆大小,优化对象晋升阈值
- 检查大对象直接进入老年代(
-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 版本 | 关键参数 | 预期效果 |
|---|---|---|---|---|
| 批处理/大数据量 | ParallelGC | JDK 8+ | -XX:+UseParallelGC -XX:MaxGCPauseMillis=1000 | 吞吐量最优 |
| 通用微服务 | G1 | JDK 8+ | -XX:+UseG1GC -XX:MaxGCPauseMillis=200 | 均衡吞吐与延迟 |
| 延迟敏感 (<10ms) | ZGC (分代) | JDK 21+ | -XX:+UseZGC -XX:MaxGCPauseMillis=10 | P99 < 1ms |
| 延迟敏感 (JDK 11-17) | ZGC / Shenandoah | JDK 15+ | -XX:+UseZGC | P99 < 5ms |
| 低内存/嵌入式 | SerialGC / Epsilon | JDK 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 参数默认值
延伸阅读
- JVM 内存模型与类加载机制 — JVM 内存区域与字节码执行
- Java 高并发编程精要 — 并发场景下的内存与 GC 影响
- 监控诊断与可观测性 — Arthas 实时诊断工具
12. 对象分配策略补充
理解对象在 JVM 中的分配路径有助于精准诊断内存问题。对象首先尝试在栈上分配(通过逃逸分析),如果对象不逃逸出方法则在栈帧中分配,随方法返回自动销毁。无法栈分配的对象进入 TLAB(线程本地分配缓冲区),TLAB 是 Eden 中为每个线程预留的私有区域,避免多线程竞争全局指针。当 TLAB 满或大对象超过阈值时,直接在 Eden 或老年代分配。
对于 G1 回收器,对象晋升不再依赖固定年龄阈值,而是采用动态年龄判定:当 Survivor 区中同龄对象总大小超过 Survivor 空间一半时,大于等于该年龄的对象直接晋升老年代。这种策略比固定阈值更适应实际存活分布。
生产环境常见陷阱是 ThreadLocal 未清理导致老年代泄漏,尤其在使用线程池时:线程不会退出,ThreadLocalMap 中的 Entry 一直存在,强引用阻碍 GC。解决方案是在 finally 块中调用 remove(),或使用线程池的 hook 机制定期清理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。