Skip to content

第74课:JVM 调优

🎯 学习目标

  • 理解 JVM 调优不是背参数,而是基于指标和业务目标调整运行时行为。
  • 掌握堆、栈、元空间、直接内存、GC 和 JIT 对性能的影响。
  • 能阅读 GC 日志,识别频繁 Young GC、Full GC、内存泄漏和停顿过长问题。
  • 了解 G1、ZGC、Parallel GC 等收集器的适用场景。
  • 能建立 JVM 问题的排查和调优流程。

📖 一、JVM 调优先确定目标

JVM 调优常见目标:

text
降低 P99 延迟
减少 Full GC
控制内存占用
提升吞吐量
缩短启动时间
稳定容器环境中的资源使用

不同目标可能冲突:

text
更大堆:可能减少 GC 频率,但单次 GC 停顿可能变长。
更小堆:内存占用少,但 GC 更频繁。
低延迟 GC:停顿短,但吞吐可能略低。
高吞吐 GC:总体效率高,但单次停顿可能更明显。

所以 JVM 调优不能脱离业务目标。


📖 二、JVM 内存区域

区域说明常见问题
存放大多数对象OOM、GC 频繁、内存泄漏
方法调用栈、局部变量StackOverflowError
元空间类元数据类加载过多、动态代理过多
直接内存堆外内存,NIO 常用Direct buffer OOM
代码缓存JIT 编译后的机器码大型应用可能不足

堆相关参数:

bash
-Xms512m
-Xmx512m

生产环境常把 -Xms-Xmx 设为相同,避免运行中扩容带来的抖动。


📖 三、GC 基础

Java 对象通常经历:

text
新对象 -> Eden
Young GC 后存活 -> Survivor
多次存活 -> Old
Old 空间不足或触发条件满足 -> Mixed GC / Full GC

常见现象:

现象可能原因
Young GC 频繁创建对象太快、年轻代太小
Full GC 频繁老年代压力大、内存泄漏、大对象多
GC 后内存不下降存在强引用、缓存无界、集合未清理
停顿时间长堆太大、收集器不合适、对象存活率高

📖 四、GC 日志

JDK 11+ 推荐:

bash
-Xlog:gc*:file=logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

观察重点:

text
GC 类型:Young、Mixed、Full
GC 前后堆大小
暂停时间
触发原因
老年代占用趋势
每分钟 GC 次数

示例解读:

text
Pause Young (Normal) 128M->32M(512M) 12.3ms

表示 Young GC 前堆使用 128M,之后 32M,总堆 512M,暂停 12.3ms。

如果每次 GC 后 Old 区持续上涨,且业务负载没有下降,就要怀疑内存泄漏或缓存增长。


📖 五、常见收集器

收集器特点适用场景
Serial GC单线程,简单小应用、客户端、测试
Parallel GC吞吐优先批处理、后台任务
G1 GC平衡吞吐和延迟,默认常用大多数服务端应用
ZGC超低停顿大堆、低延迟服务
Shenandoah低停顿部分 JDK 发行版支持

Spring Boot 服务在现代 JDK 上通常优先使用默认 G1。只有明确遇到延迟或吞吐问题时,再考虑切换。

G1 示例:

bash
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

ZGC 示例:

bash
-XX:+UseZGC

不要只因为“ZGC 高级”就切换。收集器选择必须压测验证。


📖 六、堆大小设置

堆大小不是越大越好。

设置原则:

text
满足业务峰值对象存活需求
留出非堆、直接内存、线程栈、操作系统空间
容器环境中不能只看宿主机内存
通过压测观察 GC 频率和停顿

容器部署示例:

bash
-XX:MaxRAMPercentage=70
-XX:InitialRAMPercentage=70

如果容器限制 1GB,不要直接 -Xmx1g,否则非堆和系统内存没有余量,可能被 OOMKilled。


📖 七、内存泄漏排查

Java 的内存泄漏通常不是“内存没释放”,而是对象仍被引用,GC 不能回收。

常见来源:

text
静态集合持续增长
本地缓存无容量限制
ThreadLocal 未 remove
监听器未注销
队列生产快于消费
大对象被会话或上下文持有

排查流程:

bash
jcmd <pid> GC.heap_info
jmap -histo:live <pid> | head
jmap -dump:live,format=b,file=heap.hprof <pid>

用 MAT 查看:

text
Dominator Tree
Retained Size
GC Roots
Leak Suspects

重点不是哪个类实例最多,而是谁持有它们导致无法释放。


📖 八、线程栈和元空间

1. 线程栈

bash
-Xss1m

线程越多,栈内存占用越大。线程池无限扩张会消耗大量非堆内存。

2. 元空间

bash
-XX:MaxMetaspaceSize=256m

动态代理、频繁类加载、脚本引擎、热部署都可能增加元空间压力。


📖 九、推荐基础参数

普通 Spring Boot 服务可以从保守配置开始:

bash
-Xms512m
-Xmx512m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xlog:gc*:file=logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100M
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=logs/heapdump.hprof

容器环境更推荐百分比参数:

bash
-XX:InitialRAMPercentage=70
-XX:MaxRAMPercentage=70
-XX:+UseG1GC
-Xlog:gc*:file=logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

这些不是最终答案,只是可观测、可排查的起点。


⚠️ 十、常见陷阱

1. 盲目复制 JVM 参数

别人的业务、JDK、容器、流量、对象模型都不同,参数不能照搬。

2. 只看堆,不看非堆

直接内存、线程栈、元空间也会导致进程内存过高。

3. OOM 后没有 dump

没有 heap dump,事后很难定位。生产应开启 HeapDumpOnOutOfMemoryError

4. 把 GC 当根因

GC 频繁可能只是结果,根因可能是代码创建对象过多、缓存无界或 SQL 返回过多数据。


🆚 十一、Java vs C 对比

维度C/C++Java
内存释放手动 free/deleteGC 自动回收
内存泄漏指针丢失或未释放引用仍存在导致不可回收
性能停顿通常来自锁/IO/系统调用还要关注 GC 停顿
调优方式分配器、对象生命周期堆、GC、对象分配、JIT

Java 减少了手动释放内存的负担,但需要理解对象引用和 GC 行为。


💡 十二、最佳实践

  • 先开启 GC 日志和 OOM dump,再谈调优。
  • 调优前记录基线:QPS、P99、GC 次数、暂停时间、内存趋势。
  • 优先修复对象创建过多、缓存无界、SQL 返回过多等代码问题。
  • 堆大小要给非堆和系统内存留余量。
  • 容器环境优先使用 MaxRAMPercentage
  • 收集器切换必须通过压测验证。
  • 线上 Full GC 频繁时,先保留现场数据,再重启止血。
  • JVM 参数要和应用版本、JDK 版本一起纳入发布记录。

🎓 小结

JVM 调优不是参数表背诵,而是围绕业务目标观察 JVM 行为。你需要先看到 GC、内存、线程和延迟的真实数据,再判断是堆太小、对象太多、缓存泄漏、线程过多,还是收集器不适合。

优秀的 JVM 调优结果不是“参数很多”,而是系统稳定、指标清楚、问题可复盘。


🧭 十三、JVM 排查剧本

线上 JVM 问题可以按症状处理。

1. CPU 高

text
1. top 定位进程。
2. top -H -p <pid> 定位高 CPU 线程。
3. 把线程 ID 转成十六进制。
4. jstack 中按 nid 查找线程栈。
5. 用 JFR 或 async-profiler 确认热点方法。

2. 内存持续上涨

text
1. 观察 GC 后内存是否回落。
2. jmap -histo:live 查看对象数量。
3. 导出 heap dump。
4. 用 MAT 查看 Dominator Tree。
5. 找到 GC Roots 引用链。
6. 判断是缓存、集合、ThreadLocal、队列还是类加载问题。

3. Full GC 频繁

text
1. 保存 GC 日志。
2. 查看老年代占用趋势。
3. 确认是否有大对象或对象晋升过快。
4. 查看是否存在内存泄漏。
5. 评估堆大小和收集器参数。
6. 修复代码问题后再调参数。

4. 容器 OOMKilled

text
1. 查看容器内存限制。
2. 确认 Xmx 或 MaxRAMPercentage。
3. 估算非堆、线程栈、直接内存。
4. 检查是否有堆外内存使用,例如 Netty、NIO、压缩库。
5. 给系统和非堆预留空间。

📌 十四、参数变更记录模板

每次 JVM 参数调整都应该记录:

text
服务名称:
JDK 版本:
实例规格:
容器内存限制:
调整前参数:
调整后参数:
调整目标:
压测场景:
调整前指标:
调整后指标:
风险:
回滚方式:

没有记录的 JVM 调优,很难复盘,也很难判断下次是否还能复用。


📌 十五、常见参数速查

参数作用
-Xms初始堆大小
-Xmx最大堆大小
-Xss单线程栈大小
-XX:MaxMetaspaceSize最大元空间
-XX:+UseG1GC使用 G1 收集器
-XX:+UseZGC使用 ZGC
-XX:MaxGCPauseMillis目标最大 GC 暂停,非硬保证
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时导出堆
-XX:HeapDumpPath堆 dump 路径
-Xlog:gc*JDK 9+ GC 日志

参数只是工具,不能替代对对象生命周期和业务负载的理解。


🔍 十六、自测问题

text
为什么 Xms 和 Xmx 常设置为相同?
为什么容器环境不能把 Xmx 设置到容器内存上限?
Young GC 和 Full GC 的影响有什么不同?
GC 后 Old 区持续上涨可能说明什么?
heap dump 主要看对象数量还是引用链?
ThreadLocal 为什么可能导致内存泄漏?
MaxGCPauseMillis 是硬性承诺吗?
为什么 GC 频繁可能只是代码问题的结果?

📌 十七、学习建议

建议在本地写一个小程序,分别观察:

text
不断创建临时对象时 Young GC 的变化。
静态 List 持有对象时堆内存不回落。
递归过深时 StackOverflowError。
直接内存过大时堆外 OOM。

再配合 GC 日志和 heap dump 分析。JVM 调优只有和具体故障现象绑定,参数才有意义。

容器部署时还要额外观察:

text
进程 RSS 是否接近容器限制。
堆外内存是否持续增长。
线程数是否异常增加。
OOMKilled 前是否有 GC 异常。