凌晨三点,电话响了。监控显示一个核心服务在十分钟内重启了四次,
日志里反复出现 java.lang.OutOfMemoryError: Java heap space。
那是我第一次独立处理线上 OOM,手忙脚乱地把堆 dump 下来,
用 MAT 分析到天亮。这篇就把那次排查的过程,以及顺带补上的 JVM 内存和 GC 知识整理出来。
先把 JVM 运行时内存结构画清楚
排查 OOM 的第一步,是搞清楚“内存”到底指哪块。JVM 在运行时把内存划分成几个区域, 每块都可能抛出不同类型的 OOM:
| 区域 | 线程共享 | 存放什么 |
|---|---|---|
| 堆(Heap) | 共享 | 对象实例、数组,GC 的主战场 |
| 元空间(Metaspace) | 共享 | 类的元数据、常量池(JDK 8 后在本地内存) |
| 虚拟机栈(VM Stack) | 线程私有 | 栈帧:局部变量表、操作数栈 |
| 本地方法栈 | 线程私有 | native 方法的调用 |
| 程序计数器(PC) | 线程私有 | 当前线程执行的字节码行号 |
| 直接内存(Direct Memory) | 共享 | NIO 的堆外缓冲区,不受堆大小约束 |
我们最常打交道的堆,又被分成两代:
- 年轻代(Young Generation):一个 Eden 区 + 两个 Survivor 区(S0、S1)。 新对象基本都在 Eden 分配。
- 老年代(Old Generation):熬过多次年轻代 GC、还活着的对象晋升到这里。
对象是怎么一步步进入老年代的
理解对象的生命周期,是理解 GC 的前提。一个普通对象的一生大致是这样:
-
new出来的对象先在 Eden 区分配(大对象可能直接进老年代)。 - Eden 满了触发 Minor GC(也叫 Young GC)。存活的对象被复制到一块 Survivor 区(比如 S0),Eden 清空。
- 下一次 Minor GC 时,Eden 和 S0 里存活的对象一起复制到另一块 S1, S0 清空。两块 Survivor 始终一块空、一块用,来回倒。
- 每倒一次,对象的“年龄”加一。年龄达到阈值(默认 15)就晋升到老年代。
- 老年代满了,触发 Major GC / Full GC,整体回收,代价大得多。
“朝生夕死”是大多数 Java 对象的真实写照——据统计,绝大部分对象在一次 Minor GC 后就成了垃圾。 这正是分代设计的依据:把频繁但廉价的回收留给年轻代,把昂贵但少见的回收留给老年代。
垃圾回收:怎么判断谁该被回收
判断对象死活,JVM 用的是可达性分析:从一组叫 GC Roots 的根对象出发,顺着引用链往下找,能被找到的对象就是活的, 找不到的就是垃圾。哪些可以作为 GC Roots?
- 虚拟机栈中引用的对象(方法里的局部变量);
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI 引用的对象。
一句话:只要还有一条从 GC Roots 到达对象的路径,它就不会被回收。 内存泄漏的本质,往往就是某条本该断开的引用,悄悄一直留着。
常见 OOM 类型对照表
看到日志里的报错,先对号入座是哪块内存出了问题:
| 报错信息 | 出问题的区域 | 典型原因 |
|---|---|---|
| Java heap space | 堆 | 内存泄漏 / 一次性加载超大对象 |
| Metaspace | 元空间 | 动态生成大量类且未卸载(如动态代理) |
| Direct buffer memory | 直接内存 | NIO 堆外 ByteBuffer 堆积 |
| GC overhead limit exceeded | 堆 | GC 花了 98% 以上时间却只回收不到 2%,濒临 OOM |
| unable to create new native thread | 线程 / 系统 | 创建的线程数超过系统上限 |
排查三板斧:jstat、jmap、MAT
那天夜里我用的就是这三样。先看 GC 情况:
# 1. 看进程的各代使用量和 GC 次数
jstat -gcutil <pid> 1000 10
# 输出大致是这样(每列都是一个百分比或次数):
# S0 S1 E O M CCS YGC YGCT FGC FGCT
# 0.00 92.15 45.30 98.74 96.20 91.00 124 3.210 18 9.800
O(老年代)那一列已经 98.74%,而 FGC(Full GC 次数)
在持续涨——典型的“老年代打满、Full GC 停不下来”。基本可以锁定是堆里有东西清不掉。
接着 dump 一份堆快照:
# 2. 导出堆 dump(注意:会触发 STW,线上慎用大堆)
jmap -dump:format=b,file=heap.hprof <pid>
# 或者直接看占用最多的对象类型
jmap -histo:live <pid> | head -20
jmap -histo:live 会先做一次 Full GC 再统计存活对象。那次我看到排在第一位的,
是一个第三方 SDK 里的 CacheEntry,几百万个实例。
把 heap.hprof 拖进 MAT(Eclipse Memory Analyzer),
跑一下 Dominator Tree 和 Leak Suspects,
它直接画出了这条引用链:一个静态 Map 兜着所有 CacheEntry,谁也不释放。
那次事故的根因
说白了就是一个无界的本地缓存。第三方 SDK 内部用一个
HashMap 缓存“查询结果”,key 是请求参数,但没有任何淘汰策略——
流量一上来,key 越积越多,整张 Map 一直涨,直到把堆撑爆。简化一下,就是这种代码:
// 反面教材:无界缓存 = 定时炸弹
public class ResultCache {
// 静态 Map 永远持有引用,GC Roots 一路可达,对象永远不会被回收
private static final Map<String, Object> CACHE = new HashMap<>();
public static Object get(String key) {
return CACHE.computeIfAbsent(key, k -> expensiveQuery(k));
}
}
临时止血是重启 + 临时调大堆;根治则是给缓存加上容量上限和淘汰策略, 换成有界缓存(比如 Caffeine):
// 正确做法:有界 + 过期
private static final Cache<String, Object> CACHE = Caffeine.newBuilder()
.maximumSize(10_000) // 最多 1 万条
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入 5 分钟后过期
.build();
public static Object get(String key) {
return CACHE.get(key, k -> expensiveQuery(k));
}
另外给 JVM 加上自动 dump 的参数,下次再炸至少能留下“案发现场”:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
OOM 排查的心法:先分清是哪块内存(看报错类型),再看 GC 健康度(jstat),
然后 dump 堆(jmap)找最大的对象(MAT)。十次泄漏里,
九次是“某个本该有界的集合,悄悄变得无界”。
那次之后,我把 JVM 内存结构和 GC 的知识重新系统学了一遍——不是为了应付面试, 而是真切体会到:你不懂的东西,迟早会在某个深夜,以事故的形式逼你学会。