Java 内存持续上涨但没有 OOM,先怎么排查?
还没 OOM 却一路涨内存,最怕的不是它还活着,而是把堆上涨、RSS 上涨和短时高峰混成一件事。先把几条曲线对到同一个事故时间窗里,方向才不会一开始就走偏。
很多“内存问题”真正最好查的时候,不是已经 OutOfMemoryError 的那一刻,而是它还没把服务打死、却已经露出完整判断链的时候。
我印象最深的一次,是订单查询服务在一个周二白天连续 6 个小时把内存基线往上抬。
表面上它还很能撑:
- 没有 OOM
- Pod 也没重启
- 接口成功率还在 99% 以上
- Full GC 做完以后,Heap 还能往下掉一点
于是群里一开始出现的判断都很熟悉:
- 先观察一下,可能就是流量高
- 还没 OOM,不一定是真问题
- 要不要先把
-Xmx从 8G 调到 10G
但那次真正有价值的地方,恰好就在“它还没死”。
因为现场没丢,趋势也没被重启洗掉。我们最后不是靠猜“是不是泄漏”,而是靠一条完整证据链把问题收敛出来:涨的是 Heap,不是堆外;涨的是老年代基线,不是短时峰值;Full GC 不是回不动一点,而是每次只回一点点;低峰不回落,说明不是单纯高峰顶上去。
这篇不想把“内存上涨”写成一套 JVM 教程,而是把那次现场里最值钱的判断顺序讲清楚:内存持续涨但还没 OOM 时,到底先怀疑哪条线,哪些证据能把别的方向排掉,动作后又该怎么验证自己没赌错。
事故是怎么暴露出来的
那次出问题的是一个提供订单详情聚合的 Java 服务。事故窗口很长,不像 OOM 那样一下子炸开,而是慢慢把人麻痹。
当天我们拉出来的时间线大概是这样:
- 09:40:新版本发布,上了一个“本地商品快照兜底缓存”
- 10:20:Heap Used 从 4.8G 缓慢爬到 5.6G,RSS 同步抬高
- 11:50:Full GC 间隔从 40 分钟缩到 12 分钟
- 13:30:午高峰过后,QPS 开始回落,但 Old 区 Full GC 后的基线没有回去
- 15:10:Heap 已经到 7.1G,RSS 7.8G,Pod 还活着,但 Full GC 频率继续变密
真正让我决定立刻按事故去查的,不是“内存变大了”,而是下面这组对照:
| 时间 | Heap Used | Full GC 后 Old 基线 | RSS | 线程数 |
|---|---|---|---|---|
| 10:20 | 5.6G | 3.1G | 6.2G | 286 |
| 11:50 | 6.2G | 3.7G | 6.8G | 289 |
| 13:30 | 6.7G | 4.2G | 7.3G | 287 |
| 15:10 | 7.1G | 4.8G | 7.8G | 288 |
这张表里最关键的不是绝对值,而是三件事同时成立:
- Heap 和 RSS 一起涨,不是 RSS 自己在飞
- 线程数几乎没动,先把线程栈膨胀排掉了
- 低峰来了,Full GC 后的 Old 基线也没回去,这不像短时流量峰值
也就是说,那时候我第一怀疑线已经不是“容器边界会不会先撞穿”,而是:堆里有一批长生命周期对象在持续积债。
为什么我先怀疑 Heap,而不是堆外或容器问题
这一步如果判断错,后面会浪费很多时间。
那次我们没有一上来就抓 Heap Dump,而是先把几组指标并在同一个 15 分钟窗里看:
jvm_memory_used_bytes{area="heap"}- Old 区使用量和 Full GC 次数
- 容器 RSS / working set
- 线程数
- Metaspace、Direct Buffer、NMT 摘要
最先把堆外方向排掉的,是两条证据:
1. RSS 和 Heap 的差值几乎没变
如果是典型堆外上涨,常见长相是 Heap 平或者只涨一点,但 RSS 和容器总内存一路往上走。
那次不是。我们看到的是:
- Heap 从 4.8G 涨到 7.1G
- RSS 从 5.5G 涨到 7.8G
- RSS 与 Heap 的差值一直维持在 700M 到 800M 左右
这说明“多出来的那一大块”并不在 Heap 外面。
2. NMT 没有给出新的 native 大头
我们在一台还没被摘流量的实例上补了:
jcmd <pid> VM.native_memory summary
看下来的结果是:
Thread基本稳定Class没有异常抬升Code、GC、Internal也没出现一条自己往上跑的分支
这一步的价值,不是给出根因,而是把“先去查 Direct Memory / JNI / 线程爆炸”这些方向压低优先级。
真正让判断收紧的,是 Full GC 后那条回不去的基线
很多团队一看到“Full GC 之后还能回一点”,就容易松下来,觉得 JVM 还扛得住。
但那次最危险的地方恰好在这里:每次都能回一点,可每次回完都比上一次更高。
GC 日志里有一段很有代表性:
[13:27:41] GC(184) Pause Full (G1 Compaction Pause) 6821M->4318M(8192M) 1.84s
[14:09:16] GC(191) Pause Full (G1 Compaction Pause) 7110M->4579M(8192M) 1.96s
[14:46:08] GC(198) Pause Full (G1 Compaction Pause) 7396M->4897M(8192M) 2.11s
这里最值得盯的不是 Full GC 时长,而是回收后的落点在持续抬高。
这意味着:
- JVM 不是完全回不动
- 但老对象债务在一轮轮往上叠
- 如果继续拖,它最后会滑向更重的 Full GC,甚至滑到 OOM
所以“内存涨但还没 OOM”真正要问的,不是“现在危不危险”,而是:
它是高峰把堆顶高了,还是有一批本该死掉的对象,正在一轮轮活进老年代?
那次是怎么把别的方向排掉的
现场真正收敛,不是靠一句“像泄漏”。我们当时连续排掉了三条很容易把人带偏的怀疑线。
不是流量高峰自然抬升
因为 13 点以后 QPS 已经回落了接近 35%,但 Full GC 后的 Old 基线没有同步下去。
如果只是高峰带来的分配压力,低峰一来,至少应该看到两个变化中的一个:
- Young / Old 压力下降,GC 间隔拉开
- Full GC 后的基线明显回落
那次两个都没出现。
不是批任务瞬时造对象
我们看了线程栈,没看到导出、报表、批同步这类长时间占堆的大任务。
线程栈里反而一直重复出现的是一条定时刷新链路:
"catalog-refresh-1" #214
at com.xx.catalog.ProductSnapshot.copy(ProductSnapshot.java:88)
at com.xx.catalog.LocalCatalogCache.refresh(LocalCatalogCache.java:131)
at com.xx.catalog.LocalCatalogCache.lambda$reload$0(LocalCatalogCache.java:96)
这说明堆里正在发生的,不是某个一次性任务,而是一条周期性把对象复制进本地缓存的路径。
也不是 Metaspace / 类加载问题
Metaspace 基本稳定,已加载类数量也没有异常增长,所以像 Metaspace 一直涨但堆没 OOM,怎么判断是不是 ClassLoader 泄漏? 那条线,当时很快就被排掉了。
Heap Dump 最后把问题钉在了哪
我们是在 一次 Full GC 刚结束后 抓的 Dump。这个时间点很重要,因为它能先排掉一部分“只是高峰中途快照”的误导项。
MAT 里真正有价值的不是 Histogram 顶上的 byte[] 和 String,而是 Dominator Tree 里一条很粗的持有链:
LocalCatalogCache
-> ConcurrentHashMap
-> ProductSnapshot
retained heap: 2.1 GB
继续沿 Path to GC Roots 往上看,根并不复杂:
- Spring 单例持有
LocalCatalogCache - 这个缓存没有 TTL
- 也没有上限
- 新版本为了兜底,把远端拉到的商品快照整份复制进本地 Map
- 老 key 不淘汰,新 key 持续叠加
到这里,问题已经不是“有没有 OOM”了,而是一次完整的积债链:
- 发布引入本地兜底缓存
- 缓存按商品维度无界增长
- 快照对象生命周期被拉长
- Full GC 每次能回一点,但老年代基线持续抬高
- 如果不处理,下一站就是 Full GC 风暴,甚至 OOM
那次动作以后,我们怎么验证自己判断对了
我们没有立刻改 JVM 参数,而是先做了两个更贴近现场的动作:
- 临时关掉本地兜底缓存开关
- 给缓存补
maximumSize和按版本失效的 TTL
真正验证判断成立的,不是“服务恢复了”,而是下面这个回落顺序:
- 开关关闭后 8 分钟:新对象进入缓存的速率先掉下来
- 15 分钟后:Old 区 Full GC 后基线从 4.8G 回到 3.5G
- 30 分钟后:Full GC 间隔重新拉回 35 分钟以上
- 晚高峰再来时:Heap 会随流量抬高,但低峰能明显回落,不再出现日内阶梯式抬升
如果动作后只是瞬时掉了一下,第二天又继续抬,那只能说明你撞对了一个表象。
但那次不是。第二天同一时段,内存长相已经变成了正常的“高峰抬、低峰落”。这才算把判断闭环真正做完。
所以,内存涨但还没 OOM 时,最该先抓住什么
如果把那次事故压成一句值班判断,我会记这句:
别先问会不会 OOM,先问涨的是 Heap 还是 RSS,Full GC 回完以后基线会不会继续抬。
因为这两步几乎决定了后面往哪查:
- Heap 和 Old 基线一起涨,低峰不回:优先怀疑长生命周期对象、缓存、集合、引用链
- Heap 平,RSS 自己涨:别在 heap 里打转,尽快切去
堆外内存上涨把容器逼死时,先留哪些证据? - 已经出现
Java heap space或GC overhead limit exceeded:说明你已经不在前兆窗口里了,应该直接切到Java 服务 OOM 了怎么排查?先分类型,再决定往哪条线查
真正容易让人后悔的,通常不是“没能提前知道根因”,而是明明现场已经把方向暴露出来了,却因为“还没 OOM”这四个字,把最好查的窗口拖过去了。