很多人说自己做过 JVM 调优,但被追问几句就哑火了。本文从"为什么要调"入手,把 JVM 调优这件事从头捋清楚。
一、先问一个问题:你的程序出了什么问题?
JVM 调优不是玄学,更不是无脑堆参数。在动手之前,先得弄清楚:程序到底哪里不对劲?
常见的症状大概就这几类:
| 接口响应时间偶发性飙高 | GC 停顿(Stop-The-World) |
| 内存一直涨,最终 OOM | 内存泄漏、对象分配太猛 |
| CPU 跑满但吞吐量低 | GC 太频繁,或线程竞争严重 |
| 程序越跑越慢 | JIT 未充分热身,或代码缓存满了 |
| 线程 hang 住,响应全无 | 死锁、资源耗尽 |
调优的起点永远是问题,不是参数。 拿到一台正常运行的服务器,没有任何问题,你去"调 JVM",多半是在瞎折腾。
二、JVM 是个什么东西?(快速建立全局观)
在调之前,得知道自己在调什么。JVM 内部主要分三大块:
┌─────────────────────────────────────────────────────────┐
│ JVM 内部 │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 运行时数据区 │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ 堆 Heap │ │ 方法区 │ │ 直接内存(堆外) │ │ │
│ │ │(GC管理)│ │Metaspace │ │ Direct Memory │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 每个线程独立:虚拟机栈 / 本地方法栈 / 程序计数器│ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 执行引擎 │ │ 垃圾回收器 GC │ │
│ │ 解释器 + JIT 编译器 │ │ G1 / ZGC / Shenandoah│ │
│ └──────────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
JVM 调优,本质上就是在这几个模块上做文章:
三、调优 Part 1:内存——分多少、怎么分
3.1 堆内存的基本结构
Java 对象都活在堆里。GC 的设计利用了一个重要观察:大部分对象生命周期极短(朝生夕死)。所以堆被分成两代:
Java 堆 (-Xms / -Xmx)
┌───────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────┐ ┌───────────────────┐ │
│ │ 新生代 Young Gen │ │ 老年代 Old Gen │ │
│ │ │ │ │ │
│ │ ┌───────┐ ┌──┐ ┌──┐ │ │ 长寿对象、大对象 │ │
│ │ │ Eden │ │S0│ │S1│ │ │ 经历多次GC后晋升 │ │
│ │ └───────┘ └──┘ └──┘ │ │ │ │
│ │ 对象在这里诞生 │ │ │ │
│ └─────────────────────────┘ └───────────────────┘ │
│ Minor GC(频繁) Major/Full GC │
└───────────────────────────────────────────────────────┘
3.2 关键内存参数
# 堆的初始和最大值(生产环境建议设相同,避免动态扩容)
-Xms4g -Xmx4g
# 新生代大小(约占堆的 1/3)
-Xmn1g
# 或者用比例
-XX:NewRatio=2 # 老年代:新生代 = 2:1
# Eden 和 Survivor 的比例
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
# Metaspace(方法区,存类信息,JDK8+已移出堆)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# 堆外直接内存(NIO、Netty 重度用户需要关注)
-XX:MaxDirectMemorySize=1g
3.3 一个常见的坑:Xms 和 Xmx 不相等
很多人 -Xms512m -Xmx4g,觉得"灵活"。实际上 JVM 启动后内存不够用时会触发堆扩容,扩容本身就有性能代价。生产环境建议 Xms = Xmx,让 JVM 一次性申请好内存,省得反复扩。
四、调优 Part 2:GC——最核心的战场
GC 调优占了整个 JVM 调优 80% 的工作量。GC 的核心矛盾只有一个:
吞吐量 vs 停顿时间(延迟)
这是一对天然的矛盾。你不能既要极低的停顿,又要极高的吞吐量——收集器设计本身就是在做这个 trade-off。
4.1 选对收集器,是第一步
| Serial GC | 单核、客户端、内存小 | 单线程,停顿长 |
| Parallel GC | 批处理、吞吐量优先 | 多线程,停顿可接受 |
| G1 GC | 通用,大堆(4G+) | 可预测停顿(-XX:MaxGCPauseMillis) |
| ZGC | 超低延迟(JDK15+ 推荐) | 亚毫秒级,几乎无 STW |
| Shenandoah | 低延迟,类似 ZGC | Red Hat 主导,OpenJDK 支持 |
JDK 版本和默认收集器对照:
JDK 8 → Parallel GC(默认)
JDK 9+ → G1 GC(默认)
JDK 21+ → ZGC 已生产就绪,可考虑直接上
4.2 G1 GC 的关键参数(目前最主流)
G1 的设计思路是把堆切成 N 个等大的 Region,每次优先回收垃圾最多的 Region(Garbage First 的由来)。
堆(G1 视角)
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ S │ O │ E │ H │ O │ E │ S │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O │ E │ O │ S │ O │ E │ O │ O │
└────┴────┴────┴────┴────┴────┴────┴────┘
E=Eden S=Survivor O=Old H=Humongous(大对象)
# 使用 G1
-XX:+UseG1GC
# 最大停顿时间目标(核心参数,G1 会自动调整回收区域)
-XX:MaxGCPauseMillis=200
# Region 大小(1MB~32MB,建议让 JVM 自动计算)
-XX:G1HeapRegionSize=4m
# Mixed GC 触发时机(老年代占堆比例)
-XX:InitiatingHeapOccupancyPercent=45
# 并发标记线程数
-XX:ConcGCThreads=4
4.3 怎么知道 GC 有问题?
先开 GC 日志,这是一切分析的基础:
# JDK 9+
-Xlog:gc*:file=/app/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/app/logs/gc.log
看日志时重点关注:
# 一条典型的 G1 日志
[2024-01-15T10:23:45.123] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
↑ 这次是 Minor GC,停顿了多久?
[2024-01-15T10:23:45.123] GC(42) 512M->384M(2048M) 45.678ms
↑ 回收前→回收后(堆总大小)耗时
健康的 GC 应该是什么样的?
- Minor GC 频率:不超过每 10 秒一次
- Minor GC 停顿:不超过 100ms
- Full GC:尽量为 0,最多每天个位数
- 老年代占用:长期稳定,不持续增长
五、调优 Part 3:排查 OOM,找到内存泄漏
OOM 分好几种,错误信息就是线索:
java.lang.OutOfMemoryError: Java heap space
→ 堆满了。先看是真泄漏还是内存不够用
java.lang.OutOfMemoryError: Metaspace
→ 类加载太多(动态代理、反射、OSGi、热部署)
java.lang.OutOfMemoryError: Direct buffer memory
→ NIO/Netty 的堆外内存没有释放
java.lang.OutOfMemoryError: unable to create native thread
→ 线程太多,或者每个线程栈太大
排查 Heap OOM 的标准姿势
第一步:拿到 Heap Dump
# 方式一:JVM 参数,OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/app/logs/heapdump.hprof
# 方式二:手动触发(进程还活着时)
jmap -dump:live,format=b,file=heapdump.hprof <pid>
第二步:用工具分析
把 .hprof 文件拖进 Eclipse MAT 或 JProfiler,看 Leak Suspects Report,它会直接告诉你哪个对象占了多少内存、是哪段代码创建的。
典型的泄漏路径长这样:
ThreadLocal → Map → ArrayList → 你的业务对象(10万个)
↑ 这里没有 remove(),随着线程复用一直积累
六、调优 Part 4:JIT——不用动,但要懂
JIT(Just-In-Time Compiler)是 JVM 的性能核武器,它在运行时把热点字节码编译成本地机器码。一般不需要干预,但有几个场景值得注意:
代码缓存(Code Cache)满了
# 查看代码缓存使用情况
jcmd <pid> VM.native_memory summary
# 调大代码缓存
-XX:ReservedCodeCacheSize=512m
服务刚启动时性能差(JIT 预热期)
这是正常的。JVM 默认要一个方法被调用 10000 次(C2 编译器阈值)才会编译成机器码。对于短生命周期的微服务,可以考虑 AppCDS(Application Class Data Sharing) 来加速启动。
# 开启分层编译(默认开启,确保没有被关闭)
-XX:+TieredCompilation
七、调优 Part 5:线程——别让锁成为瓶颈
线程问题通常表现为:CPU 高但进展缓慢,或者接口偶发 timeout。
查线程状态,一招鲜:
# 导出所有线程堆栈
jstack <pid> > thread_dump.txt
# 或者用 jcmd
jcmd <pid> Thread.print > thread_dump.txt
然后搜索关键词:
BLOCKED → 线程在等待锁,找锁竞争
WAITING → 线程在等待某个条件(可能是死锁)
常见线程参数:
# 每个线程的栈大小(默认 512k 或 1m,线程多时适当调小)
-Xss256k
# 注意:这个不影响业务线程池,线程池大小要在代码里配
八、调优工具箱,收好了
| 实时监控 JVM 状态 | jconsole、jvisualvm、Arthas |
| 查内存/线程/类 | jmap、jstack、jstat |
| 分析 Heap Dump | Eclipse MAT、JProfiler |
| 分析 GC 日志 | GCViewer、GCEasy(在线) |
| 诊断线上问题(神器) | Arthas(阿里开源) |
Arthas 几个常用命令:
# 查看 JVM 内存分布
memory
# 查看类加载情况
classloader -t
# 追踪某个方法的耗时(不用重启!)
trace com.example.UserService getUserById
# 查看对象在内存中的数量
ognl "@com.example.cache.LocalCache@instance.size()"
九、调优的正确姿势——一个完整流程
发现问题
↓
收集数据(GC日志、Heap Dump、Thread Dump、指标监控)
↓
定位瓶颈(GC停顿?内存泄漏?锁竞争?CPU占用?)
↓
单次只改一个参数或方向
↓
压测或等待足够时间观察效果
↓
对比前后数据,确认是否改善
↓
记录结论 → 循环迭代
千万别做的事:
- 一次改一堆参数,然后不知道是哪个起的效果
- 看别人的博客抄参数,不管自己的业务场景
- 没有监控,靠感觉说"好像快了一点"
十、一个实际案例:接口 P99 偶发飙到 5 秒
现象: 电商系统下单接口,平时 P99 在 200ms,每隔几分钟就会飙到 4~5 秒。
排查过程:
解决方案:
# 调整前
-Xmx4g -Xms4g -Xmn512m
# 调整后:新生代放大,让短命对象死在新生代
-Xmx4g -Xms4g -Xmn2g -XX:MaxGCPauseMillis=200
# 同时切换到 G1,利用停顿时间预测
-XX:+UseG1GC
代码侧用了对象池复用序列化缓冲区。
效果: Full GC 降为 0,P99 稳定在 180ms。
总结
JVM 调优归根结底就是这几件事:
真正好的 JVM 调优,是让你感觉不到 JVM 的存在——接口稳定、内存平稳、GC 安静。
本文所有参数以 JDK 11/17 为主。不同 JDK 版本部分参数有差异,建议结合 java -XX:+PrintFlagsFinal -version 查看当前 JVM 的实际默认值。
如果本文对你有帮助,点个赞再走 ✌️


