Java

Java 内存持续上涨但没有 OOM,先怎么排查?

还没 OOM 却一路涨内存,最怕的不是它还活着,而是把堆上涨、RSS 上涨和短时高峰混成一件事。先把几条曲线对到同一个事故时间窗里,方向才不会一开始就走偏。

  • Java
  • JVM
  • 内存排查
  • OOM
  • 性能排查
14 分钟阅读

很多“内存问题”真正最好查的时候,不是已经 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 UsedFull GC 后 Old 基线RSS线程数
10:205.6G3.1G6.2G286
11:506.2G3.7G6.8G289
13:306.7G4.2G7.3G287
15:107.1G4.8G7.8G288

这张表里最关键的不是绝对值,而是三件事同时成立:

  1. Heap 和 RSS 一起涨,不是 RSS 自己在飞
  2. 线程数几乎没动,先把线程栈膨胀排掉了
  3. 低峰来了,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 没有异常抬升
  • CodeGCInternal 也没出现一条自己往上跑的分支

这一步的价值,不是给出根因,而是把“先去查 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”了,而是一次完整的积债链:

  1. 发布引入本地兜底缓存
  2. 缓存按商品维度无界增长
  3. 快照对象生命周期被拉长
  4. Full GC 每次能回一点,但老年代基线持续抬高
  5. 如果不处理,下一站就是 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 回完以后基线会不会继续抬。

因为这两步几乎决定了后面往哪查:

真正容易让人后悔的,通常不是“没能提前知道根因”,而是明明现场已经把方向暴露出来了,却因为“还没 OOM”这四个字,把最好查的窗口拖过去了。