最后更新:2026-10-30
适用环境:Linux、HotSpot JDK 17/21;容器部分以 Kubernetes 为例

服务突然重启,日志里出现 OOM,先别急着把 -Xmx 翻倍。Java 堆放不下对象、类元数据耗尽、线程创建失败,以及容器被系统杀掉,处理方向并不一样。先确定是哪一种,再决定该取什么现场、改哪一层。

下面的命令使用 Linux Bash。PID、命名空间、Pod 和目录都要替换成实际值;JDK 工具应与目标 JVM 的版本匹配,并在同一主机、相应的进程与文件系统环境中运行,使用目标服务的有效用户和组。容器里不能直接套用宿主机 PID。

先看完整错误,别只截一个 OOM

日志或平台状态优先检查
Java heap space堆容量、存活对象、一次性大批量分配
GC overhead limit exceededGC 长时间忙碌但回收很少,结合实际收集器判断
Metaspace / Compressed class space类数量、类加载器及对应区域限制
Direct buffer memory 或直接缓冲区分配失败NIO 直接内存、缓冲区生命周期及相关客户端
创建 native thread 失败平台线程数量、系统或容器的任务数限制、原生内存
Requested array size exceeds VM limit数组长度计算与 VM 实现限制
Pod 上次退出原因是 OOMKilled容器和节点的内存约束,不能只看 Java 堆

Oracle 的 OOM 排查说明明确区分了堆空间、元空间与数组上限等原因。Java heap space 也不自动等于内存泄漏:业务一次读取过多数据,或者容量配置不足,都可能触发它。

保留异常第一行、完整调用栈、发生时间、实例标识和最近发布版本。有些 OOM 只发生在某个工作线程,进程仍然存在;“JVM 抛错”和“整个实例退出”要分别核对。

进程还在时,先留一份基本现场

先从应用平台或进程列表确认 PID。下面假设目标为 24680,/var/log/java 已由运维预先准备好,服务用户可写,且位于有足够空间的磁盘上。

java_pid=24680
umask 077
case_dir="/var/log/java/oom-$java_pid-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$case_dir" || exit 1

date -Is > "$case_dir/time.txt"
ps -p "$java_pid" -o pid,ppid,nlwp,rss,vsz,etime,args   > "$case_dir/process.txt"
jcmd "$java_pid" VM.version > "$case_dir/vm-version.txt"
jcmd "$java_pid" VM.flags > "$case_dir/vm-flags.txt"
jcmd "$java_pid" GC.heap_info > "$case_dir/heap-info.txt"

把这些资料与同一时段的应用日志、GC 日志、请求量、队列长度和内存曲线放在一起。rss 是进程驻留内存,vsz 是虚拟地址空间,二者都不能直接当成“Java 活对象占用”。

JVM 已经退出后,jcmd 不能再向它取证;应寻找已有 dump、GC 日志和平台的退出记录。若进程仍在但工具连接失败,先核对版本、用户、PID 命名空间和 Attach 是否可用,不要在不明状态下反复执行重型诊断。jcmd 官方手册说明了运行条件和各命令的影响级别。

如果同时出现 CPU 持续升高,可对照 Java 进程 CPU 100% 排查,区分业务计算、GC 压力和线程热点。

Java 堆溢出:看回收后的趋势,再看谁持有对象

从故障前的 GC 日志开始,关注回收前后占用、停顿时间,以及同等业务负载下回收后的占用是否持续抬升。不同收集器的日志和回收阶段不同,不能把一次堆使用率很高就判成泄漏。

几种常见线索可以帮助安排后续检查:

  • 回收后占用仍不断上涨:追查长期存活对象、无上限缓存、任务积压和引用链。
  • 某次导出或查询后突然冲高:核对结果集、分页、批大小、文件读取和临时数组。
  • 业务峰值过去后能回落:先核算峰值并发和容量,不凭峰值本身下泄漏结论。
  • 出现数组 VM 上限错误:先修正数组长度和数据规模,单纯加堆未必有用。

已有 heap dump 时,优先离线分析。没有 dump 且进程还活着,再评估是否值得现场生成。

GC.heap_dump 是高影响操作,默认会请求一次 Full GC,可能带来长停顿和大量磁盘写入。 先按服务可用性安排流量与取证窗口,确认目标 JVM 能看到并写入同一个目录,磁盘空间充足;不要同时给所有实例做 dump,也不要在文件已存在时直接覆盖。

df -h "$case_dir"
jcmd "$java_pid" help GC.heap_dump

# 评估停顿与磁盘影响后,再执行这一行
jcmd "$java_pid" GC.heap_dump "$case_dir/heap-$(date +%Y%m%d-%H%M%S).hprof"

JDK 17 与 21 的命令细节以目标 JVM 的 help 为准;这里没有使用跨版本支持不同的压缩或覆盖选项。dump 中可能包含业务数据和认证信息,应限制访问,并在受控环境分析,不能直接传到公共分析网站。

用 Eclipse Memory Analyzer 等工具打开文件后,先看对象分布,再用 Dominator Tree 找到占用较大的保留对象集合,沿引用链判断为什么它们还活着。MAT 对支配树的说明和 Shallow / Retained Heap 的定义有助于区分“对象自身大小”和“由它保留下来的内存”。

例如,看到大量 byte[],只能说明字节数组占得多。还要追到持有它们的缓存、请求对象、队列或文件处理逻辑,确认数量为什么不能回落,再决定修改点。对象直方图里的“大户”不一定就是泄漏根因。

为下一次启动保留 GC 日志和自动 dump

如果这次重启已经丢失现场,可以在下一次受控发布时补齐启动配置。下面只演示参数写法:app.jar、目录和 1g 都是示例,不能直接当成生产容量建议。目录应是服务用户可写的磁盘或持久卷,避免把大 dump 写进计入容器内存的 tmpfs。

umask 077
diag_dir="/var/log/java/diag-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$diag_dir" || exit 1

java -Xmx1g   -XX:+HeapDumpOnOutOfMemoryError   -XX:HeapDumpPath="$diag_dir"   "-Xlog:gc*:file=$diag_dir/gc-%p-%t.log:time,uptime,level,tags:filecount=5,filesize=20M"   -jar app.jar

这是 JDK 17/21 的统一日志写法,不适合直接复制到 JDK 8。GC 日志按文件轮转,heap dump 并不会因此自动轮转或限制大小。每次启动使用独立目录,还应有容量监控和保留策略;多实例共享卷时需进一步区分实例目录。

自动 dump 依赖 JVM 有机会处理相应的 OOM、文件系统可写且资源尚可使用。操作系统直接杀掉进程、某些原生内存或线程错误,以及磁盘写满等情况,都可能没有 dump。启用了参数却没文件,应先看错误类型和写入结果,不能反推“没发生 OOM”。相关参数见 java 启动命令手册。

堆看起来不满时,按内存区域继续查

-Xmx 只限制 Java 堆,不是整个进程的总内存上限。元空间、线程栈、直接缓冲区、代码缓存、GC 自身结构和原生库,都可能占用额外内存。

Metaspace。 核对实际 MaxMetaspaceSize、类加载数量与类加载器变化。动态生成类、反复创建类加载器却无法释放等方向需要引用证据支持;不能看到这个错误就认定是某个框架泄漏。

直接内存。 核对 NIO BufferPool 的已用量、数量,以及网络或存储客户端自己的缓冲区指标。MaxDirectMemorySize 针对 NIO 直接缓冲区分配,并不封顶所有原生库内存;只看堆 dump 也可能找不到进程 RSS 增长的全部来源。

线程创建失败。 先看平台线程是否持续增加,以及目标进程实际受到的限制:

ps -p "$java_pid" -o pid,nlwp,rss,args
cat "/proc/$java_pid/limits"
jcmd "$java_pid" Thread.print -l

容器或 systemd 还可能有任务数限制,应检查目标服务所属的 cgroup 或单元配置。只在当前登录 shell 执行 ulimit,不能证明目标进程拥有相同限制。线程堆栈会有开销,缩小 -Xss 也可能引入栈溢出,应先解决线程数量失控或资源预算问题。线程一直卡住时,可继续看 Java 线程死锁定位。

如果需要长期区分 JVM 内部内存,可以在后续受控启动中加入 -XX:NativeMemoryTracking=summary。NMT 默认关闭,有额外开销,应先评估;未提前启用的 JVM,不能靠下面的查询补出历史数据。

jcmd "$java_pid" VM.native_memory summary scale=MB
jcmd "$java_pid" VM.native_memory baseline

# 同一进程经过一段有代表性的业务负载后再比较
jcmd "$java_pid" VM.native_memory summary.diff scale=MB

NMT 适合比较 JVM 各分类随时间的变化。reserved、committed 和操作系统 RSS 的统计含义不同,不能直接等同或简单相减后就宣布剩余部分是泄漏;它也不覆盖所有非 JVM 原生代码的分配。具体范围见 Oracle 诊断工具中的 NMT 说明。

容器 OOMKilled:把上次退出记录和内存限制对上

应用日志突然中断,却没有 Java OOM 栈,应检查平台记录。下面需要已有的 kubectl 访问权限,并把三个名称换成真实值;多容器 Pod 要核对发生故障的那一个容器。

namespace=prod
pod=your-pod-name
container=app

kubectl -n "$namespace" get pod "$pod" -o yaml
kubectl -n "$namespace" describe pod "$pod"
kubectl -n "$namespace" logs "$pod" -c "$container" --previous --tail=300

重点对照 containerStatuses 中相应容器的 lastState.terminated,记录 reason、exitCode 和 finishedAt,再看它的 resources.limits.memory、节点事件及故障前内存监控。--previous 取上一个容器实例的日志;Pod 已删除或日志未保留时,需要到日志平台继续找。

Kubernetes 的内存资源说明展示了 OOMKilled 与退出码 137 的例子,但 137 本身不能独立证明 OOM,还可能来自其他 SIGKILL 场景。要结合退出原因和对应时刻的容器、节点证据。当前一次 kubectl top 的低值,也不能排除上次实例曾经达到峰值。

若容器限制是 2Gi,却把 Java 堆上限也设成接近 2Gi,其他内存就缺少余量。应把堆、线程、直接内存与其他开销放到同一预算里,按实际峰值测量留余量;没有适用于所有应用的固定堆占比。

重启后探针还在失败时,可结合 Spring Boot 健康检查 DOWN 检查恢复过程,避免把探针引发的重启与内存不足混在一起。

修改后,用同类负载确认问题有没有回来

修复要对应证据:缓存增加上限和淘汰策略,批处理缩小批次,队列设置容量和消费约束,线程池限制并发,资源未释放则修正生命周期;容量不足则在资源总预算内调整配置。

复测时至少对照相近的数据规模、并发和运行时长,观察回收后堆占用、进程或容器内存、线程或类数量,以及错误率、队列积压和重启次数。需要逐渐累积才出现的问题,只跑一次接口不能证明已经解决。

保留修改前后的配置与观察区间。如果只是临时扩容或重启恢复,应在记录中明确这一点,继续追查占用来源。

参考资料

更新记录

  • 2026-10-30:整理 OOM 类型、取证顺序、堆与原生内存分支,以及容器退出记录的判断方法;命令按官方资料核对,未对本站生产 JVM 触发 OOM 或执行转储。