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 异常。