最后更新: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 exceeded | GC 长时间忙碌但回收很少,结合实际收集器判断 |
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 检查恢复过程,避免把探针引发的重启与内存不足混在一起。
修改后,用同类负载确认问题有没有回来
修复要对应证据:缓存增加上限和淘汰策略,批处理缩小批次,队列设置容量和消费约束,线程池限制并发,资源未释放则修正生命周期;容量不足则在资源总预算内调整配置。
复测时至少对照相近的数据规模、并发和运行时长,观察回收后堆占用、进程或容器内存、线程或类数量,以及错误率、队列积压和重启次数。需要逐渐累积才出现的问题,只跑一次接口不能证明已经解决。
保留修改前后的配置与观察区间。如果只是临时扩容或重启恢复,应在记录中明确这一点,继续追查占用来源。
参考资料
- Oracle:OOM 与内存泄漏排查
- Oracle:JDK 17 jcmd
- Oracle:JDK 21 jcmd
- Oracle:JDK 21 java 启动参数
- Oracle:诊断工具与 NMT
- Eclipse MAT:Dominator Tree
- Eclipse MAT:Shallow 与 Retained Heap
- Kubernetes:容器内存请求与限制
更新记录
- 2026-10-30:整理 OOM 类型、取证顺序、堆与原生内存分支,以及容器退出记录的判断方法;命令按官方资料核对,未对本站生产 JVM 触发 OOM 或执行转储。
Java 服务 OOM 怎么排查
https://java.li/archives/java-oom-troubleshooting
评论