2226 字
约 7 分钟
15
S05-A16 · OOM 来得突然:内存去哪了
2024-12-31
2026-11-10

一个服务稳定跑了三个月,堆占用一直在 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. 自测

  1. 两种 OOM 形态的判据是什么?为什么"看 Full GC 后的基线"能区分它们?
  2. 堆外 OOM 为什么表现为"堆正常、容器被杀"?它与 JVM 的 OutOfMemoryError 有什么不同?
  3. ThreadLocal 泄漏为什么在线程池里更严重?
  4. 突发峰值定位为什么要求日志记"参数规模"而不只是"参数值"?
  5. 为什么"先用直方图,再决定 dump"?直接 dump 的问题是什么?
  6. 判断"是否需要追根因"的那个问题是什么?
S05-A16 · OOM 来得突然:内存去哪了
https://www.clxhxhhr.top/posts/6493/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。