Java中OOM怎么排查

文章导读
搜「Java OOM 怎么排查」的人,通常已经看到了 java.lang.OutOfMemoryError,或者进程毫无征兆地重启、消失。这时最先要做的不是调大 -Xmx,而是分清这次 OOM 发生在哪一层:JVM 堆内空间耗尽、堆外空间(Metaspace、直接内存、线程栈)耗尽、进程或容器被系统终止,还是虽然不是内存问题、也被误当成内存问题来治。层判断错了,参数越调越乱。下面按实际排查的先后顺
📋 目录
  1. Ⅰ Java OOM先定位在哪一层
  2. Ⅱ Java OOM:先看这四个位置
  3. Ⅲ Java OOM:按可能性从高到低排除
  4. Ⅳ Java OOM:改完怎么确认真的解决了
A A

搜「Java OOM 怎么排查」的人,通常已经看到了 java.lang.OutOfMemoryError,或者进程毫无征兆地重启、消失。这时最先要做的不是调大 -Xmx,而是分清这次 OOM 发生在哪一层:JVM 堆内空间耗尽、堆外空间(Metaspace、直接内存、线程栈)耗尽、进程或容器被系统终止,还是虽然不是内存问题、也被误当成内存问题来治。层判断错了,参数越调越乱。下面按实际排查的先后顺序,给出命令和判断依据。

建议的判断顺序是:先在应用日志里确认 OutOfMemoryError 的具体文案(Java heap space、Metaspace、Direct buffer memory、unable to create new native thread 等),再用 jcmd、jstat 查看堆和元空间的实际占用,最后对照容器的 memory 限制以及宿主机上的 dmesg,确认有没有被内核 OOM Killer 杀掉。文案不同,处理方向完全不同;如果是容器层被杀,改 JVM 参数通常无效,应该先核对堆内和堆外各自使用的总内存与 limit 的关系。

Java OOM先定位在哪一层

先分级,再动手。下面这张判断清单按「现象 → 检查位置 → 判断依据」组织,逐条排除即可:

  • 堆内耗尽:日志文案为 java.lang.OutOfMemoryError: Java heap space 或 GC overhead limit exceeded。检查 jcmd <pid> GC.heap_info 与 jstat -gcutil <pid>,判断依据是多次 Full GC 之后老年代占用列(O,也就是 Old 区使用率)仍接近满值、不回落。
  • 元空间/类元数据耗尽:文案为 Metaspace 或 Compressed class space。检查 jcmd <pid> VM.metaspace(部分 JDK 版本没有该子命令,可改看 jcmd <pid> VM.native_memory summary),判断依据是 Metaspace 只涨不跌,且与动态生成类、热部署、脚本引擎的使用节奏对得上。
  • 直接内存/本地内存耗尽:文案为 Direct buffer memory,或堆看起来正常但进程 RSS 持续上涨。检查 jcmd <pid> VM.native_memory summary,判断依据是 Internal、Other 段落持续增长,常见于 Netty、NIO 相关代码路径。
  • 线程创建失败:文案为 unable to create new native thread。检查 jstack <pid> | grep -c 'java.lang.Thread.State' 和 ulimit -u,判断依据是线程数接近系统上限,或线程池没有设置上限。
  • 容器/宿主层被杀:进程直接消失,应用日志里没有 OOM 异常。检查 kubectl describe pod <pod> 里 Last State 是否显示 OOMKilled、Exit Code 是否为 137,并在宿主机执行 dmesg -T | grep -i -E "killed process|out of memory"。判断依据是内核日志里有杀掉该进程的记录。

Java OOM:先看这四个位置

四类信息源按顺序查,基本能覆盖绝大多数情况:

Java中OOM怎么排查
  1. 应用日志。取完整路径,不要凭印象:grep -n "OutOfMemoryError" /path/to/app.log,同时查看 JVM 崩溃文件 ls -lt /path/to/workdir/hs_err_pid*.log(路径由 -XX:ErrorFile 决定),以及堆转储文件 ls -lh /path/to/dump/java_pid*.hprof(由 -XX:HeapDumpPath 决定)。
  2. 命令输出。先执行 jps -l 拿到 pid,再依次执行 jcmd <pid> VM.flags(确认实际生效参数)、jcmd <pid> GC.heap_info、jstat -gcutil <pid> 1000 20(观察 GC 频率和老年代变化趋势)。
  3. 配置项。容器侧 kubectl get pod <pod> -o jsonpath='{.spec.containers[*].resources}';JVM 侧重点看 -Xms、-Xmx、-XX:MaxMetaspaceSize、-XX:MaxDirectMemorySize、-Xss 是否设置,以及 jcmd <pid> VM.flags 输出的实际值(命令行参数可能被启动脚本覆盖)。
  4. 面板字段。容器内直接读 cgroup:cat /sys/fs/cgroup/memory.max、cat /sys/fs/cgroup/memory.current、cat /sys/fs/cgroup/memory.events(cgroup v1 对应 /sys/fs/cgroup/memory/memory.limit_in_bytes 等);监控面板则关注堆使用量曲线、Full GC 次数、线程数、容器 working set 是否与 limit 贴合。

Java OOM:按可能性从高到低排除

以下是常见成因的排序,每个都给一个能确认或排除它的动作:

Java中OOM怎么排查
  • 堆内对象持续增长(泄漏或缓存无上限)。确认动作:jcmd <pid> GC.heap_dump /data/dump/heap-$(date +%F-%H%M).hprof 导出后,用 MAT 或 jhat 类工具查看 Dominator Tree。处理方向是找出持有链,给缓存加容量上限或过期策略。
  • 单次大对象或大结果集。确认动作:jmap -histo:live <pid> | head -30,看前几名是否为巨量 byte[]、String、HashMap$Node。常见于一次性导出、全表查询、批量文件加载,处理方向是改分页、流式读取。
  • 元空间增长。确认动作:jcmd <pid> VM.metaspace 隔一段时间看两次总量;如果与反射、动态代理、脚本编译相关,先给 -XX:MaxMetaspaceSize 设定明确上限,让问题尽早暴露,而不是无限膨胀。
  • 直接内存。确认动作:对比 jcmd <pid> VM.native_memory summary 的 Internal、Other 与堆占用;未显式设置 -XX:MaxDirectMemorySize 时,建议显式设置,便于观察。
  • 线程数过多。确认动作:jstack <pid> | grep -c 'java.lang.Thread.State' 统计线程总量,并检查线程池是否有界队列。
  • 容器 limit 小于堆内与堆外占用之和。确认动作:把 -Xmx 与 Metaspace、线程栈估算、直接内存相加,与 memory.max 对比,留出余量;否则会表现为 OOMKilled 而不是 Java 异常。

这里有一个容易踩的坑:jmap -histo:live 和带 live 的 dump 会触发 Full GC,业务高峰执行可能把勉强支撑的服务直接压垮,建议在低峰执行并先评估停顿影响。另一个坑是把容器被内核杀掉误判成“不是 OOM”——日志里没有 OutOfMemoryError 不等于没有内存问题,先看 Exit Code 137 和 cgroup 的 oom 计数。

Java OOM:改完怎么确认真的解决了

改参数或改代码后,建议至少做三件事,并覆盖一个完整的业务周期再下结论(例如日常一天加上一次批量任务或流量高峰,具体时长需要结合业务节奏确认):

Java中OOM怎么排查
  1. 确认 GC 行为。JDK 9 及以上加 -Xlog:gc*,gc+heap=debug:file=/data/logs/gc-%t.log:time,uptime:filecount=5,filesize=50m,JDK 8 用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log,然后用 tail -f /data/logs/gc-*.log 观察 Full GC 之后老年代是否明显回落。
  2. 确认占用趋势。定时执行 jstat -gcutil <pid> 60000 60,或抽查 jcmd <pid> GC.heap_info;同时看 cat /sys/fs/cgroup/memory.current 是否持续爬升贴近 memory.max。稳定期内的判断标准是:老年代在 GC 后能回落到合理区间,容器 working set 不呈单向上涨。
  3. 准备回滚。回滚前先留证据:jcmd <pid> GC.heap_dump /data/dump/rollback-$(date +%F-%H%M).hprof;镜像或参数回退用 kubectl rollout undo deployment/<name>,非容器部署则切回上一个 jar 包与旧启动脚本。上线前把旧镜像 tag、旧参数文件、旧配置一并存档,回滚步骤写进变更单,避免现场再拼参数。

需要说明的边界:堆 dump 体积通常接近堆上限,导出前确认磁盘剩余空间(df -h /data),否则失败会掩盖真正问题;另外临时调大 -Xmx 或加 swap 只是争取排查时间的临时手段,不是解决方案,最终仍需回到具体成因上处理。