调优不是背参数
JVM 调优最常见的误区,是把网上的一套参数原样贴到启动脚本里:-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio=8。这些参数在 JDK 8 + CMS 的年代也许合理,放到 Java 21 + G1 + 容器环境里,很多已经不适用,甚至会帮倒忙。
调优的目标只有两个:不让应用因为内存问题挂掉,以及让 GC 停顿落在业务能接受的范围内。先定目标,再看参数。
容器里的堆怎么定
K8s 里 JVM 看到的是容器 limit(UseContainerSupport 默认开启),但 MaxRAMPercentage 只决定堆的上限,不覆盖元空间、线程栈、直接内存、代码缓存。堆设成 limit 的 100%,几乎必然被 OOMKilled。
比较稳的起点是堆占容器内存的 60% 到 75%:
-XX:InitialRAMPercentage=60.0
-XX:MaxRAMPercentage=75.0
-XX:MaxMetaspaceSize=512m
-XX:MaxDirectMemorySize=256m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app
-XX:+ExitOnOutOfMemoryError
剩下的 25% 到 40% 留给元空间、线程栈(每个线程约 1MB)、Netty 的堆外内存、JIT 代码缓存和 GC 自身的数据结构。如果应用线程数上千,或者大量使用 NIO/Netty,直接内存要单独估算,不能只盯着堆。
InitialRAMPercentage 和 MaxRAMPercentage 设成接近的值,可以避免堆反复伸缩。ExitOnOutOfMemoryError 让进程在 OOM 后退出,交给 K8s 重启,比一个已经内存错乱还继续接流量的进程安全。
GC 选择与日志
Java 21 默认 G1,大多数 Web 服务用默认值就够了。G1 的核心参数是 -XX:MaxGCPauseMillis,它是目标不是承诺,设成 50ms 不会保证每次都 50ms,只是让 G1 更倾向于缩小年轻代、增加 GC 次数。堆特别大(几十 GB)、对停顿极敏感的场景,可以评估 ZGC;Java 21 的分代 ZGC 还需要显式开启,且属于实验特性,上线前要压测。
不管用哪个收集器,GC 日志必须开:
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50M
这是 JDK 9 之后的统一日志写法,-XX:+PrintGCDetails 那套已经废弃。日志按 50MB 滚动、保留 5 个文件,既能覆盖最近几天的故障,又不会把磁盘写满。
OOM 不止一种
| 报错 | 典型原因 | 先看什么 |
|---|---|---|
Java heap space |
堆内对象泄漏或堆太小 | heap dump + MAT |
GC overhead limit exceeded |
堆几乎满,GC 空转 | GC 日志、dump |
Metaspace |
动态生成类、类加载器泄漏 | 类直方图、加载器引用链 |
Direct buffer memory |
Netty/NIO 堆外泄漏 | NMT、MaxDirectMemorySize |
unable to create native thread |
线程数过多、ulimit 限制 | 线程数、ulimit -u |
Requested array size exceeds VM limit |
一次性申请超大数组 | 代码里的分页/批量逻辑 |
| 容器退出码 137 | 被 cgroup OOMKill | kubectl describe pod |
最后一种不是 OutOfMemoryError,JVM 根本来不及打印堆栈,进程直接被内核杀掉。看到 137 先去查容器内存 limit 和实际 RSS,不要一头扎进堆分析。
常见坑
坑一:-Xmn 固定年轻代。 G1 会动态调整年轻代大小,手动固定会限制它的决策空间,通常得不偿失。
坑二:只看堆,不看堆外。 堆用了 60%,容器却 OOMKilled,问题多半在直接内存、线程栈或元空间。开 -XX:NativeMemoryTracking=summary,用 jcmd <pid> VM.native_memory summary 看各区域。
坑三:jmap -histo 不带 :live。 看到的是包含垃圾对象的直方图,容易把正常的大对象误判成泄漏。要精确统计就加 :live,但它会触发一次 Full GC,生产上谨慎使用。
坑四:HeapDumpPath 不可写。 容器里 /var/log/app 没挂卷、权限不对、磁盘不够,OOM 时 dump 静默失败,事后什么都没有。提前验证目录可写,并确认有足够空间。
坑五:抓了 OOM 还继续跑。 捕获 OutOfMemoryError 后不退出,应用可能已经处于不一致状态。让进程退出、让编排系统重启,是更负责任的做法。
坑六:GC 日志写满磁盘。 没配 filecount 和 filesize,日志无限增长,最后把节点磁盘打满,引发更大的故障。
排查步骤
- 先确认是
OutOfMemoryError还是容器 OOMKilled,两者的排查路径完全不同。 - 看 GC 日志里 Full GC 的频率和回收效果:回收后堆仍然很高,基本就是泄漏;回收后能降下来,多半是堆太小或流量突增。
jcmd <pid> GC.heap_info和jstat -gcutil <pid> 1000看各区使用率和 GC 次数。- 拿到 heap dump 后用 MAT 看 Dominator Tree,找占比最大的对象和它的 GC Root 引用链。
- 堆内正常但内存仍高,用 NMT 看堆外;线程数异常就
jstack统计线程状态。 - 最后回到代码,确认对象的生命周期是否符合预期,再决定是改代码还是调参数。
评论