1. 先说结论:调优不是调参数,而是定位问题
面试里被问到「你做过 JVM 调优吗」,很多人第一反应是背参数:-Xms、-Xmx、-XX:MaxGCPauseMillis。但真实工作中,JVM 调优的绝大部分时间花在定位问题上,改参数往往只是最后一步。
我的经验可以概括成一句话:
先看现象,再看数据,最后才动参数;没有监控数据的调优都是玄学。
下面按「问题分类 → 排查手段 → 典型调优动作 → 落地验证」的顺序展开,都是可以直接复用的套路。
2. 先分清是哪一类问题
线上 JVM 相关问题,基本逃不出下面几类,先归类再动手,能省掉大量无效尝试。
| 现象 | 常见根因 | 优先排查方向 |
|---|---|---|
| 接口 RT 周期性抖动 | GC 停顿(尤其是 Full GC) | GC 日志、堆内存曲线 |
| 内存持续上涨直至 OOM | 内存泄漏 / 大对象 / 缓存无上限 | 堆 dump、对象直方图 |
| CPU 打满但 QPS 不高 | 死循环、频繁 GC、锁竞争 | 线程栈、GC 次数 |
| 服务启动慢、卡顿 | 类加载、元空间不足 | -verbose:class、Metaspace 使用率 |
| 线程数暴涨 | 线程池配置不当、阻塞调用 | jstack、线程数监控 |
归类之后,再决定是看 GC、看堆、还是看线程,避免一上来就 jmap 全量 dump 把机器拖死。
3. 排查工具箱:我常用的几个命令
JDK 自带的工具足够覆盖 90% 的场景,不需要一上来就上商业 APM。
bash
# 1. 看 GC 概况:每秒 Young GC / Full GC 次数与耗时
jstat -gcutil <pid> 1000 10
# 2. 看堆内对象分布,快速找"谁在占内存"
jmap -histo:live <pid> | head -30
# 3. 导出堆快照,用 MAT / JProfiler 分析引用链
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
# 4. 看线程状态,定位阻塞与死锁
jstack <pid> > /tmp/thread.txt
# 5. 看 JVM 启动参数,确认实际生效的配置
jinfo -flags <pid>
几个实践要点:
jstat -gcutil是最轻量 的入口,先看 Full GC 频率和FGCT(Full GC 总耗时)占比,超过 5% 就值得关注。jmap -histo:live会触发一次 Full GC,生产环境慎用 ,优先用jcmd <pid> GC.class_histogram。- 堆 dump 文件可能和堆一样大,务必确认磁盘空间,并尽量在低峰期做。
4. 典型场景一:GC 停顿导致接口抖动
这是最常见的一类。现象是监控上 RT 每隔几十秒出现一个尖刺,和 GC 时间点高度吻合。
排查步骤:
- 开启 GC 日志(JDK 9+ 用统一日志):
bash
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=50m
- 用 GCViewer 或 GCEasy 分析,重点看三件事:
- Full GC 是否频繁(老年代回收不掉 → 大概率内存泄漏或堆太小);
- 单次 Young GC 停顿是否过长(新生代过大或存活对象过多);
- 是否有 Promotion Failed / Concurrent Mode Failure。
常见调优动作:
bash
# 堆大小固定,避免动态扩缩带来的额外停顿
-Xms8g -Xmx8g
# 新生代占堆比例,默认 1/3,读多写少可适当调大
-XX:NewRatio=2
# 单个 Survivor 与 Eden 的比例
-XX:SurvivorRatio=8
# 目标最大停顿时间(G1 下是软目标)
-XX:MaxGCPauseMillis=200
# 大对象直接进老年代阈值,避免在新生代反复复制
-XX:PretenureSizeThreshold=1m
如果确认是短生命周期大对象导致的频繁 Young GC,优先考虑减少对象创建(比如复用缓冲区、避免循环内字符串拼接),而不是一味调大新生代。
5. 典型场景二:内存泄漏导致 OOM
现象是堆内存曲线呈锯齿状持续抬升,Full GC 后也回不到基线,最终 java.lang.OutOfMemoryError: Java heap space。
排查步骤:
- 配置 OOM 时自动 dump,这是最关键的兜底手段:
bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
- 用 MAT 打开 dump,看 Dominator Tree,找出 Retained Heap 最大的对象。
- 顺着 Path to GC Roots(排除弱引用/软引用)找到是谁持有引用不放。
我遇到过的真实案例:
一个本地缓存用了 ConcurrentHashMap 做 key 为「用户 ID + 时间戳」的缓存,但没有设置过期和容量上限,跑了一周后老年代被撑满。修复方式不是调大堆,而是换成带容量上限和过期策略的 Caffeine:
java
Cache<String, UserInfo> cache = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
结论:内存泄漏类问题,调参数只能延缓,不能解决。
6. 典型场景三:CPU 飙高
CPU 打满但 QPS 没涨,通常是死循环、频繁 GC 或锁竞争。
排查步骤:
bash
# 1. 找出占 CPU 最高的线程
top -Hp <pid>
# 2. 把线程 ID 转成十六进制
printf "%x\n" <tid>
# 3. 在线程栈里定位该线程在干什么
jstack <pid> | grep -A 30 "nid=0x<hex_tid>"
如果栈顶反复出现 GC task thread,说明是 GC 太频繁,回到场景一;如果是业务线程卡在某个方法,就看是不是死循环或锁等待。
7. 参数不是越多越好:一份可落地的基线配置
很多团队的问题不是参数没调,而是参数乱调。我一般先给一份保守基线,再按监控数据微调。
bash
# 堆:固定大小,避免动态扩缩
-Xms8g -Xmx8g
# 元空间:设置上限,防止无限增长
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
# 垃圾回收器:JDK 9+ 默认 G1,大堆低延迟场景首选
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# GC 日志
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=50m
# OOM 自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
# 栈大小:默认 1m,线程多时可适当调小
-Xss512k
几个原则:
-Xms和-Xmx设成一样,避免堆动态扩缩带来的额外开销和抖动。- 不要盲目调大堆,堆越大单次 Full GC 越久,先确认是不是泄漏。
- 容器环境务必关注
MaxRAMPercentage,JDK 8u191+ 支持容器感知,否则容易按宿主机内存算堆。
8. 调优之后:一定要有验证
改完参数不是结束,必须验证效果,否则下次出问题还是抓瞎。
我的验证清单:
- 压测对比:同样的流量模型下,对比调优前后的 TP99、GC 次数、Full GC 耗时。
- 观察周期:至少观察一个完整的业务高峰周期(比如一周),不要只看一小时。
- 保留回滚方案:参数变更走配置中心或启动脚本版本管理,出问题能快速回退。
- 沉淀监控:把 GC 次数、堆使用率、线程数做成看板,让问题在发生前就能被发现。
9. 面试怎么答
如果面试官问「你做过 JVM 调优吗」,我建议按这个结构回答,比背参数有说服力得多:
- 说场景:什么业务、什么现象(RT 抖动 / OOM / CPU 高)。
- 说定位:用了什么工具、看到了什么数据、怎么排除其他可能。
- 说动作:改了什么、为什么这么改(是代码问题还是参数问题)。
- 说结果:量化收益,比如 Full GC 从每天 20 次降到 0 次,TP99 从 800ms 降到 200ms。
核心观点:JVM 调优的本质是「用数据定位问题」,参数只是手段之一,代码层面的优化往往收益更大。