OOM 生产级排查手册:从告警到代码根因
学习目标
读完本页,你不应只会背“导出 dump 用 MAT 分析”,而应能够:
- 区分 Java 堆 OOM、GC Overhead、元空间、直接内存、线程创建失败、数组超限和容器 OOMKilled。
- 在业务仍受影响时决定先摘流量、先采证还是先重启,并知道每个动作会丢失什么证据。
- 使用 JDK 7、JDK 8 常见工具和 JDK 9+ 的
jcmd、统一 GC 日志完成采证。 - 用 MAT 从“哪个类多”继续追到“谁持有它、为什么无法回收、对应哪段业务代码”。
- 区分内存泄漏、容量不足、瞬时峰值、反压失效和 JVM 外内存超限。
- 给出修复、容量核算、压测验证、监控预防的完整闭环。
一、先建立正确模型:OOM 到底是什么
OutOfMemoryError 的准确含义是:JVM 或底层运行环境要申请某种资源,但在当前限制下无法满足。这里的资源不一定是 Java 堆。
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 先问四个问题
- 当前是单实例故障,还是所有实例同时异常?
- 进程还活着吗,还是已经退出或被容器重启?
- 是否有健康副本承接流量,故障实例能否从负载均衡摘除?
- 当前磁盘、CPU、剩余内存是否允许执行直方图或 heap dump?
2.2 生产处理决策树
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 进程退出、容器被重启,还是进程存在但服务已经完全不可用。
| 状态 | 还能做什么 | 核心思路 |
|---|---|---|
| 系统已经崩溃 | 不能再对原进程执行 jstat、jstack、jcmd、Arthas | 依赖事前自动保存的 dump、GC 日志、应用日志、监控、容器事件和 core 文件还原现场 |
| 系统仍在运行 | 可以在线查看 GC、线程、对象、类加载器和堆外内存 | 先摘流量和低风险采证,再判断是否允许 dump,防止诊断操作压垮濒危实例 |
必须先记住:
抛出
OutOfMemoryError不等于 JVM 必然立即退出。
OutOfMemoryError 是 Error。如果 OOM 只发生在某个请求线程、任务线程或分配路径中,该线程的调用可能失败并结束,但其他线程和 JVM 进程可能继续运行。系统也可能表现为“端口还在,但大量请求超时、Full GC 频繁、线程池堆积”,属于技术上存活、业务上濒死。
相反,下面几种情况更容易直接退出或被杀:
- 容器内存超过 cgroup limit,被内核杀死,显示
OOMKilled或退出码 137; - 启用了遇到 OOM 主动退出或崩溃的 JVM 参数;
- OOM 出现在关键线程或关键基础设施中,最终触发进程退出;
- 外部守护进程、Kubernetes 存活探针因服务失去响应而重启实例;
- 应用错误地捕获 OOM 后继续运行,但随后再次 OOM、长时间 Full GC 或健康检查失败。
2.4 场景一:系统已经崩溃怎么排查
系统崩溃后,原进程内的动态状态已经消失。此时再执行下面命令没有意义:
jstat -gcutil <old-pid>
jstack <old-pid>
jcmd <old-pid> GC.class_histogram
java -jar arthas-boot.jar正确流程是“确认死亡方式 → 收集遗留物 → 对齐时间线 → 离线分析 → 用新实例恢复业务”。
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 重点查:
kubectl describe pod <pod-name>
kubectl logs <pod-name> --previous
kubectl get pod <pod-name> -o yaml判断表:
| 证据 | 更可能的结论 |
|---|---|
应用日志有 Java heap space,同时生成 .hprof | JVM 内部堆分配失败,随后进程退出或被重启 |
Pod Reason: OOMKilled、Exit Code: 137 | cgroup 总内存超限,未必有 Java OOM 栈 |
有 hs_err_pid*.log | JVM 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 发生后的最后一条日志。真正有价值的是构造时间线:
10:00 发布新版本
10:05 MQ 消费耗时开始增加
10:10 线程池队列持续上涨
10:15 Old 区基线从 45% 上升到 80%
10:18 开始连续 Full GC
10:20 Java heap space,实例退出这个时间线能把“内存结果”和“业务起因”连接起来。
第三步:如果没有 dump 怎么办
没有 dump 不等于完全无法排查,但结论可信度会下降。可以交叉使用:
- GC 日志判断是持续泄漏趋势还是瞬时峰值;
- APM 分配热点、历史类实例或内存池指标;
- OOM 异常栈定位最后一次失败的分配入口;
- MQ、线程池、缓存、导出行数等业务指标;
- 对同版本、相同数据量的隔离实例做受控复现并采 dump。
不能仅凭异常栈认定根因。异常栈只说明“最后在哪次分配时没有空间”,真正占满内存的对象可能由其他线程和更早的业务持有。
第四步:恢复业务时不要破坏证据
可以拉起新实例或扩容恢复流量,但不要覆盖原 dump、GC 日志和容器事件。若原实例磁盘是临时卷,Pod 删除后文件会消失,应先按公司规范复制到受控诊断位置。heap dump 可能包含密码、token、身份证号、请求体等敏感数据,必须限制下载和访问。
2.5 场景二:系统还在运行怎么排查
系统仍运行时,最大的优势是还能看到实时状态;最大的风险是 JVM 可能只剩很少内存,错误的诊断操作会触发长时间 STW、磁盘打满或第二次 OOM。
正确顺序是“判断业务可用性 → 摘流量 → 低风险采证 → 高风险采证 → 决定重启”。
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 还活着,业务已经接近不可用”。这时优先摘流量,而不是继续让用户请求扩大现场。
第二步:先执行低风险命令
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.txtArthas 可以使用:
dashboard
jvm
memory
thread -n 10先不要直接执行 jmap -histo:live、heapdump --live、无限制 watch 或高频 trace。live 统计通常需要触发 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 示例:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/gc.logJDK 9+ 使用统一日志:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags部分 JDK 8 更新版本及后续 JDK 还支持遇到 OOM 主动退出或崩溃的选项,例如:
-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 从告警开始:第一分钟到底做什么
先在工单或诊断记录中写下五项信息,避免多实例、多版本环境查错机器:
告警时间:精确到秒,并注明时区
服务名称:例如 order-service
实例标识:Pod 名、容器 ID、主机名或 IP
应用版本:镜像 tag、Git commit 或发布批次
告警内容:JVM heap、容器 memory、重启、接口超时还是日志 OOM然后只回答三个问题:
问题一:故障发生在哪个实例?
问题二:原 Java 进程还在不在?
问题三:OOM 证据来自应用日志、容器事件、GC 日志还是监控?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:
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 包含多个容器,先列出容器名:
kubectl get pod <pod> -n <namespace> \
-o jsonpath='{.spec.containers[*].name}'后续命令必须通过 -c <container> 指定真正运行 Java 的容器,避免查到 sidecar。
第二步:先看是不是 OOMKilled
kubectl describe pod <pod> -n <namespace>在输出中找:
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:
kubectl logs <pod> -n <namespace> -c <container> --previous \
--timestamps > previous-app.log为什么必须加 --previous:Pod 重启后,不加它读取的是新容器日志,而 OOM 发生在已经退出的上一个容器。
如果 --previous 没有内容,可能是:
- 日志只输出到文件,没有输出到 stdout;
- 容器已经重启多次,Kubernetes 通常只保留紧邻的上一个容器日志;
- 日志已由采集系统转走,应到 ELK、Loki、云日志平台按 Pod 名和时间查询;
- 节点日志轮转后已经丢失。
第三步:搜索 Java OOM 文本
在日志平台按“服务 + Pod + 时间范围”搜索,下列关键词不要只搜一个:
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 上可以搜索:
grep -nE "OutOfMemoryError|Java heap space|GC overhead|Metaspace|PermGen|Direct buffer memory|unable to create native thread|Requested array size" previous-app.logPowerShell 搜索:
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 进程
kubectl exec -n <namespace> -c <container> <pod> -- ps -ef
kubectl exec -n <namespace> -c <container> <pod> -- jcmd -l常见 Java 容器里 Java 是 PID 1:
1 /opt/java/bin/java -Xms... -Xmx... -jar app.jar不要默认所有容器都是 PID 1,也不要把宿主机 PID 直接当容器 PID。以容器内 jcmd -l 或 ps 输出为准。
如果容器没有 ps、jcmd:
- 镜像可能使用 JRE 或极简镜像,没有完整 JDK 工具;
- 应使用公司批准的诊断镜像、临时调试容器或节点侧诊断方案;
- attach 通常要求与目标 JVM 相同用户、可访问同一 PID 命名空间;
- 不要在事故现场临时从互联网下载未知二进制工具。
获取 PID 后才能执行:
jcmd <pid> VM.command_line
jstat -gcutil <pid> 1000 20
jcmd <pid> Thread.print
jcmd <pid> GC.class_histogram第五步:获取容器 limit 和实时使用量
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:从容器状态到错误文本
先找到容器:
docker ps -a
docker inspect <container-id>重点查看状态字段:
docker inspect <container-id> \
--format '{{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} FinishedAt={{.State.FinishedAt}}'典型输出:
exited OOMKilled=true ExitCode=137 FinishedAt=2026-07-12T02:20:00Z这说明容器被 OOM Killer 杀死,但仍然不能直接说 Java 堆泄漏。继续读取容器日志:
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容器仍运行时:
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 管理:
systemctl status order-service
journalctl -u order-service --since "2026-07-12 10:00:00" \
--until "2026-07-12 10:30:00"找 Java 进程优先使用 JDK 工具:
jcmd -l
jps -lv没有 JDK 工具时:
ps -ef | grep '[j]ava'grep '[j]ava' 可以避免把 grep 命令自身也匹配出来。找到 PID 后记录完整启动命令:
ps -p <pid> -o pid,ppid,user,lstart,etime,args第二步:不知道应用日志在哪怎么办
日志位置通常来自以下来源,按顺序查:
- systemd unit 的
StandardOutput、StandardError; - Java 启动参数中的
-Dlogging.file.name、-Dlogging.file.path; - Spring Boot 配置中的
logging.file.name/path; - Logback、Log4j2 配置文件;
- 启动脚本中的
> app.log 2>&1或nohup.out; - 公司统一日志目录,例如
/data/logs/<service>/。
查看 systemd 配置:
systemctl cat order-service查看进程完整命令行:
tr '\0' ' ' < /proc/<pid>/cmdline查看进程已经打开的日志文件:
lsof -p <pid> | grep -E '\.log|stdout|stderr'找到日志后按故障时间和关键词搜索:
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 杀死
不同发行版和权限下可使用:
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'典型内核日志可能出现:
Out of memory: Killed process 12345 (java) total-vm:... anon-rss:...它说明 Linux 内核杀死了 Java 进程。还要继续查是谁消耗内存、是否有 cgroup 限制,以及该机器是否多个进程争抢内存。
3.4 Windows:从 java.exe、服务和事件日志获取信息
PowerShell 查 Java 进程:
Get-CimInstance Win32_Process -Filter "Name='java.exe'" |
Select-Object ProcessId, CreationDate, CommandLine如果由 Windows 服务管理:
Get-Service
Get-CimInstance Win32_Service |
Where-Object { $_.PathName -match 'java|order-service' } |
Select-Object Name, State, ProcessId, PathNameJDK 工具仍然可以使用:
jcmd -l
jps -lv
jcmd <pid> VM.command_line
jstat -gcutil <pid> 1000 20
jcmd <pid> Thread.print应用日志搜索:
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 记录,或使用:
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
java.lang.OutOfMemoryError: Java heap space
at com.example.export.OrderExportService.export(...)结论:某次 Java 堆对象或数组分配失败。下一步看 GC 日志、对象直方图和 heap dump。异常栈是失败分配点,不一定是长期持有者。
GC overhead limit exceeded
java.lang.OutOfMemoryError: GC overhead limit exceeded结论:JVM 花费绝大多数时间 GC,却只能回收很少空间。按 Heap OOM 排查,但要重点确认连续 Full GC 和回收收益。
JDK 7 PermGen space
java.lang.OutOfMemoryError: PermGen space结论:JDK 7 HotSpot 永久代耗尽。查类数量、动态代理、热部署和 ClassLoader 无法卸载,不要去 MAT 里只找业务 DTO。
JDK 8+ Metaspace
java.lang.OutOfMemoryError: Metaspace结论:类元数据使用的 Metaspace 无法继续分配。查 VM.classloader_stats、类加载趋势、动态生成类和 ClassLoader 泄漏。
Direct buffer memory
java.lang.OutOfMemoryError: Direct buffer memory结论:NIO 直接缓冲区分配失败。查 MaxDirectMemorySize、NMT、DirectByteBuffer、Netty ByteBuf 生命周期和容器总内存。普通 heap dump 不能完整展示本地字节内容。
unable to create native thread
java.lang.OutOfMemoryError: unable to create new native thread结论:操作系统无法再创建本地线程。立即查线程数量、线程名分布、-Xss、用户进程限制、容器 PID limit 和剩余本地内存。
Requested array size exceeds VM limit
java.lang.OutOfMemoryError: Requested array size exceeds VM limit结论:代码请求的数组长度超过 JVM 可表示或允许的单数组限制。优先查异常栈上的长度计算、全量读取和错误的容量转换;它不一定表示整个堆已经被占满。
只有 OOMKilled / 137,没有 Java 异常
Reason: OOMKilled
Exit Code: 137结论:只能先确认容器总内存超限,不能直接分类为 Heap OOM。继续核对:
历史 heap 使用量
容器 RSS / working set
Metaspace
Direct Memory
线程数 × Xss
容器 memory limit
是否有自动 hprof3.6 如果日志里搜不到 OOM,怎么继续判断
“日志没有 OOM”只代表没有搜到文本,不代表没有内存故障。按下面顺序排除:
- 查错实例:日志属于新 Pod,不是发生故障的旧 Pod;
- 查错时间:监控使用 UTC,日志使用本地时区;
- 查错容器:读到了 sidecar 日志;
- 日志在文件中,没有输出 stdout;
- 容器被内核直接杀死,JVM 来不及打印;
- 日志异步队列在 OOM 时无法刷新;
- 实际不是 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_pid | JVM native crash,按崩溃而非普通 Heap OOM 分析 |
3.7 最小可执行采证目录
系统还活着时,可以先创建一个按实例和时间隔离的诊断目录,避免多个事故文件互相覆盖:
mkdir -p /data/diag/order-service-20260712-102000依次保存:
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命令执行前确认目录空间和权限。这个目录应同时附带:
pod-describe.txt
previous-app.log
故障时间范围内的 gc.log
监控截图或导出数据
发布版本和配置摘要到这里,才算真正完成“先判断是哪类 OOM”。分类不是凭经验猜,而是由错误文本、平台退出原因、JVM 实时状态和历史监控共同得出。
以下命令中的 <pid> 替换为上面实际找到的 Java 进程号,输出目录应换成真实有空间且受控的目录。
3.8 保存进程、版本和启动参数
jps -lv
jcmd <pid> VM.version
jcmd <pid> VM.command_line
jcmd <pid> VM.flags
jcmd <pid> VM.system_propertiesJDK 7 环境如果目标命令不可用或功能不完整,可结合:
jinfo -flags <pid>
jmap -heap <pid>要记录 -Xms、-Xmx、-Xss、PermGen/Metaspace 上限、直接内存上限、GC 收集器、dump 和 GC 日志配置。没有这些信息,就无法判断是泄漏还是预算配置错误。
3.9 保存 GC 趋势
jstat -gcutil <pid> 1000 20
jstat -gccapacity <pid> 1000 10不要只看某一行,要看趋势:
| 现象 | 初步解释 | 不能直接下的结论 |
|---|---|---|
| Old 持续上升,Full GC 后也降不下去 | 长生命周期对象不断增加或存活集已经过大 | 不能只凭这一项断定“内存泄漏” |
| Old 在批任务期间陡增,任务结束后明显下降 | 瞬时峰值或批处理工作集过大 | 不一定存在永久泄漏 |
| Young GC 很频繁但 Old 稳定 | 分配速率高、年轻代偏小或短命对象多 | 不等于堆马上 OOM |
| Full GC 次数快速增加且每次回收很少 | 堆接近极限,GC 已无法腾出足够空间 | 仍需对象证据说明谁占用 |
3.10 连续抓三份线程栈
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.txtJDK 7/8 常见替代:
jstack -l <pid> > /data/diag/thread.txt连续三份的意义是区分“瞬时经过某方法”和“长期卡在同一调用点”。重点寻找:
- 大量线程等待数据库连接、HTTP 响应、Redis、MQ ACK。
- 大量任务卡在
Future.get、join、锁或有界资源池。 - 线程池消费者变慢,队列里的任务对象持续堆积。
- 线程名数量异常,是否每次请求都创建新线程。
3.11 获取对象直方图
jcmd <pid> GC.class_histogram > /data/diag/histo.txtJDK 7/8 也常用:
jmap -histo <pid> > /data/diag/histo.txt
jmap -histo:live <pid> > /data/diag/histo-live.txtlive 通常需要 Full GC 才能只统计存活对象,生产风险更高。先看普通直方图,再决定是否需要 live 或 dump。
直方图只能回答“某类有多少实例、浅大小合计多少”,不能回答“谁持有它”。看到 byte[] 第一名不能直接说 byte 数组泄漏,因为字符串、网络报文、压缩、图片、序列化和文件缓冲最终都可能表现为 byte[]。
四、按错误文本分流,不要拿同一套命令硬查
| 错误或平台现象 | 资源区域 | 首要证据 | 常见根因 |
|---|---|---|---|
Java heap space | Java 堆 | GC 日志、直方图、heap dump | 无界缓存、集合、队列、全量查询、大对象峰值 |
GC overhead limit exceeded | Java 堆 | Full GC 频率和回收收益 | 堆几乎耗尽,GC 花大量时间却回收极少 |
Requested array size exceeds VM limit | 单个数组申请 | 异常栈和请求参数 | 错误长度计算、一次性读取、超大结果集 |
PermGen space | JDK 7 永久代 | 类数量、ClassLoader | 热部署、动态类、类加载器泄漏 |
Metaspace | JDK 8+ 元空间 | classloader stats、NMT | CGLIB/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
jcmd <pid> GC.heap_dump /data/dump/app.hprofJDK 7/8 常见方式:
jmap -dump:format=b,file=/data/dump/app.hprof <pid>更重要的是提前配置自动留证:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump5.3 MAT 的正确分析顺序
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 常见引用链如何翻译成代码根因
System Class -> LocalCache.CACHE -> ConcurrentHashMap -> Node -> OrderDTO含义:静态字段是 GC Root 可达的,缓存没有容量或过期边界。
Thread -> ThreadLocalMap -> Entry -> value -> UserContext / byte[]含义:线程池线程长期存活,请求结束没有在 finally 中 remove()。
ThreadPoolExecutor -> workQueue -> Node -> Runnable -> request payload含义:线程池消费能力低于提交速度,无界队列把请求对象留在堆里。
ExportThread -> ArrayList -> OrderDTO -> String / byte[]含义:全量查询和生成文件在同一时间保留多份数据,是峰值设计问题,不一定是永久泄漏。
六、Metaspace / PermGen OOM
JDK 7 HotSpot 常见永久代,JDK 8 移除永久代并使用本地内存中的 Metaspace。变化的是实现位置,不是“类元数据再也不会 OOM”。
jcmd <pid> VM.classloader_stats
jcmd <pid> GC.class_histogram
jstat -class <pid> 1000 10JDK 8 若启动时开启 NMT:
-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary分析重点:类加载总数是否持续上涨、同名 ClassLoader 是否不断出现、旧应用 ClassLoader 是否因线程、ThreadLocal、JDBC Driver、日志框架或定时器引用而无法卸载。仅增大 MaxMetaspaceSize 不能修复 ClassLoader 泄漏。
七、Direct Memory 与 native memory
直接内存不在 Java 堆中,因此 heap dump 可能只看到很小的 DirectByteBuffer 包装对象,却看不到对应本地内存的完整字节内容。
jcmd <pid> VM.native_memory summary
jcmd <pid> VM.native_memory detailNMT 必须在进程启动时开启,事故发生后不能完整追溯未记录的历史:
-XX:NativeMemoryTracking=summaryNetty 场景还应检查池化分配器指标、ByteBuf 的 retain/release 是否配对、异常分支是否遗漏释放。泄漏检测可在受控环境临时提高级别,但高级检测会增加开销,不能长期无评估地全量开启。
八、Unable to create native thread
创建线程不只是创建一个 Java Thread 对象,操作系统还要创建本地线程并为线程栈等结构预留地址空间和内存。
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。
kubectl describe pod <pod-name>
kubectl logs <pod-name> --previous
kubectl top pod <pod-name>总预算至少要考虑:
容器内存 > 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 适合进程尚存活时缩小范围,不等于所有命令都无风险。
dashboard
jvm
memory
thread -n 10
thread --state BLOCKED怀疑静态缓存:
ognl '@com.example.cache.LocalCache@CACHE.size()'怀疑特定类:
vmtool --action getInstances --className com.example.OrderDTO --limit 5需要离线引用链:
heapdump /data/dump/arthas.hprofwatch、trace、tt、vmtool 和 ognl 都要限制次数、深度和匹配范围。高频方法全量观察会制造额外 CPU、输出和内存压力;OGNL 只做经过审批的只读检查。
十一、一个完整商业案例:MQ 消费变慢导致堆 OOM
现象:活动期间订单消息进入速度为 5000 条/秒,数据库抖动后消费者只能处理 1200 条/秒。应用内部又使用无界线程池队列预取消息。扩消费者短暂有效,随后数据库连接池和数据库 CPU 饱和,单条耗时继续上升,队列重新堆积并最终 OOM。
证据链:
- MQ Lag 按分区持续上涨,不是只有个别分区热点。
- 应用线程池 queue size 持续上涨,完成 TPS 低于提交 TPS。
- 三份线程栈显示工作线程长期等待数据库连接或慢 SQL。
- Histogram 中业务消息、Runnable、
LinkedBlockingQueue$Node数量异常。 - 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 指标分析。
