Appearance
第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=200ZGC 示例:
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/delete | GC 自动回收 |
| 内存泄漏 | 指针丢失或未释放 | 引用仍存在导致不可回收 |
| 性能停顿 | 通常来自锁/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:+HeapDumpOnOutOfMemoryError | OOM 时导出堆 |
-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 异常。