这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 OOM(OutOfMemoryError)表示 JVM 无法满足某个内存区域的分配或分配相关请求。它不只是“堆不够”,不同 OOM 类型的排查方向完全不同。
27.1 常见类型
| OOM | 含义 | 方向 |
|---|---|---|
| Java heap space | Java 堆不足 | 对象过多、泄漏、堆配置 |
| GC overhead limit exceeded | GC 占用时间过高且回收效果差 | 堆接近耗尽 |
| Metaspace | 类元数据空间不足 | 动态类生成、ClassLoader |
| Unable to create new native thread | 无法创建 Native 线程 | 线程数、栈大小、OS 限制 |
| Direct buffer memory | 直接内存不足 | NIO、Netty、堆外池 |
| Requested array size exceeds VM limit | 数组超过 JVM 限制 | 拆分数据 |
| Out of swap space 或 OS 级 OOM | 交换空间或系统内存不足 | 容器、进程、系统 |
必须先读完整异常信息,再决定排查方向。
27.2 准备证据
生产 OOM 后不要急着重启。应先保留:
- OOM 完整异常栈;
- GC 日志;
- 堆 dump;
- JVM 参数;
- 容器 memory limit;
- 进程 RSS;
- 线程数;
- 元空间和直接内存指标;
- 当时流量和业务日志。
推荐启动配置:
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dump \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/log/gc.log:time,uptime,level,tags:filecount=10,filesize=50m \
-jar app.jar
27.3 Java heap space
完整异常:
java.lang.OutOfMemoryError: Java heap space
排查流程:
1. 看是否伴随突发流量或大请求
2. 分析 GC 日志确认回收后占用
3. 打开堆 dump
4. 找 Retained Heap 最大对象
5. Path to GC Roots 定位持有者
6. 结合源码确认生命周期
7. 修复后压测验证
常见原因:
| 原因 | 特征 |
|---|---|
| 无界缓存 | HashMap/List 持续增长 |
| 大查询 | 一次加载百万行 |
| 大文件 | 全量读入内存 |
| 批量导出 | 累积所有结果 |
| ThreadLocal 泄漏 | 线程池线程持有 |
| 队列积压 | 生产快于消费 |
| 堆配置不足 | Live 数据确实需要更多 |
如果堆 dump 显示大量不可达对象,可能是 OOM 时分配过快,GC 来不及完成,而不是传统泄漏。
27.4 GC overhead limit exceeded
该 OOM 表示 JVM 花了大量时间回收,但释放空间很少。通常说明堆里大量对象仍可达,或堆余量太小。
示例判断:
观察期 GC 时间占比 > 98%
回收后堆占用 > 2%
不同版本行为和参数可能调整,不能只靠字面比例记忆。重点仍是看 GC 日志中回收后占用和停顿分布。
处理:
- 堆 dump 分析存活对象;
- 修复杂质对象或泄漏;
- 增大堆只是临时缓解;
- 降低分配速率;
- 检查缓存上限。
27.5 Metaspace
异常:
java.lang.OutOfMemoryError: Metaspace
常见原因:
- 动态代理生成大量类;
- Groovy、JSP、脚本引擎重复编译;
- 反射调用生成类;
- 热部署导致旧 ClassLoader 无法卸载;
- 类加载器泄漏。
排查:
jcmd <pid> VM.metaspace
jcmd <pid> GC.class_histogram
堆 dump 中查看:
- 类数量;
- ClassLoader 数量;
- 同名类被多少 Loader 加载;
- Class 对象 Retained Heap;
- 引用链指向谁。
处理方向:
- 限制动态类生成;
- 复用脚本编译结果;
- 检查热部署释放;
- 清理全局注册回调;
- 合理设置
MaxMetaspaceSize。
27.6 无法创建线程
异常:
java.lang.OutOfMemoryError: unable to create new native thread
它不是堆内存不足,而是进程无法再创建 Native 线程。
常见原因:
- 线程数达到进程或系统限制;
- 线程栈设置过大;
- 代码频繁 new Thread;
- 容器 PID namespace 或 pids cgroup 限制;
- 系统内存不足;
- 线程泄漏。
查看:
jcmd <pid> Thread.print > thread.txt
ps -M -p <pid> | wc -l
ulimit -u
cat /proc/<pid>/status | grep Threads
处理:
- 改用线程池并设置上限;
- 降低
-Xss; - 排查未关闭的线程来源; 4| 提高容器 pids 限制,前提是有明确容量评估;
- 使用异步 IO 或虚拟线程时重新评估模型。
27.7 直接内存
异常:
java.lang.OutOfMemoryError: Direct buffer memory
常见来源:
| 组件 | 典型使用 |
|---|---|
| NIO | DirectByteBuffer |
| Netty | PooledDirectByteBuf |
| RPC 框架 | 堆外缓冲 |
| 缓存 | 堆外缓存 |
| 压缩/序列化 | Native 缓冲 |
排查:
- 检查
-XX:MaxDirectMemorySize; - 查看直接内存指标;
- 检查 Netty arena 指标;
- 确认释放路径;
- 是否泄漏 DirectByteBuffer;
- NMT 是否启用。
启用 Native Memory Tracking:
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary
NMT 有轻微开销,生产启用前应评估。
27.8 容器 OOM Kill
JVM 未抛 OOM 但进程消失,可能是被容器运行时或操作系统杀掉。
证据:
Kubernetes Event: OOMKilled, exit code 137
dmesg: Out of memory: Killed process <pid>
原因:
-Xmx加堆外超过容器 limit;- 线程栈过多;
- 直接内存泄漏;
- Native 库泄漏;
- sidecar 或同 Pod 其他进程消耗内存;
- limit 设置过低。
排查:
1. 确认 OOMKilled
2. 查看 Pod memory limit
3. 查看 JVM 最大堆
4. 统计线程数和栈大小
5. 查看直接内存和 Native 内存
6. 调整堆占比或修复泄漏
常见公式:
容器内存 > 堆 + Metaspace + 线程栈 + 直接内存 + CodeCache + Native + 余量
27.9 OOM 应急流程
发现告警
-> 确认是否仍可访问
-> 摘除流量
-> 保存 GC 日志和 dump
-> 记录监控和事件
-> 如果无法诊断,手动生成 dump
-> 重启或替换实例
-> 修复根因
-> 灰度验证
如果配置了 ExitOnOutOfMemoryError,进程会快速退出,由编排系统拉起。这样可以避免故障实例继续接收请求,但前提是 dump 已写入本地卷。
本章小结
OOM 排查第一步是识别类型:堆、元空间、线程、直接内存、数组限制还是系统级 OOM。不同类型对应不同工具和参数。堆 OOM 用 GC 日志和堆 dump;Metaspace 看类加载器;线程 OOM 看线程数和系统限制;直接内存看堆外使用;OOMKilled 则要看容器总内存。生产环境必须提前配置 dump 和日志,否则重启后证据消失。
思考题
Java heap space和Direct buffer memory的排查差异是什么?- 为什么堆 dump 可能看到大量不可达对象?
- Metaspace OOM 常见来源有哪些?
- 容器 exit code 137 一定代表 Java OOM 吗?
- 如何设计 OOM 应急预案?