JVM 调优思路(从“瞎调参数”到“可验证的工程方法”)
这份文档讲的是方法论 + 常见场景的落地步骤。JVM 调优不是背参数,而是:先测、再定位、再改、再验证。
默认以服务端 Java(Spring Boot / 微服务 / 容器)为主,兼顾 JDK8~JDK17 常见差异。
1. 先把“调优目标”说清楚(不然你永远在乱跑)
调优的目标通常就三类,选一个当主目标,别三心二意:
- 低延迟:P99/P999 响应时间稳定,GC 暂停可控
- 高吞吐:单位时间处理更多请求(允许一定暂停)
- 低成本:更少内存、更少 CPU、同样吞吐(尤其容器/云上)
经验:线上多数问题是“延迟毛刺”或“内存不可控”,吞吐反而不是第一位。
2. 调优的基本套路(强烈建议照这个来)
2.1 先做基线(Baseline)
你需要一组“现在”的数据,才能证明“你改完更好了”。至少要有:
- 业务指标:QPS、P99、错误率、超时率
- JVM 指标:GC 次数/暂停、堆使用、线程数、类加载数
- 系统指标:CPU、Load、RSS、IO、网络
2.2 再定位“瓶颈类型”
JVM 调优常见瓶颈几乎都落在这几类:
- GC 压力大(分配太快 / 回收太慢 / 老年代膨胀)
- 堆外内存吃满(DirectBuffer / mmap / Netty)
- 元空间泄漏(类加载器泄漏、动态代理生成太多类)
- 线程太多(栈内存、上下文切换、锁竞争)
- CPU 火焰图热点(对象创建过多、序列化、正则、JSON、反射等)
2.3 再做小步实验(一次只改一个点)
- 一次改一个参数或一个代码点
- 压测/回放同样的流量
- 用同样的指标对比(别凭感觉)
3. 先把“证据”拿到:你需要哪些观测手段
3.1 必开:GC 日志(别调优还不让 JVM 说话)
- JDK8 常用:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationStoppedTime -Xloggc:/path/gc.log
- JDK9+(推荐写法):
-Xlog:gc*,safepoint:file=/path/gc.log:time,uptime,tags,level
GC 日志是调优的“黑匣子”。没有它,很多结论都是拍脑袋。
3.2 线上常用命令(不重启也能看很多)
- 查看 JVM 参数/版本/命令行:
jcmd <pid> VM.version
jcmd <pid> VM.flags
jcmd <pid> VM.command_line
- 查看堆/类/线程概况:
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_stats
jcmd <pid> Thread.print
jstat -gcutil <pid> 1000
- 生成 dump(高危操作:注意磁盘、会 STW):
jcmd <pid> GC.heap_dump /tmp/heap.hprof
jcmd <pid> VM.native_memory summary # 需要 -XX:NativeMemoryTracking=summary/detail
3.3 低开销性能分析(强烈推荐)
- JFR(JDK11+ 非常好用):看分配热点、锁、线程、GC、IO
- async-profiler:火焰图定位 CPU 热点/分配热点
- Arthas:临时看热点方法、线程阻塞、Trace 调用链
4. JVM 里“真影响线上”的几个内存块(别只盯着堆)
- Java Heap(堆):对象主要在这;你调的大部分参数都围绕它
- Metaspace(元空间):类元数据;类加载器泄漏会炸它
- Thread Stack(线程栈):线程多了就会占内存;
-Xss影响 - Direct/Native(堆外):Netty/NIO/ByteBuffer/mmap;OOM 不一定是堆 OOM
- Code Cache:JIT 编译后的代码缓存,满了会退化性能
结论:你看到“机器内存快满了”,不代表堆满;也可能是堆外、线程、元空间。
5. 选择 GC:别迷信“最牛”,要看你的目标
5.1 常见选择(JDK 版本相关)
- Parallel GC:吞吐强,暂停长(适合离线/批处理)
- G1 GC:通用型,暂停可控(现代服务端默认主力)
- ZGC / Shenandoah:超低暂停(延迟敏感、堆很大时很香,但看 JDK 版本支持)
如果你是常规 Spring Boot 微服务:优先 G1。
如果你是低延迟且堆很大:考虑 ZGC/Shenandoah(前提:JDK17+ 更稳)。
6. 调优最常见的“症状 -> 原因 -> 处理”
下面按线上最常见的几种痛点给出“排查路径 + 参数方向 + 代码方向”。
场景 A:Minor GC 很频繁,P99 抖动明显
症状
- GC 日志里 Young GC(或 G1 的 Evacuation Pause)很密
- CPU 有波动,吞吐受影响
jstat -gcutil看 YGC 次数飞涨
常见原因
- 对象分配速率太高(JSON、字符串拼接、List/Map 频繁创建)
- 新生代太小,撑不住分配洪峰
- 大对象直接进老年代(G1 的 humongous)
处理步骤
- 先算“分配速率”
- 用 JFR/async-profiler 看 Allocation flamegraph
- 业务上减分配:复用 buffer、避免临时对象、优化序列化
- 参数上:扩大可用堆/新生代(优先保证整体堆合理)
- JDK8(CMS/Parallel)时代常见:
-Xms -Xmx固定 +-XX:NewRatio - G1 时代不建议死抠 NewRatio,更建议给足堆、让 G1 自适应
- JDK8(CMS/Parallel)时代常见:
- 如果是 humongous(大对象):
- 优化大数组/大字符串/大 ByteBuffer 的生命周期
- 让大对象别频繁创建(比如把大响应做流式输出)
场景 B:Full GC / Mixed GC 很慢,卡顿明显
症状
- 请求出现“秒级/十秒级”暂停
- GC 日志出现 Full GC 或 G1 Mixed Pause 很长
- 老年代占用长期高位
常见原因
- 老年代真的装不下了(泄漏或缓存不受控)
- 晋升过快(Survivor 放不住,直接进老年代)
- Reference(Soft/Weak/Phantom)处理开销大
- G1:Region 回收跟不上 / Humongous 多
处理步骤
- 判断是“泄漏”还是“业务峰值”
- 堆使用曲线是否回不去?(回不去很像泄漏)
- Dump + 分析(MAT / YourKit / JProfiler)
- 看 Dominator Tree、Top Consumers、GC Roots
- 如果不是泄漏:
- 给足堆(最简单有效)
- 调整 GC 目标(例如 G1 的
-XX:MaxGCPauseMillis别设置得太激进)
- 如果是泄漏:
- 修代码(缓存、静态集合、ThreadLocal、监听器、ClassLoader)
- 绝大多数“调参数”救不了真正泄漏
场景 C:CPU 很高,但 GC 并不频繁
症状
- CPU 常年 80%+,但 GC log 很平稳
- QPS 上去后延迟爆炸
常见原因
- 业务热点(序列化/反序列化、加解密、压缩、正则)
- 锁竞争/线程切换(线程太多)
- 线程池队列堆积导致上下游雪崩
处理步骤
- async-profiler 采 CPU 火焰图(最直接)
- 如果锁竞争:JFR 看 Monitor/Lock 事件
- JVM 参数通常不是主因,重点在:
- 减少热点路径的对象创建与拷贝
- 控制线程数,避免“堆线程解决一切”的幻觉
- 让 IO 等待别变 CPU 忙等
场景 D:OOM 但堆没满(最容易误判)
典型报错
java.lang.OutOfMemoryError: Direct buffer memoryOutOfMemoryError: Metaspaceunable to create new native thread
处理思路
- Direct OOM:看 Netty/NIO buffer;限制/监控
-XX:MaxDirectMemorySize - Metaspace OOM:检查动态生成类/反射代理;设置
-XX:MaxMetaspaceSize只是止血 - Native thread:线程数太多或
-Xss太大;减少线程、调小栈
建议开启 NMT(需要重启):
-XX:NativeMemoryTracking=summary
然后:
jcmd <pid> VM.native_memory summary
7. 一套“可抄作业”的调优检查清单
7.1 启动参数(通用基线)
- 固定堆:减少动态扩容抖动
-Xms4g -Xmx4g
- 记录崩溃信息、OOM 自动 dump(注意磁盘)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp
-XX:ErrorFile=/tmp/hs_err_pid%p.log
- 打开 GC 日志(JDK9+ 推荐)
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags
7.2 容器/K8s 必看(很多人死在这里)
在容器里,别继续用“按物理机内存”的老思路,建议使用百分比参数(JDK10+ 更友好):
-XX:InitialRAMPercentage=50
-XX:MaxRAMPercentage=75
重点:容器内存限制太小,堆 + 堆外 + 线程栈 + 元空间 = 一起把 Pod 干掉。
8. G1 常用调参方向(别上来就堆一堆 flags)
你真的需要调 G1 参数的情况:
- 你已经有 GC 日志和明确瓶颈
- 你已经确认“加堆/修分配”解决不了
几个常见方向:
- 目标暂停时间(别设太小,不然 G1 会很激进、吞吐掉)
-XX:MaxGCPauseMillis=200
- 触发 Mixed 回收阈值(让老年代别拖太久)
-XX:InitiatingHeapOccupancyPercent=30
- 并行/并发线程(通常交给 JVM 自己,除非你 CPU 很小或很大)
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=2
经验:G1 最有效的“参数”经常不是参数,而是 给足堆 + 降低分配率。
9. 线上一次完整调优的“实战流程模板”
- 明确目标:比如 P99 < 200ms,Full GC 不能超过 200ms
- 开启/收集:GC 日志 + 指标(至少 1~3 天)
- 定位:
- GC 问题?看暂停、频率、老年代趋势
- CPU 问题?火焰图
- 堆外问题?NMT/Direct 指标
- 提出假设:比如“分配率太高导致 YGC 频繁”
- 实验:
- 先改代码(减少分配/减少大对象)
- 再改参数(加堆、调 G1 阈值)
- 验证:压测/回放 + 对比基线
- 固化:把结论写进 runbook(别下次又从头踩坑)
10. 常见误区(踩了就会“越调越差”)
- 只看堆,不看堆外/线程/元空间
- GC 暂停长就疯狂调 MaxGCPauseMillis(会牺牲吞吐,甚至更抖)
- 不做基线,改完只靠感觉
- 一次改一堆参数,最后根本不知道哪个有效
- 把泄漏当作“堆不够”(短期能活,长期必炸)
- 容器里照搬物理机参数(OOMKilled + 还以为是 JVM 崩了)
11. 最小可用的“参数模板”(给你一个起点)
11.1 通用服务端(G1,JDK11/17)
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags
11.2 延迟敏感(ZGC,JDK17+,需要验证)
-Xms4g -Xmx4g
-XX:+UseZGC
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags
注意:不同 JDK 版本对 GC 实现和默认值差异很大,别跨版本硬抄 flags。
12. 快速自检:你现在到底该从哪里下手?
- 你看到 P99 抖动 + GC 日志有长暂停 → 先看 GC 类型/暂停来源(Young/Mixed/Full)
- 你看到内存涨到顶不回落 → 先怀疑泄漏(dump 分析),别先加堆
- 你看到 CPU 常年高 → 火焰图先上,别先调 GC
- 你看到 OOM 但堆不大 → 查 Direct/Metaspace/线程,别只看
-Xmx
13. 附:常用命令速查
# 进程
jps -l
# GC/堆概况
jstat -gcutil <pid> 1000
jcmd <pid> GC.heap_info
# 线程栈
jstack <pid> | head -200
jcmd <pid> Thread.print > /tmp/threaddump.txt
# 导出堆 dump(可能 STW)
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# NMT(需要启动时开 -XX:NativeMemoryTracking=summary)
jcmd <pid> VM.native_memory summary
779

被折叠的 条评论
为什么被折叠?



