一份启动脚本里堆了三十多个 -XX 参数,其中一半是从某个"高性能模板"抄来的。问起某一个的用途,没人答得出;而真正出过问题的堆外内存,反而没有设上限。
JVM 参数的问题不是"调得对不对",而是多数参数在多数场景下不需要调。JVM 的默认值已经针对通用场景做过权衡,而这些权衡被一份模板覆盖之后,问题往往出现在没人看的地方。
1. 三层参数:环境、容量、调优
| 层 | 例子 | 该不该设 |
|---|---|---|
| 环境层 | -Xms/-Xmx、-Xlog、-XX:+HeapDumpOnOutOfMemoryError |
必须设 |
| 容量层 | 线程栈大小、直接内存上限、元空间上限 | 视部署形态 |
| 调优层 | GC 线程数、新生代比例、TLAB | 多数不必 |
第一层是"让系统可在生产观测与兜底"的参数,缺一个就是隐患。第二层与容器、负载有关。第三层才是真正的调优,而它需要基线数据才谈得上。
分层的意义在于"判断顺序":先问第一层齐不齐,再问第二层预算够不够,最后才问第三层要不要调。跳过前两层直接调优,是这类问题最典型的错序。
抄模板的人最常犯的错是在第一层缺失的情况下先动第三层:没有堆转储开关,却把新生代比例改成 1:3。
2. 必须设的六个参数
| 参数 | 作用 |
|---|---|
-Xms = -Xmx |
避免堆伸缩带来的抖动 |
-Xlog:gc*(JDK 9+) |
没有 GC 日志等于无法排障 |
-XX:+HeapDumpOnOutOfMemoryError |
OOM 时留下现场 |
-XX:HeapDumpPath |
指定落盘位置(容器里必须) |
-XX:+ExitOnOutOfMemoryError |
OOM 后退出而非僵死 |
-XX:MaxRAMPercentage |
容器里按比例分配堆 |
还有两个常被漏掉的:-XX:MaxDirectMemorySize 与 -XX:NativeMemoryTracking。前者给堆外内存设上限,后者能在怀疑内存泄漏时给出各区域的用量——它的开销约 5%,只在排查期开启。
第五项很重要:默认情况下 OOM 之后进程可能还活着,只是每个请求都失败。容器编排看到进程还在,就不会重启它,于是故障长期存在。
第六项是容器时代的关键:-Xmx 写死 4G 而容器限制 2G,进程会被 OOM Killer 直接杀掉,连堆转储都来不及。
// 容器里的最小可用参数集(JDK 17)
java -Xms2g -Xmx2g \
-XX:MaxRAMPercentage=70 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/dump/heap.hprof \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=20M \
-jar app.jar
3. 容器相关的两个坑
| 坑 | 现象 |
|---|---|
| 堆外内存未计入 | 容器被 OOM Killer 杀掉 |
| CPU 感知失效 | GC 线程数按宿主机算 |
第一项是容器部署失败的头号原因:JVM 只算堆,而直接内存、元空间、线程栈、JIT 代码缓存都在堆外。堆设了 70%,加上堆外 20% 就可能撞到限制。
两个坑可以合起来验证:跑一个空转容器,看 Native Memory Tracking 的总量和 GC 线程数。两个数一对,就能判断这个部署形态有没有踩到容器感知的问题。
// 容器感知自检:核数、GC 线程、堆外总量
java -XX:+PrintFlagsFinal -version | grep -E 'ActiveProcessorCount|ParallelGCThreads|MaxHeapSize'
java -XX:NativeMemoryTracking=summary -jar app.jar &
jcmd $(pgrep -f app.jar) VM.native_memory summary | head -30
第二项在老版本 JDK 上普遍存在:-XX:ActiveProcessorCount 不设,GC 线程按宿主机的 64 核算,容器限 2 核时大量线程争抢,GC 反而更慢。
// 容器内存预算:堆 + 堆外,留出安全边界
// 容器 limit 4G
// 堆 65% ≈ 2.6G
// 元空间 ~256M
// 直接内存 ~512M(Netty/大文件场景要单设 -XX:MaxDirectMemorySize)
// 线程栈 ~100M(200 线程 × 512K)
// 其它(代码缓存、JIT、GC 结构) ~300M
// 合计约 3.8G,余量 5% 作为缓冲
4. 调优层的三个前提
| 前提 | 没有会怎样 |
|---|---|
| 有基线(延迟与吞吐曲线) | 改了不知道好坏 |
| 有可复现负载 | 改了无法验证 |
| 有明确的调优目标 | 改了不知道往哪调 |
第二项是最容易被跳过的:在生产上直接调参,等于拿真实流量做实验,而反馈被噪声掩盖,一周后也说不清有没有效果。
调优的正确顺序是"先量后调":先用 GC 日志算出三个数(吞吐占比、平均停顿、最大停顿),再决定动哪个参数。三个数一起看,才知道现有配置是"停顿过长"还是"吞吐不足"。
// 从 GC 日志读三个数(JDK 9+ 统一日志格式)
1. 吞吐占比 = 1 - (GC 总耗时 / 运行时长),一般应 > 99%
2. 平均停顿 = GC 总停顿 / GC 次数
3. 最大停顿 = 单次最长停顿(决定尾延迟)
// 三个数里通常只有一个不达标,那一个才指向该调的参数
第三项同样关键:"延迟低"与"吞吐高"常互相冲突,不同目标对应相反的参数方向。没有目标,任何调整都是在两个方向上随机走。
5. 排错:五种"调了参数更差"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| GC 停顿变短但吞吐下降 | 新生代调小 | 按目标取舍 |
| 堆更大但延迟更高 | 大堆导致单次 GC 变长 | 检查 GC 与堆的关系 |
| 容器被 OOM 杀掉 | 堆外未预算 | 加直接内存上限 |
| 应用卡住但 CPU 不高 | 单线程 GC 或元空间 GC 频繁 | 看 GC 日志而非监控 |
| 启动变慢 | 参数过多触发额外检查 | 精简参数 |
排错时还应当确认"参数真的生效了":用 PrintFlagsFinal 打印最终值,而不是相信自己写的启动脚本。容器编排、JAVA_TOOL_OPTIONS、镜像里的默认参数都可能覆盖你的设置。
// 确认最终生效值,而不是启动脚本里的值
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E 'MaxHeapSize|MaxDirectMemorySize|UseG1GC'
// 若与启动脚本不一致 → 有更高优先级的来源覆盖了它
第四项值得展开:元空间不足触发的 Full GC 是"频繁但短暂"的,在 CPU 利用率上几乎看不出来,只有在 GC 日志里才能看到连续的全量回收。
还有一个"参数即文档"的问题:三十个参数里没有一个注释,于是没人敢删。合理的做法是给每个参数标上"为什么设"与"何时复查",让删除有依据——否则模板会一直膨胀下去。
7. 边界:什么情况不必调 JVM
| 场景 | 为什么 | 做法 |
|---|---|---|
| 短生命周期批处理 | 来不及调优 | 只设堆与日志 |
| 无状态小服务、流量平稳 | 默认值够用 | 只设第一层 |
| 已用托管运行时(如 Serverless) | 参数由平台决定 | 只做应用层优化 |
| 问题在数据库或下游 | 与 JVM 无关 | 别调 JVM |
还有一条与"稳定"有关的边界:参数变更应当一次只动一个。同时改堆、改 GC、改线程数,出问题时无法归因——而 JVM 参数组合的效果恰恰常是非线性的。
判断口径一句话:这次要解决的问题,在 GC 日志里有没有痕迹。有痕迹才轮到 JVM 参数;没有痕迹,调参数只是绕开真问题。
8. 自测
- 三层参数中,哪一层"缺一个就是隐患"?为什么先动第三层是常见的错序?
-XX:+ExitOnOutOfMemoryError解决的是什么问题?没有它时编排系统会怎么反应?- 容器内存预算里,堆之外还有哪四项要算?为什么直接内存要单独设上限?
- 为什么"在生产上调参"几乎无法得出结论?调优层的三个前提是什么?
- 元空间不足导致的 Full GC 为什么在 CPU 监控上看不出来?
- 判断"是否该调 JVM"的那个问题是什么?