ElIauk

JVM 调优:堆、GC 与 OOM 排查

调优不是背参数 JVM 调优最常见的误区,是把网上的一套参数原样贴到启动脚本里:-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio=8。这些参数在 JDK 8 + CMS 的年代也许合理,放到 Java 21 + G1 + 容器环境里,很多已经不适用,甚至会帮倒忙。 调优的

评论
JVM 调优:堆、GC 与 OOM 排查

调优不是背参数

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,日志无限增长,最后把节点磁盘打满,引发更大的故障。

排查步骤

  1. 先确认是 OutOfMemoryError 还是容器 OOMKilled,两者的排查路径完全不同。
  2. 看 GC 日志里 Full GC 的频率和回收效果:回收后堆仍然很高,基本就是泄漏;回收后能降下来,多半是堆太小或流量突增。
  3. jcmd <pid> GC.heap_info 和 jstat -gcutil <pid> 1000 看各区使用率和 GC 次数。
  4. 拿到 heap dump 后用 MAT 看 Dominator Tree,找占比最大的对象和它的 GC Root 引用链。
  5. 堆内正常但内存仍高,用 NMT 看堆外;线程数异常就 jstack 统计线程状态。
  6. 最后回到代码,确认对象的生命周期是否符合预期,再决定是改代码还是调参数。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者