先记住一句话:没有观测数据的调优,都是猜。 这份表的前半部分全是「怎么拿到数据」, 参数只在最后出现。
一、先分清是哪一类故障
| 现象 | 大概率的根因 | 第一条命令 |
|---|---|---|
| CPU 100%,接口全部变慢 | 死循环 / 正则回溯 / 频繁 GC / 线程数过多 | top -Hp <pid> |
| OOM,进程被杀或抛 OutOfMemoryError | 内存泄漏 / 堆太小 / 大对象一次性加载 | jcmd <pid> GC.heap_info |
| 接口偶发卡顿 1~3 秒,CPU 不高 | Full GC 停顿 / 锁竞争 / 外部调用超时 | jstat -gcutil <pid> 1000 |
| 内存缓慢上涨,重启就好 | 静态集合累积 / ThreadLocal 未清理 / 连接未关 | jcmd <pid> GC.class_histogram |
二、8 步定位(按顺序做,别跳)
第 1 步:确认 JVM 真实参数
jcmd <pid> VM.flags
jcmd <pid> VM.system_properties | grep -E 'java.version|java.home'
容器里最常踩的坑:-Xmx 没设,JVM 按宿主机的内存算,被 OOMKilled 才发现。JDK 10+ 默认开 UseContainerSupport,JDK 8u191 之前不支持 cgroup 限制。
第 2 步:看 GC 是不是元凶
jstat -gcutil <pid> 1000 20
看两列就够:FGC(Full GC 次数)在一分钟内涨了,且 FGCT 跟着涨 → 是 GC 问题;O(老年代使用率)Full GC 后仍不下降 → 有对象一直被引用,是泄漏不是参数问题。
第 3 步:定位到线程(CPU 高时)
top -Hp <pid> # 找出占用最高的线程 TID
printf '%x\n' <TID> # 转成十六进制
jstack <pid> | grep -A 30 "nid=0x<hex>"
连续抓 3 次(间隔 5 秒),同一个栈顶重复出现才是真热点;只抓一次经常抓到 GC 线程或 JIT 编译线程。
第 4 步:确认是不是 GC 线程在烧 CPU
如果 top -Hp 里排前面的线程名是 GC Thread# 或 G1 Young RemSet,别再翻业务代码了,回到第 2 步。
第 5 步:看堆里到底是谁
jcmd <pid> GC.class_histogram | head -30
注意:这条会触发一次 STW(很短),生产可用但要挑时机。要找「实例数异常多」的类,不是「占内存最大」的类——byte[] 永远排第一,没意义。
第 6 步:需要现场就 dump,但要先看磁盘
df -h /tmp
jcmd <pid> GC.heap_dump /data/dump-$(date +%s).hprof
堆多大文件就多大。dump 期间会 STW,线上大堆可能几十秒,先摘流量再 dump。JDK 8 上用 jmap -dump:live,format=b,file=...。
第 7 步:低开销地看一段时间
jcmd <pid> JFR.start duration=120s filename=/tmp/app.jfr
# 用 JDK Mission Control 打开,看分配热点与锁竞争
JFR 开销通常低于 2%,比 async-profiler 更适合「先开着看」。JDK 8 需要 -XX:+UnlockCommercialFeatures,JDK 11+ 直接可用。
第 8 步:只有在 1~7 步都有数据之后,才动参数
改参数前把「改前 / 改后」的同一指标记录下来,否则你永远不知道是参数生效还是流量降了。
三、参数速查表
| 参数 | 作用 | 建议 |
|---|---|---|
-Xms = -Xmx | 避免堆反复伸缩 | 生产必设相等 |
-XX:MaxRAMPercentage=70 | 按容器限额算堆 | 容器场景优于写死 -Xmx |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时自动 dump | 必开 |
-XX:HeapDumpPath | dump 落盘位置 | 指向大磁盘,别放容器临时层 |
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=50M | GC 日志(JDK 9+) | 必开,滚动防打满 |
-XX:+PrintGCDetails -Xloggc: | GC 日志(JDK 8) | JDK 8 用这组 |
-XX:MaxMetaspaceSize | 限制元空间 | 建议设,防动态生成类撑爆 |
-XX:MaxGCPauseMillis=200 | G1 停顿目标 | 别设太小(<50 会让堆变小、GC 更频繁) |
-XX:ParallelGCThreads | 并行 GC 线程数 | 容器里按 limit 核数算,不是宿主机核数 |
-XX:+ExitOnOutOfMemoryError | OOM 直接退出 | 让编排系统重建,比半死状态好 |
四、GC 选型判断
- 堆 ≤ 4G,追求吞吐:Parallel。批处理、离线任务。
- 堆 4G~32G,通用服务:G1。JDK 9 起是默认,够用且稳定。
- 停顿要求 < 10ms,堆很大:ZGC(JDK 15+ 生产可用,JDK 21 有分代 ZGC)。代价是吞吐略低、内存占用略高。
- 不要做的事:在生产上为了「看起来更快」把 G1 换成 ZGC 却不测吞吐;ZGC 的染色指针会吃掉一部分堆外内存,容器里要留余量。
五、五个最常见的坑
- 容器里没设
-Xmx,JVM 按宿主机内存算 → 被内核 OOMKilled,日志里连 OOM 都没有。 - 把「Full GC 后老年代不降」当成参数问题去调 G1 → 实际是内存泄漏,调参数只是延后爆炸。
- 只看
top的进程 CPU,不看top -Hp的线程 → 分不清是业务线程还是 GC 线程。 - dump 直接打到容器临时层 → 容器一重启,现场没了。
- GC 日志不滚动 → 磁盘写满,服务以另一种方式挂掉。
六、和这份表配套读的
- JVM 运行时数据区(地图)——先搞清楚「堆、元空间、堆外」各是什么。
- 垃圾回收(地图)——分代假设与收集器差异。
- JVM 参数生成器——按容器内存和 JDK 版本生成一组参数。
这份表给的是排查顺序,不是结论。任何一次参数调整都必须有改前改后的同一指标对比, 否则下次出问题你还是不知道原因。