Skip to content

OOM 生产级排查手册:从告警到代码根因

学习目标

读完本页,你不应只会背“导出 dump 用 MAT 分析”,而应能够:

  1. 区分 Java 堆 OOM、GC Overhead、元空间、直接内存、线程创建失败、数组超限和容器 OOMKilled。
  2. 在业务仍受影响时决定先摘流量、先采证还是先重启,并知道每个动作会丢失什么证据。
  3. 使用 JDK 7、JDK 8 常见工具和 JDK 9+ 的 jcmd、统一 GC 日志完成采证。
  4. 用 MAT 从“哪个类多”继续追到“谁持有它、为什么无法回收、对应哪段业务代码”。
  5. 区分内存泄漏、容量不足、瞬时峰值、反压失效和 JVM 外内存超限。
  6. 给出修复、容量核算、压测验证、监控预防的完整闭环。

一、先建立正确模型:OOM 到底是什么

OutOfMemoryError 的准确含义是:JVM 或底层运行环境要申请某种资源,但在当前限制下无法满足。这里的资源不一定是 Java 堆。

mermaid
flowchart TD
    A["申请资源失败"] --> B{"申请的是什么资源"}
    B --> C["Java 堆中的对象或数组"]
    B --> D["类元数据空间"]
    B --> E["直接内存或其他本地内存"]
    B --> F["新的本地线程及线程栈"]
    B --> G["容器整体内存"]
    C --> H["Heap OOM / GC Overhead / Array Limit"]
    D --> I["JDK 7 PermGen / JDK 8+ Metaspace"]
    E --> J["Direct buffer / native memory"]
    F --> K["Unable to create native thread"]
    G --> L["OOMKilled / exit code 137"]

因此,下面这句话是不完整甚至可能有害的:

OOM 就是堆小,把 -Xmx 调大即可。

如果是泄漏,调大堆只会延迟故障;如果是容器总内存超限,调大堆会压缩堆外空间,让进程更快被杀;如果是线程、元空间或直接内存问题,-Xmx 根本不是直接限制项。

二、事故现场的正确执行顺序

2.1 先问四个问题

  1. 当前是单实例故障,还是所有实例同时异常?
  2. 进程还活着吗,还是已经退出或被容器重启?
  3. 是否有健康副本承接流量,故障实例能否从负载均衡摘除?
  4. 当前磁盘、CPU、剩余内存是否允许执行直方图或 heap dump?

2.2 生产处理决策树

mermaid
flowchart TD
    A["收到内存告警或实例重启"] --> B["记录时间、实例、版本、流量和批任务"]
    B --> C{"进程是否还活着"}
    C -- "否" --> D["查退出原因、previous log、事件和自动 dump"]
    C -- "是" --> E{"是否仍在影响业务"}
    E -- "是" --> F["有副本则摘流量,保留故障实例"]
    E -- "否" --> G["保持现场并限制新流量"]
    F --> H["先采低风险证据"]
    G --> H
    H --> I["错误栈、GC、线程、参数、容器指标、对象直方图"]
    I --> J{"证据是否足以定位"}
    J -- "否" --> K{"磁盘和停顿风险可接受吗"}
    K -- "是" --> L["导出 heap dump 或启用专项采样"]
    K -- "否" --> M["先扩容止损并保留已有证据"]
    J -- "是" --> N["定位代码和容量根因"]
    L --> N
    M --> N
    N --> O["修复、压测、灰度、监控和复盘"]

2.3 两种事故状态必须使用两套排查方案

OOM 发生后,生产现场可以先按“系统是否还能运行”分成两类。这里的“系统崩了”要继续确认是 Java 进程退出、容器被重启,还是进程存在但服务已经完全不可用。

状态还能做什么核心思路
系统已经崩溃不能再对原进程执行 jstatjstackjcmd、Arthas依赖事前自动保存的 dump、GC 日志、应用日志、监控、容器事件和 core 文件还原现场
系统仍在运行可以在线查看 GC、线程、对象、类加载器和堆外内存先摘流量和低风险采证,再判断是否允许 dump,防止诊断操作压垮濒危实例

必须先记住:

抛出 OutOfMemoryError 不等于 JVM 必然立即退出。

OutOfMemoryErrorError。如果 OOM 只发生在某个请求线程、任务线程或分配路径中,该线程的调用可能失败并结束,但其他线程和 JVM 进程可能继续运行。系统也可能表现为“端口还在,但大量请求超时、Full GC 频繁、线程池堆积”,属于技术上存活、业务上濒死。

相反,下面几种情况更容易直接退出或被杀:

  • 容器内存超过 cgroup limit,被内核杀死,显示 OOMKilled 或退出码 137;
  • 启用了遇到 OOM 主动退出或崩溃的 JVM 参数;
  • OOM 出现在关键线程或关键基础设施中,最终触发进程退出;
  • 外部守护进程、Kubernetes 存活探针因服务失去响应而重启实例;
  • 应用错误地捕获 OOM 后继续运行,但随后再次 OOM、长时间 Full GC 或健康检查失败。

2.4 场景一:系统已经崩溃怎么排查

系统崩溃后,原进程内的动态状态已经消失。此时再执行下面命令没有意义:

bash
jstat -gcutil <old-pid>
jstack <old-pid>
jcmd <old-pid> GC.class_histogram
java -jar arthas-boot.jar

正确流程是“确认死亡方式 → 收集遗留物 → 对齐时间线 → 离线分析 → 用新实例恢复业务”。

mermaid
flowchart TD
    A["发现实例退出或容器重启"] --> B["记录实例、退出时间、版本和节点"]
    B --> C{"死亡方式是什么"}
    C -- "Java OOM 后退出" --> D["查 OOM 栈、自动 hprof 和 GC 日志"]
    C -- "OOMKilled / 137" --> E["查 Pod 事件、previous log、RSS 和 limit"]
    C -- "JVM 崩溃" --> F["查 hs_err、core 和系统日志"]
    C -- "探针重启" --> G["查探针失败、Full GC 和接口超时"]
    D --> H["按时间戳关联请求、批任务、MQ Lag 和发布事件"]
    E --> H
    F --> H
    G --> H
    H --> I["MAT / GC 日志 / NMT 历史指标离线分析"]
    I --> J["新实例恢复流量,同时保留故障文件"]
    J --> K["复现、修复、压测和预防"]

第一步:确认它为什么死

裸机或虚拟机先查应用退出日志、服务管理器和内核日志。Docker/Kubernetes 重点查:

bash
kubectl describe pod <pod-name>
kubectl logs <pod-name> --previous
kubectl get pod <pod-name> -o yaml

判断表:

证据更可能的结论
应用日志有 Java heap space,同时生成 .hprofJVM 内部堆分配失败,随后进程退出或被重启
Pod Reason: OOMKilledExit Code: 137cgroup 总内存超限,未必有 Java OOM 栈
hs_err_pid*.logJVM native crash,不应直接等同普通 Java 堆 OOM
退出码正常但探针连续失败可能是平台重启、发布退出或健康检查在 Full GC/阻塞期间失败
什么文件都没有事前留证不足,需要依赖监控时间序列,并补齐自动采证配置

第二步:收集崩溃后还能留下的证据

  • *.hprof:用 MAT 分析堆对象和 GC Roots;
  • GC 日志:看崩溃前 Old 区趋势、Full GC 次数、暂停和回收效果;
  • 应用日志:看 OOM 文本、首次异常线程和业务入口;
  • hs_err_pid*.log:看 JVM 崩溃信号、问题线程、本地库和内存映射;
  • APM 与监控:看 heap、RSS、direct、metaspace、线程数、请求量、P99;
  • 容器事件:看 limit、working set、OOMKilled 和重启次数;
  • 业务指标:看导出任务、批处理、MQ Lag、缓存大小和发布变更。

不要只分析 OOM 发生后的最后一条日志。真正有价值的是构造时间线:

text
10:00 发布新版本
10:05 MQ 消费耗时开始增加
10:10 线程池队列持续上涨
10:15 Old 区基线从 45% 上升到 80%
10:18 开始连续 Full GC
10:20 Java heap space,实例退出

这个时间线能把“内存结果”和“业务起因”连接起来。

第三步:如果没有 dump 怎么办

没有 dump 不等于完全无法排查,但结论可信度会下降。可以交叉使用:

  1. GC 日志判断是持续泄漏趋势还是瞬时峰值;
  2. APM 分配热点、历史类实例或内存池指标;
  3. OOM 异常栈定位最后一次失败的分配入口;
  4. MQ、线程池、缓存、导出行数等业务指标;
  5. 对同版本、相同数据量的隔离实例做受控复现并采 dump。

不能仅凭异常栈认定根因。异常栈只说明“最后在哪次分配时没有空间”,真正占满内存的对象可能由其他线程和更早的业务持有。

第四步:恢复业务时不要破坏证据

可以拉起新实例或扩容恢复流量,但不要覆盖原 dump、GC 日志和容器事件。若原实例磁盘是临时卷,Pod 删除后文件会消失,应先按公司规范复制到受控诊断位置。heap dump 可能包含密码、token、身份证号、请求体等敏感数据,必须限制下载和访问。

2.5 场景二:系统还在运行怎么排查

系统仍运行时,最大的优势是还能看到实时状态;最大的风险是 JVM 可能只剩很少内存,错误的诊断操作会触发长时间 STW、磁盘打满或第二次 OOM。

正确顺序是“判断业务可用性 → 摘流量 → 低风险采证 → 高风险采证 → 决定重启”。

mermaid
flowchart TD
    A["进程仍存在"] --> B{"业务是否还能正常响应"}
    B -- "基本正常" --> C["限制新增高风险任务并保留现场"]
    B -- "严重超时" --> D["有健康副本时立即摘流量"]
    C --> E["先采参数、GC、线程和容器指标"]
    D --> E
    E --> F["获取普通对象直方图"]
    F --> G{"是否已能判断区域和增长来源"}
    G -- "是" --> H["保存证据后决定恢复或重启"]
    G -- "否" --> I{"dump 风险是否可接受"}
    I -- "是" --> J["导出 heap dump 后离线 MAT 分析"]
    I -- "否" --> K["不强行 dump,先止损并依靠已有证据"]
    J --> H
    K --> H

第一步:判断是“真正常”还是“假存活”

进程存在、端口监听不代表系统健康。要同时看:

  • 健康检查是否真正执行了数据库、线程池等关键路径;
  • 请求成功率、P95/P99、超时和拒绝数量;
  • Full GC 是否连续发生,GC 后 Old 区是否下降;
  • 工作线程是否都在等待锁、连接池或下游;
  • 线程池队列、MQ 内部缓冲、缓存大小是否继续增长;
  • 容器 RSS 是否逼近 limit。

如果服务不断 Full GC,偶尔才能响应健康检查,它属于“JVM 还活着,业务已经接近不可用”。这时优先摘流量,而不是继续让用户请求扩大现场。

第二步:先执行低风险命令

bash
jcmd <pid> VM.flags
jstat -gcutil <pid> 1000 20
jcmd <pid> Thread.print > /data/diag/thread.txt
jcmd <pid> GC.class_histogram > /data/diag/histo.txt

Arthas 可以使用:

bash
dashboard
jvm
memory
thread -n 10

先不要直接执行 jmap -histo:liveheapdump --live、无限制 watch 或高频 tracelive 统计通常需要触发 Full GC,高频增强命令也会增加 CPU 和输出压力。

第三步:决定是否导出 dump

满足以下条件再考虑:

  • 实例已经摘流量或有足够副本;
  • dump 目录空间足够且不是系统关键盘;
  • 能接受可能的 STW 和大量磁盘 IO;
  • 已经保存线程、GC、参数和容器指标;
  • dump 有敏感数据保护措施。

如果实例堆是几十 GB、磁盘不足、GC 已濒临失控,强行 dump 可能弊大于利。这时可以优先保留低风险证据后重启止损,在隔离环境复现,或者依赖事先自动生成的 OOM dump。

第四步:为什么系统还能运行也不能一直拖

业务线程抛 OOM 后,线程可能终止,但 JVM 里的其他线程继续工作。如果内存压力来自持续泄漏或无界队列,释放少量临时对象后系统可能短暂恢复,随后再次 OOM。继续承载流量可能造成:

  • 请求重复、事务结果不确定;
  • MQ 消费成功但 ACK 或状态更新失败;
  • 缓存和本地状态部分更新;
  • 日志、监控、告警本身因分配失败而丢失;
  • Full GC 风暴拖垮整个实例。

所以“系统还在运行”代表有机会在线采证,不代表可以忽略 OOM 后继续长期服务。

2.6 怎样提前为系统崩溃场景留证

JDK 7、JDK 8 项目至少应配置自动 heap dump 和 GC 日志。JDK 8 示例:

bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/gc.log

JDK 9+ 使用统一日志:

bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags

部分 JDK 8 更新版本及后续 JDK 还支持遇到 OOM 主动退出或崩溃的选项,例如:

bash
-XX:+ExitOnOutOfMemoryError
-XX:+CrashOnOutOfMemoryError

二者不能随便同时当作固定模板:

  • ExitOnOutOfMemoryError 倾向于在 OOM 后退出,让编排平台拉起健康实例;
  • CrashOnOutOfMemoryError 会让 JVM 崩溃并产生崩溃诊断信息,影响和文件体积更大;
  • 是否支持取决于实际 JDK 厂商和更新版本,部署前应执行 java -XX:+PrintFlagsFinal -version 或在测试环境验证;
  • 自动重启只能恢复服务,不能替代 dump、日志、监控和根因修复。

还要为 dump 目录设置磁盘容量告警、文件保留策略和访问权限,否则自动 dump 可能把磁盘写满。

2.7 为什么不能一上来 dump

heap dump 需要遍历堆并写出大量数据,文件大小可能接近已用堆大小。不同 JVM、堆大小和系统负载下,可能产生明显停顿和磁盘 IO。磁盘被打满还会连带造成日志无法写入、数据库或同机服务异常。因此必须先确认:

  • dump 目录不是容量很小的系统盘。
  • 可用空间至少能覆盖预估 dump,并留出安全余量。
  • 故障实例已经摘流量或业务允许暂停。
  • dump 文件中的用户数据、令牌、请求体等敏感信息有访问控制。

三、第一轮:五分钟内完成低风险采证

这一章从“我刚收到报警,手上什么都没有”开始。不要先背 OOM 类型表,先通过下面步骤找到故障对象、判断进程状态、找到日志,再从真实输出中识别 OOM 类型。

3.0 从告警开始:第一分钟到底做什么

先在工单或诊断记录中写下五项信息,避免多实例、多版本环境查错机器:

text
告警时间:精确到秒,并注明时区
服务名称:例如 order-service
实例标识:Pod 名、容器 ID、主机名或 IP
应用版本:镜像 tag、Git commit 或发布批次
告警内容:JVM heap、容器 memory、重启、接口超时还是日志 OOM

然后只回答三个问题:

text
问题一:故障发生在哪个实例?
问题二:原 Java 进程还在不在?
问题三:OOM 证据来自应用日志、容器事件、GC 日志还是监控?
mermaid
flowchart TD
    A["收到内存告警或服务不可用"] --> B["从监控确认服务和实例"]
    B --> C["记录告警时间、版本和实例标识"]
    C --> D{"部署在哪里"}
    D --> E["Kubernetes"]
    D --> F["Docker"]
    D --> G["Linux / systemd"]
    D --> H["Windows 服务或进程"]
    E --> I["确认 Pod 状态、重启次数和 OOMKilled"]
    F --> J["确认容器状态、退出码和日志"]
    G --> K["确认 Java PID、服务状态和系统日志"]
    H --> L["确认 java.exe、事件日志和应用日志"]
    I --> M["获得错误文本或确认没有 Java 错误文本"]
    J --> M
    K --> M
    L --> M
    M --> N["按证据分类 OOM,再选择专项工具"]

3.1 Kubernetes:从哪里获取 OOM 信息

第一步:找到具体 Pod

如果告警只给了服务名,先列出 Pod:

bash
kubectl get pod -n <namespace> -o wide
kubectl get pod -n <namespace> -l app=order-service -o wide

重点记录:

  • Pod 名;
  • 所在 Node;
  • STATUS
  • RESTARTS
  • Pod IP;
  • 是否同一批 Pod 都在重启。

如果一个 Pod 包含多个容器,先列出容器名:

bash
kubectl get pod <pod> -n <namespace> \
  -o jsonpath='{.spec.containers[*].name}'

后续命令必须通过 -c <container> 指定真正运行 Java 的容器,避免查到 sidecar。

第二步:先看是不是 OOMKilled

bash
kubectl describe pod <pod> -n <namespace>

在输出中找:

text
Containers:
  order-service:
    State:          Running
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
    Restart Count:  3
Events:

看到 Reason: OOMKilled 和常见的 Exit Code: 137,可以先得出:

原容器曾经超过 cgroup 内存限制,被操作系统杀死。这是容器总内存问题,不能仅凭它认定 Java 堆 OOM。

此时下一步是获取上一个容器日志,而不是对新容器的 PID 做 dump:

bash
kubectl logs <pod> -n <namespace> -c <container> --previous \
  --timestamps > previous-app.log

为什么必须加 --previous:Pod 重启后,不加它读取的是新容器日志,而 OOM 发生在已经退出的上一个容器。

如果 --previous 没有内容,可能是:

  • 日志只输出到文件,没有输出到 stdout;
  • 容器已经重启多次,Kubernetes 通常只保留紧邻的上一个容器日志;
  • 日志已由采集系统转走,应到 ELK、Loki、云日志平台按 Pod 名和时间查询;
  • 节点日志轮转后已经丢失。

第三步:搜索 Java OOM 文本

在日志平台按“服务 + Pod + 时间范围”搜索,下列关键词不要只搜一个:

text
OutOfMemoryError
Java heap space
GC overhead limit exceeded
Metaspace
PermGen space
Direct buffer memory
unable to create native thread
Requested array size exceeds VM limit
Killed
OOMKilled

已下载日志在 Linux/macOS 上可以搜索:

bash
grep -nE "OutOfMemoryError|Java heap space|GC overhead|Metaspace|PermGen|Direct buffer memory|unable to create native thread|Requested array size" previous-app.log

PowerShell 搜索:

powershell
Select-String -Path .\previous-app.log `
  -Pattern 'OutOfMemoryError|Java heap space|GC overhead|Metaspace|PermGen|Direct buffer memory|unable to create native thread|Requested array size'

如果 Pod 是 OOMKilled,但日志中没有任何 OutOfMemoryError,这并不矛盾。更可能是内核在 JVM 打印异常前直接杀死进程,或者超限的是堆外、线程栈等总内存。

第四步:Pod 仍运行时进入容器确认 Java 进程

bash
kubectl exec -n <namespace> -c <container> <pod> -- ps -ef
kubectl exec -n <namespace> -c <container> <pod> -- jcmd -l

常见 Java 容器里 Java 是 PID 1:

text
1 /opt/java/bin/java -Xms... -Xmx... -jar app.jar

不要默认所有容器都是 PID 1,也不要把宿主机 PID 直接当容器 PID。以容器内 jcmd -lps 输出为准。

如果容器没有 psjcmd

  • 镜像可能使用 JRE 或极简镜像,没有完整 JDK 工具;
  • 应使用公司批准的诊断镜像、临时调试容器或节点侧诊断方案;
  • attach 通常要求与目标 JVM 相同用户、可访问同一 PID 命名空间;
  • 不要在事故现场临时从互联网下载未知二进制工具。

获取 PID 后才能执行:

bash
jcmd <pid> VM.command_line
jstat -gcutil <pid> 1000 20
jcmd <pid> Thread.print
jcmd <pid> GC.class_histogram

第五步:获取容器 limit 和实时使用量

bash
kubectl top pod <pod> -n <namespace> --containers
kubectl get pod <pod> -n <namespace> \
  -o jsonpath='{range .spec.containers[*]}{.name}{" requests="}{.resources.requests.memory}{" limits="}{.resources.limits.memory}{"\n"}{end}'

kubectl top 是当前或短周期指标,不能还原已经发生的峰值。历史峰值应到 Prometheus、Grafana 或云监控查询,例如容器 working set、RSS、limit 和 restart count。

3.2 Docker:从容器状态到错误文本

先找到容器:

bash
docker ps -a
docker inspect <container-id>

重点查看状态字段:

bash
docker inspect <container-id> \
  --format '{{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} FinishedAt={{.State.FinishedAt}}'

典型输出:

text
exited OOMKilled=true ExitCode=137 FinishedAt=2026-07-12T02:20:00Z

这说明容器被 OOM Killer 杀死,但仍然不能直接说 Java 堆泄漏。继续读取容器日志:

bash
docker logs --timestamps <container-id> > container.log 2>&1
grep -nE "OutOfMemoryError|Java heap space|GC overhead|Metaspace|Direct buffer memory|unable to create native thread" container.log

容器仍运行时:

bash
docker top <container-id>
docker exec <container-id> jcmd -l
docker stats <container-id>

docker stats 只能说明当前容器使用情况;已经退出时,要依赖监控历史和 docker inspect

3.3 Linux / systemd:从服务名找到 PID 和日志

第一步:确认服务和 Java 进程

如果由 systemd 管理:

bash
systemctl status order-service
journalctl -u order-service --since "2026-07-12 10:00:00" \
  --until "2026-07-12 10:30:00"

找 Java 进程优先使用 JDK 工具:

bash
jcmd -l
jps -lv

没有 JDK 工具时:

bash
ps -ef | grep '[j]ava'

grep '[j]ava' 可以避免把 grep 命令自身也匹配出来。找到 PID 后记录完整启动命令:

bash
ps -p <pid> -o pid,ppid,user,lstart,etime,args

第二步:不知道应用日志在哪怎么办

日志位置通常来自以下来源,按顺序查:

  1. systemd unit 的 StandardOutputStandardError
  2. Java 启动参数中的 -Dlogging.file.name-Dlogging.file.path
  3. Spring Boot 配置中的 logging.file.name/path
  4. Logback、Log4j2 配置文件;
  5. 启动脚本中的 > app.log 2>&1nohup.out
  6. 公司统一日志目录,例如 /data/logs/<service>/

查看 systemd 配置:

bash
systemctl cat order-service

查看进程完整命令行:

bash
tr '\0' ' ' < /proc/<pid>/cmdline

查看进程已经打开的日志文件:

bash
lsof -p <pid> | grep -E '\.log|stdout|stderr'

找到日志后按故障时间和关键词搜索:

bash
grep -nE "OutOfMemoryError|Java heap space|GC overhead|Metaspace|PermGen|Direct buffer memory|unable to create native thread|Requested array size" /data/logs/order-service/*.log

第三步:进程已经死了,确认是否被 Linux OOM Killer 杀死

不同发行版和权限下可使用:

bash
journalctl -k --since "2026-07-12 10:00:00" \
  --until "2026-07-12 10:30:00" | grep -iE 'out of memory|killed process|oom'

dmesg -T | grep -iE 'out of memory|killed process|oom'

典型内核日志可能出现:

text
Out of memory: Killed process 12345 (java) total-vm:... anon-rss:...

它说明 Linux 内核杀死了 Java 进程。还要继续查是谁消耗内存、是否有 cgroup 限制,以及该机器是否多个进程争抢内存。

3.4 Windows:从 java.exe、服务和事件日志获取信息

PowerShell 查 Java 进程:

powershell
Get-CimInstance Win32_Process -Filter "Name='java.exe'" |
  Select-Object ProcessId, CreationDate, CommandLine

如果由 Windows 服务管理:

powershell
Get-Service
Get-CimInstance Win32_Service |
  Where-Object { $_.PathName -match 'java|order-service' } |
  Select-Object Name, State, ProcessId, PathName

JDK 工具仍然可以使用:

powershell
jcmd -l
jps -lv
jcmd <pid> VM.command_line
jstat -gcutil <pid> 1000 20
jcmd <pid> Thread.print

应用日志搜索:

powershell
Get-ChildItem D:\logs\order-service -Filter *.log -Recurse |
  Select-String -Pattern 'OutOfMemoryError|Java heap space|GC overhead|Metaspace|PermGen|Direct buffer memory|unable to create native thread|Requested array size'

进程已经退出时,检查 Windows 事件查看器中的 Application、System 记录,或使用:

powershell
Get-WinEvent -LogName Application -MaxEvents 500 |
  Where-Object { $_.TimeCreated -ge (Get-Date).AddHours(-2) } |
  Where-Object { $_.Message -match 'java|OutOfMemory|order-service' }

Windows 上同样需要提前配置 OOM dump 和 GC 日志;进程退出后不能临时 attach 已消失的 JVM。

3.5 拿到错误文本后,如何准确分类

先找完整异常文本和异常栈首行,不要只看监控标题“Java 内存高”。下面是可以直接用于分类的特征。

java.lang.OutOfMemoryError: Java heap space

text
java.lang.OutOfMemoryError: Java heap space
    at com.example.export.OrderExportService.export(...)

结论:某次 Java 堆对象或数组分配失败。下一步看 GC 日志、对象直方图和 heap dump。异常栈是失败分配点,不一定是长期持有者。

GC overhead limit exceeded

text
java.lang.OutOfMemoryError: GC overhead limit exceeded

结论:JVM 花费绝大多数时间 GC,却只能回收很少空间。按 Heap OOM 排查,但要重点确认连续 Full GC 和回收收益。

JDK 7 PermGen space

text
java.lang.OutOfMemoryError: PermGen space

结论:JDK 7 HotSpot 永久代耗尽。查类数量、动态代理、热部署和 ClassLoader 无法卸载,不要去 MAT 里只找业务 DTO。

JDK 8+ Metaspace

text
java.lang.OutOfMemoryError: Metaspace

结论:类元数据使用的 Metaspace 无法继续分配。查 VM.classloader_stats、类加载趋势、动态生成类和 ClassLoader 泄漏。

Direct buffer memory

text
java.lang.OutOfMemoryError: Direct buffer memory

结论:NIO 直接缓冲区分配失败。查 MaxDirectMemorySize、NMT、DirectByteBuffer、Netty ByteBuf 生命周期和容器总内存。普通 heap dump 不能完整展示本地字节内容。

unable to create native thread

text
java.lang.OutOfMemoryError: unable to create new native thread

结论:操作系统无法再创建本地线程。立即查线程数量、线程名分布、-Xss、用户进程限制、容器 PID limit 和剩余本地内存。

Requested array size exceeds VM limit

text
java.lang.OutOfMemoryError: Requested array size exceeds VM limit

结论:代码请求的数组长度超过 JVM 可表示或允许的单数组限制。优先查异常栈上的长度计算、全量读取和错误的容量转换;它不一定表示整个堆已经被占满。

只有 OOMKilled / 137,没有 Java 异常

text
Reason: OOMKilled
Exit Code: 137

结论:只能先确认容器总内存超限,不能直接分类为 Heap OOM。继续核对:

text
历史 heap 使用量
容器 RSS / working set
Metaspace
Direct Memory
线程数 × Xss
容器 memory limit
是否有自动 hprof

3.6 如果日志里搜不到 OOM,怎么继续判断

“日志没有 OOM”只代表没有搜到文本,不代表没有内存故障。按下面顺序排除:

  1. 查错实例:日志属于新 Pod,不是发生故障的旧 Pod;
  2. 查错时间:监控使用 UTC,日志使用本地时区;
  3. 查错容器:读到了 sidecar 日志;
  4. 日志在文件中,没有输出 stdout;
  5. 容器被内核直接杀死,JVM 来不及打印;
  6. 日志异步队列在 OOM 时无法刷新;
  7. 实际不是 OOM,而是 Full GC、死锁、CPU 高、节点宕机或平台发布重启。

此时用独立证据判断:

独立证据能说明什么
Pod OOMKilled / Docker OOMKilled=true容器总内存超限
内核 Killed process (java)Linux OOM Killer 杀死进程
heap 长时间接近 max 且 Full GC 回收很少高度怀疑 Heap 压力,但仍需对象证据
RSS 超 limit、heap 明显未满更怀疑堆外、线程栈或其他 native memory
.hprof 文件生成时间与故障一致JVM 至少触发了支持 heap dump 的 OOM 路径
hs_err_pidJVM native crash,按崩溃而非普通 Heap OOM 分析

3.7 最小可执行采证目录

系统还活着时,可以先创建一个按实例和时间隔离的诊断目录,避免多个事故文件互相覆盖:

bash
mkdir -p /data/diag/order-service-20260712-102000

依次保存:

bash
jcmd <pid> VM.command_line \
  > /data/diag/order-service-20260712-102000/command-line.txt

jcmd <pid> VM.flags \
  > /data/diag/order-service-20260712-102000/flags.txt

jstat -gcutil <pid> 1000 20 \
  > /data/diag/order-service-20260712-102000/jstat-gcutil.txt

jcmd <pid> Thread.print \
  > /data/diag/order-service-20260712-102000/threads-1.txt

jcmd <pid> GC.class_histogram \
  > /data/diag/order-service-20260712-102000/histogram.txt

命令执行前确认目录空间和权限。这个目录应同时附带:

text
pod-describe.txt
previous-app.log
故障时间范围内的 gc.log
监控截图或导出数据
发布版本和配置摘要

到这里,才算真正完成“先判断是哪类 OOM”。分类不是凭经验猜,而是由错误文本、平台退出原因、JVM 实时状态和历史监控共同得出。

以下命令中的 <pid> 替换为上面实际找到的 Java 进程号,输出目录应换成真实有空间且受控的目录。

3.8 保存进程、版本和启动参数

bash
jps -lv
jcmd <pid> VM.version
jcmd <pid> VM.command_line
jcmd <pid> VM.flags
jcmd <pid> VM.system_properties

JDK 7 环境如果目标命令不可用或功能不完整,可结合:

bash
jinfo -flags <pid>
jmap -heap <pid>

要记录 -Xms-Xmx-Xss、PermGen/Metaspace 上限、直接内存上限、GC 收集器、dump 和 GC 日志配置。没有这些信息,就无法判断是泄漏还是预算配置错误。

3.9 保存 GC 趋势

bash
jstat -gcutil <pid> 1000 20
jstat -gccapacity <pid> 1000 10

不要只看某一行,要看趋势:

现象初步解释不能直接下的结论
Old 持续上升,Full GC 后也降不下去长生命周期对象不断增加或存活集已经过大不能只凭这一项断定“内存泄漏”
Old 在批任务期间陡增,任务结束后明显下降瞬时峰值或批处理工作集过大不一定存在永久泄漏
Young GC 很频繁但 Old 稳定分配速率高、年轻代偏小或短命对象多不等于堆马上 OOM
Full GC 次数快速增加且每次回收很少堆接近极限,GC 已无法腾出足够空间仍需对象证据说明谁占用

3.10 连续抓三份线程栈

bash
jcmd <pid> Thread.print > /data/diag/thread-1.txt
# 间隔约 3 到 5 秒后再次执行
jcmd <pid> Thread.print > /data/diag/thread-2.txt
jcmd <pid> Thread.print > /data/diag/thread-3.txt

JDK 7/8 常见替代:

bash
jstack -l <pid> > /data/diag/thread.txt

连续三份的意义是区分“瞬时经过某方法”和“长期卡在同一调用点”。重点寻找:

  • 大量线程等待数据库连接、HTTP 响应、Redis、MQ ACK。
  • 大量任务卡在 Future.getjoin、锁或有界资源池。
  • 线程池消费者变慢,队列里的任务对象持续堆积。
  • 线程名数量异常,是否每次请求都创建新线程。

3.11 获取对象直方图

bash
jcmd <pid> GC.class_histogram > /data/diag/histo.txt

JDK 7/8 也常用:

bash
jmap -histo <pid> > /data/diag/histo.txt
jmap -histo:live <pid> > /data/diag/histo-live.txt

live 通常需要 Full GC 才能只统计存活对象,生产风险更高。先看普通直方图,再决定是否需要 live 或 dump。

直方图只能回答“某类有多少实例、浅大小合计多少”,不能回答“谁持有它”。看到 byte[] 第一名不能直接说 byte 数组泄漏,因为字符串、网络报文、压缩、图片、序列化和文件缓冲最终都可能表现为 byte[]

四、按错误文本分流,不要拿同一套命令硬查

错误或平台现象资源区域首要证据常见根因
Java heap spaceJava 堆GC 日志、直方图、heap dump无界缓存、集合、队列、全量查询、大对象峰值
GC overhead limit exceededJava 堆Full GC 频率和回收收益堆几乎耗尽,GC 花大量时间却回收极少
Requested array size exceeds VM limit单个数组申请异常栈和请求参数错误长度计算、一次性读取、超大结果集
PermGen spaceJDK 7 永久代类数量、ClassLoader热部署、动态类、类加载器泄漏
MetaspaceJDK 8+ 元空间classloader stats、NMTCGLIB/ByteBuddy、脚本、插件、ClassLoader 泄漏
Direct buffer memory直接内存NMT、direct pool、Netty 指标DirectByteBuffer 或 ByteBuf 未释放、上限过小
unable to create native thread本地线程和栈线程数、-Xss、OS 限制无界建线程、阻塞导致线程膨胀、pid 限制
OOMKilled / 137容器总内存Pod 事件、RSS、cgroup limit堆外预算不足、limit 太小、内核直接杀进程

五、Heap OOM:从“对象很多”追到引用链根因

5.1 先区分泄漏与峰值

  • 泄漏:本应失效的对象仍被错误引用,基线随时间持续抬高。例如静态 Map 没有淘汰、监听器没有注销、ThreadLocal 未清理。
  • 容量不足:对象都有效,但业务正常工作集本来就大于堆。例如同时缓存 500 万个有效商品。
  • 瞬时峰值:平时正常,某次导出、聚合、反序列化同时存在输入、领域对象和输出字节数组,多份数据叠加超过堆。
  • 反压失效:生产速度长期高于消费速度,无界线程池队列、内存队列或批次缓存不断增长。

修复方向完全不同:泄漏要断错误引用;容量不足要重新设计数据驻留或扩容;峰值要流式和分批;反压失效要有界队列、限流、拒绝和下游治理。

5.2 导出 heap dump

bash
jcmd <pid> GC.heap_dump /data/dump/app.hprof

JDK 7/8 常见方式:

bash
jmap -dump:format=b,file=/data/dump/app.hprof <pid>

更重要的是提前配置自动留证:

bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump

5.3 MAT 的正确分析顺序

mermaid
flowchart TD
    A["打开 hprof"] --> B["确认 dump 时间、堆大小和 JVM 信息"]
    B --> C["Histogram 找异常类和实例数量"]
    C --> D["Dominator Tree 按 Retained Heap 排序"]
    D --> E["展开支配对象和集合内容"]
    E --> F["Path To GC Roots 排除弱引用等路径"]
    F --> G["定位静态字段、线程、ClassLoader、队列或缓存"]
    G --> H["映射到代码入口和业务事件"]
    H --> I["结合 GC、线程栈、日志验证假设"]

必须理解四个概念:

概念准确含义用途
Shallow Heap对象自身直接占用的内存,不含它引用的对象看对象本体大小
Retained Heap如果该对象被回收,预计可连带释放的对象总量找真正的内存支配者
Dominator Tree如果从 GC Roots 到对象 B 的所有路径都经过 A,则 A 支配 B找“小对象持有巨大对象图”的入口
Path To GC Roots从目标对象反向寻找仍可达的 GC Root 路径回答对象为什么活着

例如一个 ConcurrentHashMap 自身 Shallow Heap 很小,但它通过节点持有 200 万个 DTO,Retained Heap 可能有数 GB。真正根因是持有 Map 的静态字段,而不是“DTO 类设计有问题”。

5.4 常见引用链如何翻译成代码根因

text
System Class -> LocalCache.CACHE -> ConcurrentHashMap -> Node -> OrderDTO

含义:静态字段是 GC Root 可达的,缓存没有容量或过期边界。

text
Thread -> ThreadLocalMap -> Entry -> value -> UserContext / byte[]

含义:线程池线程长期存活,请求结束没有在 finallyremove()

text
ThreadPoolExecutor -> workQueue -> Node -> Runnable -> request payload

含义:线程池消费能力低于提交速度,无界队列把请求对象留在堆里。

text
ExportThread -> ArrayList -> OrderDTO -> String / byte[]

含义:全量查询和生成文件在同一时间保留多份数据,是峰值设计问题,不一定是永久泄漏。

六、Metaspace / PermGen OOM

JDK 7 HotSpot 常见永久代,JDK 8 移除永久代并使用本地内存中的 Metaspace。变化的是实现位置,不是“类元数据再也不会 OOM”。

bash
jcmd <pid> VM.classloader_stats
jcmd <pid> GC.class_histogram
jstat -class <pid> 1000 10

JDK 8 若启动时开启 NMT:

bash
-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary

分析重点:类加载总数是否持续上涨、同名 ClassLoader 是否不断出现、旧应用 ClassLoader 是否因线程、ThreadLocal、JDBC Driver、日志框架或定时器引用而无法卸载。仅增大 MaxMetaspaceSize 不能修复 ClassLoader 泄漏。

七、Direct Memory 与 native memory

直接内存不在 Java 堆中,因此 heap dump 可能只看到很小的 DirectByteBuffer 包装对象,却看不到对应本地内存的完整字节内容。

bash
jcmd <pid> VM.native_memory summary
jcmd <pid> VM.native_memory detail

NMT 必须在进程启动时开启,事故发生后不能完整追溯未记录的历史:

bash
-XX:NativeMemoryTracking=summary

Netty 场景还应检查池化分配器指标、ByteBuf 的 retain/release 是否配对、异常分支是否遗漏释放。泄漏检测可在受控环境临时提高级别,但高级检测会增加开销,不能长期无评估地全量开启。

八、Unable to create native thread

创建线程不只是创建一个 Java Thread 对象,操作系统还要创建本地线程并为线程栈等结构预留地址空间和内存。

bash
ps -eLf | grep java | wc -l
jcmd <pid> Thread.print
ulimit -u

容器还需检查 PID 限制、内存 limit。粗略预算不能只算 线程数 × Xss,但这个乘积足以提醒你:500 个线程、每线程 1 MB 栈,仅栈预算就可能接近 500 MB,此外还有堆、元空间、直接内存和 JVM 本地结构。

根因通常不是“系统线程上限太低”这么简单,还要追为什么线程数增加:是否每请求建线程、下游无超时导致线程长期阻塞、线程池 maximumPoolSize 过大、SynchronousQueue 在拥塞时不断促使线程池扩线程。

九、容器 OOMKilled:为什么没有 Java OOM 日志

当进程 RSS 等内存计入 cgroup 后超过容器限制,内核可能直接杀死进程。JVM 没有机会抛 Java 异常,也可能来不及生成 heap dump。

bash
kubectl describe pod <pod-name>
kubectl logs <pod-name> --previous
kubectl top pod <pod-name>

总预算至少要考虑:

text
容器内存 > Java Heap
         + Metaspace / Compressed Class Space
         + Direct Memory
         + 线程栈和线程本地结构
         + Code Cache
         + GC / JVM native structures
         + JNI / 动态库 / mmap
         + 安全余量

例如容器限制 2 GiB,却设置 -Xmx1800m,剩余约 248 MiB 很可能不够元空间、线程栈、DirectBuffer 和 JVM 自身使用。此时“堆还没满但 Pod 被杀”完全合理。

十、Arthas 在线排查顺序

Arthas 适合进程尚存活时缩小范围,不等于所有命令都无风险。

bash
dashboard
jvm
memory
thread -n 10
thread --state BLOCKED

怀疑静态缓存:

bash
ognl '@com.example.cache.LocalCache@CACHE.size()'

怀疑特定类:

bash
vmtool --action getInstances --className com.example.OrderDTO --limit 5

需要离线引用链:

bash
heapdump /data/dump/arthas.hprof

watchtracettvmtoolognl 都要限制次数、深度和匹配范围。高频方法全量观察会制造额外 CPU、输出和内存压力;OGNL 只做经过审批的只读检查。

十一、一个完整商业案例:MQ 消费变慢导致堆 OOM

现象:活动期间订单消息进入速度为 5000 条/秒,数据库抖动后消费者只能处理 1200 条/秒。应用内部又使用无界线程池队列预取消息。扩消费者短暂有效,随后数据库连接池和数据库 CPU 饱和,单条耗时继续上升,队列重新堆积并最终 OOM。

证据链:

  1. MQ Lag 按分区持续上涨,不是只有个别分区热点。
  2. 应用线程池 queue size 持续上涨,完成 TPS 低于提交 TPS。
  3. 三份线程栈显示工作线程长期等待数据库连接或慢 SQL。
  4. Histogram 中业务消息、Runnable、LinkedBlockingQueue$Node 数量异常。
  5. MAT 引用链落到 ThreadPoolExecutor.workQueue

为什么扩消费者只短暂有效:消费者增加也增加了数据库并发;下游饱和后单任务耗时变长,单实例消费能力反而下降。系统瓶颈没有消失,只是从 MQ 消费端转移到了数据库。

修复不是单纯继续加消费者,而是:限制预取和内存队列、线程池有界、慢 SQL 和索引优化、批量写入、数据库容量治理、按下游承载能力限流、失败重试退避、积压消息分阶段回放。

十二、修复后怎么证明真的好了

修完代码并不等于结束。至少验证:

  • 相同数据量和并发下,Old 区基线是否稳定。
  • Full GC 后存活集是否回落,GC 暂停是否满足目标。
  • 队列长度、缓存大小、线程数是否有明确上限。
  • 堆、Direct Memory、Metaspace、RSS 和容器 limit 是否都有监控。
  • dump 自动生成目录是否有容量告警、轮转和访问控制。
  • 故障场景是否加入压测:大导出、慢下游、MQ 积压、大文件、动态类加载。

十三、面试标准回答与追问

线上 OOM 怎么排查

我先记录故障时间、实例、版本和流量,确认进程是否存活以及是 Java OOM 还是容器 OOMKilled。有副本时先摘除故障实例保现场。然后按错误文本分类:Heap、GC Overhead、PermGen/Metaspace、Direct Memory、native thread 或 137。先采集启动参数、GC 趋势、连续线程栈、容器事件和对象直方图;确认磁盘与停顿风险后再导出 heap dump。堆问题用 MAT 的 Histogram、Dominator Tree、Retained Heap 和 Path To GC Roots,把大对象追到静态缓存、ThreadLocal、线程池队列、全量查询等代码根因。堆外问题用 NMT、线程数、Netty 指标和容器 RSS。最后区分泄漏、峰值、容量不足或反压失效,完成代码修复、容量核算、压测、灰度和监控闭环。

MAT 找到 byte[] 第一名,能否直接认定泄漏

不能。byte[] 是很多上层对象的底层载体。必须查看 Dominator Tree 和 Path To GC Roots,确认它由哪个缓存、请求、队列、线程或 ClassLoader 持有,并结合业务时间线和 GC 趋势证明它不该存活或峰值不合理。

Full GC 后 Old 仍为 95%,说明 GC 有 bug 吗

通常不能这样判断。GC 只能回收不可达对象;如果 95% 的对象仍可从 GC Roots 到达,或者正常存活集本来就很大,GC 没有权利回收。要用 dump 和引用链判断为什么仍然可达。

为什么 heap dump 正常,Pod 仍然 OOMKilled

heap dump 主要描述 Java 堆对象,Pod 限制约束的是进程总内存。DirectBuffer、Metaspace、线程栈、Code Cache、GC 本地结构、JNI 和 mmap 都可能把 RSS 推过 limit,因此要结合 NMT、线程数、进程和 cgroup 指标分析。

关联知识点