一个服务稳定跑了三个月,堆占用一直在 40% 上下。某天上午开始频繁 Full GC,下午直接 OOM——而代码这一周没有发布。查下来是一个"每天增长一点"的缓存:它没有淘汰策略,键是用户 ID,三个月积累了两千万条。
OOM 几乎从不"突然"。它有两种真实形态:缓慢泄漏(增长可见,但没人看趋势)与突发峰值(一次操作申请了远超预期的大对象)。这两种的排查路径完全不同,还有一层"时间"错觉:泄漏是线性的,而 OOM 是突然的。所以"上个月还好"这句话不能证明没有泄漏,只能证明还没到墙。
而监控上它们都只表现为"内存满了"。
1. 两种 OOM 的指纹
| 形态 | 特征 |
|---|---|
| 缓慢泄漏 | 每次 Full GC 后基线抬高 |
| 突发峰值 | 基线正常,单个请求后暴涨 |
| 大对象分配 | 一次申请巨大数组 |
| 堆外溢出 | 堆正常,进程内存涨 |
第一项的判据是"Full GC 之后":如果每次回收后仍剩下比上次更多,就是泄漏。若回收后回到基线,则是峰值问题。
第四项最容易被误判:堆正常、容器被杀第二项(突发峰值)有个"放大"效应:一次大查询会同时放大"结果集"与"响应序列化"两份内存,于是 50MB 的查询可能吃掉 150MB。
,而错误日志只写"Killed"——那是内核 OOM Killer,与 JVM 的 OutOfMemoryError 完全是两件事。
2. 缓慢泄漏的常见来源
| 来源 | 机制 |
|---|---|
| 无界缓存 | 键只增不删 |
| 静态集合 | 生命周期等于进程 |
| 监听器未注销 | 引用链被持有 |
| ThreadLocal 未清理 | 线程池复用下累积 |
| 连接/流未关闭 | 资源对象堆积 |
第五项(未关闭)在异常路径上最多:正常路径有 finally,而超时/取消路径没有,于是"偶发失败"会留下连接与流,慢慢积累。
第四项在线程池里特别隐蔽:ThreadLocal 的值随线程存活,而线程池的线程几乎不死,于是每个请求放进去的对象都留了下来。
第一项最常见:自己写的 ConcurrentHashMap 当缓存用,却没有淘汰。而在开发与测试环境,数据量小,永远看不出问题。
// 反例:无界缓存
private static final Map<Long, User> CACHE = new ConcurrentHashMap<>();
public User get(Long id) { return CACHE.computeIfAbsent(id, repo::find); }
// 三个月后:两千万条,每条 400B → 8GB,OOM
// 正例:有界 + 过期
private final Cache<Long, User> cache = Caffeine.newBuilder()
.maximumSize(100_000).expireAfterWrite(Duration.ofMinutes(10)).build();
3. 突发峰值怎么定位
| 手段 | 用途 |
|---|---|
| 打印请求参数规模 | 找出"大请求" |
| 分配速率监控 | 看是否有异常分配 |
| 单个请求的分配剖析 | 定位代码 |
| 限流/拒绝大请求 | 止血 |
第一项最直接:OOM 前那一条请求的参数就是证据,但前提是日志里记了"参数规模"(如列表长度、文件大小),而不只是参数值。
第二项要能看出"每秒分配多少 MB":分配速率突然翻十倍,通常是某个批处理或大查询被触发。第三项(单请求剖析)的成本较高,只在"峰值反复出现且找不到请求"时用:用分配剖析跑一条真实请求,能看到每个字节从哪来。
// 可观测的三行日志(在入口处)
req.items=12000 req.bytes=48MB req.type=bulk_import
// 有了这两行,OOM 时立刻知道"是哪个请求把内存打爆"
// 反例:只记 req.id,事后无法判断规模
4. 堆外内存:另一种 OOM
| 区域 | 常见原因 |
|---|---|
| 直接内存 | Netty/大文件缓冲未释放 |
| 元空间 | 动态生成类过多 |
| 线程栈 | 线程数过多 |
| JNI/本地库 | 原生分配 |
第一项的表现是"堆正常、容器被杀":JVM 只算堆,堆外不在它的账上。所以 -Xmx 设得再合理,也可能被 OOM Killer 杀掉。
第一项(直接内存)还有个"配置缺失"的常见形态:未设 -XX:MaxDirectMemorySize 时,直接内存默认可接近堆大小,于是"堆 4G + 直接内存 3G"在 8G 容器里必然被杀。
排查方式是开 Native Memory Tracking:它能按区域列出用量,把"不知道去哪了"变成"某一区域超了"。
// 堆外排查:按区域看用量
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd $(pgrep -f app.jar) VM.native_memory summary
// 关注:Java Heap / Class / Thread / Code / GC / Internal / Other
// 若 "Internal" 或 "Other" 持续增长 → 直接内存或 JNI 泄漏
监控上要把"内存"拆成三个数来看,否则 OOM 时无法判断性质:
// 三个数:任何两个都不够
1. Full GC 后堆占用(基线) → 判断泄漏
2. 堆峰值 / 上限 → 判断余量
3. 进程 RSS / 容器 limit → 判断堆外
// 反例:只看"堆使用率 70%",而 RSS 已达容器上限 → 被 OOM Killer 杀掉
5. 止血与根治
| 阶段 | 动作 |
|---|---|
| 止血 | 限流、拒绝大请求、重启 |
| 定位 | 堆直方图/堆转储、NMT |
| 修复 | 加界/加过期/改结构 |
| 验证 | 压测长跑 + 观察基线 |
第一项的"重启"是合法手段:它的价值是争取时间第三项(修复)的判据是"能否说明增长为什么停止":如果改完只能说"再观察",那多半没修到根因。
,但要记录"重启了几次",因为频繁重启说明问题在恶化。
第四项的验证必须"长跑":泄漏类问题在短测里看不出来,至少跑数小时并观察"Full GC 后基线"是否稳定。
// 堆直方图:30 秒内看出谁占得多,不需要完整 dump
jcmd $(pgrep -f app.jar) GC.class_histogram | head -20
// 需要引用链时再 dump(会暂停,注意窗口)
jcmd $(pgrep -f app.jar) GC.heap_dump /var/dump/heap.hprof
6. 排错:五种"内存问题查不出"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 堆正常但容器被杀 | 堆外/直接内存 | NMT + 设上限 |
| 每次 GC 后基线抬高 | 泄漏 | 堆直方图对比 |
| 只在峰值 OOM | 大请求 | 记参数规模 + 限流 |
| 重启后几小时又 OOM | 未定位根因 | 抓 dump 再重启 |
| dump 文件巨大无法分析 | 堆太大 | 先直方图后 dump |
第五项还有一个"权限"问题:容器里 ptrace 或 /proc 受限时 jcmd 连不上,于是只能靠预先开启的 HeapDumpOnOutOfMemoryError。这也是为什么这个参数必须默认开着。
第五项是实践中的常见障碍:几十 GB 的 dump 打不开。所以顺序应当是"先用直方图看类型占比,再决定是否 dump"。
7. 边界:什么情况不必追内存
| 场景 | 为什么 | 做法 |
|---|---|---|
| 短生命周期批处理 | 结束即释放 | 给足堆即可 |
| 明确的大内存任务 | 设计如此 | 单独部署/限流 |
| 已用堆外托管(如 Netty 池) | 有池管理 | 监控池指标 |
| 一次性脚本 | 不长期 | 随意 |
判断口径一句话:这次 OOM 之后,同样的负载再来一次会不会再 OOM。最后一条与"容量"有关的边界:内存问题与 GC 参数的关系常被高估。调大堆只能推迟 OOM,而"调小堆"反而能让泄漏更早暴露——所以"用更小的堆跑测试"是一种主动的检测手段。
会,必须查根因;不会(一次性大任务),调大堆或限流即可。
8. 自测
- 两种 OOM 形态的判据是什么?为什么"看 Full GC 后的基线"能区分它们?
- 堆外 OOM 为什么表现为"堆正常、容器被杀"?它与 JVM 的
OutOfMemoryError有什么不同? - ThreadLocal 泄漏为什么在线程池里更严重?
- 突发峰值定位为什么要求日志记"参数规模"而不只是"参数值"?
- 为什么"先用直方图,再决定 dump"?直接 dump 的问题是什么?
- 判断"是否需要追根因"的那个问题是什么?