Java

Metaspace 一直涨但堆没 OOM,怎么判断是不是 ClassLoader 泄漏?

Heap 还算平,Metaspace 和已加载类数量却一轮轮往上叠时,别再沿 heap 那条线硬查。把发布动作、类加载数量、GC 日志和 ClassLoader 持有关系放在一起,方向会快很多。

  • Java
  • JVM
  • Metaspace
  • ClassLoader
  • 性能排查
14 分钟阅读

Metaspace 这类问题最容易把人带偏的地方,是它长得不像大家最熟悉的 heap OOM。

我见过一次很典型的现场:规则引擎服务的 Heap 一直不算难看,Old 区也没有一路顶死,但 RSS 在涨,Metaspace 从 200 多 MB 一路爬到 1.2 GB,GC 日志里开始不断冒 Metadata GC Threshold。值班群里第一轮动作很自然:

  • 先把 -Xmx 再加一点
  • 再观察一轮发布是不是会稳住
  • 要不要先抓个 Heap Dump 看对象

这些动作看起来都不离谱,但那次真正的主线根本不在 heap。

因为事故的起点不是“堆里装不下业务对象”,而是:每发一轮规则,服务里就多留下一批旧的 ClassLoader 和它们加载过的类。

所以这篇不想泛讲 Metaspace 概念,而是把那次现场拆开:为什么我会先怀疑 ClassLoader 生命周期,哪些证据把普通 heap、Direct Memory 这些方向排掉,最后又是怎么用 Heap Dump 和类加载数据把问题钉死的。

这次事故,不是从 Heap 图开始看懂的

那次服务是个规则计算节点,支持业务在线发布 Groovy 规则。真正把问题暴露出来的,不是一条 OOM,而是三轮发布时间点和 Metaspace 曲线完全对上了。

时间线大概是这样:

  • 14:05:第一轮规则发布
  • 14:22:Metaspace 从 260MB 涨到 410MB,已加载类数增加约 4300
  • 15:11:第二轮发布
  • 15:40:GC 日志开始出现 Pause Full (Metadata GC Threshold)
  • 16:32:第三轮灰度发布
  • 17:05:Metaspace 1.18GB,Heap 仍只占到 58%,RSS 同时抬到 5.4GB

这类现场里,如果只看 Heap,很容易得出错误安慰:

  • 堆没满
  • Full GC 也不是因为 Old 顶死
  • 可能只是 JVM 内存整体紧一点

但真正让我先转去查 Metaspace / ClassLoader 的,是这三个同步信号:

  1. 每次上涨都跟发布动作对齐,不跟流量高峰对齐
  2. 已加载类数量一轮轮增加,但 unloaded classes 基本不动
  3. Heap 相对稳定,Metaspace 和 RSS 才是抬头主角

为什么我先怀疑 ClassLoader,而不是普通 Heap 问题

那次如果继续按 heap 方向查,大概率会被带去看 Histogram 里那些根本解释不了 Metaspace 的对象。

我先把下面几组数据并到一起看:

  • Heap / Old 使用率
  • Metaspace 使用量
  • LoadedClassCount / UnloadedClassCount
  • GC 日志里的触发词
  • 发布记录

一并起来,主线就很清楚了。

Heap 并没有给出“堆快满了”的证据

问题窗口里,Heap 基本在 5.2G 到 5.8G 之间波动,Full GC 后还能回到 4G 出头。这不像 Java 内存持续上涨但没有 OOM,先怎么排查? 里那种老年代基线持续积债的样子。

GC 触发词已经把矛头指到了类元数据

GC 日志里反复出现的,不是 to-space exhaustedhumongous allocation 这一类 heap 触发词,而是:

[15:41:22] GC(88) Pause Full (Metadata GC Threshold) 5634M->4128M(8192M) 1.42s
[15:46:07] GC(91) Pause Full (Metadata GC Threshold) 5711M->4183M(8192M) 1.58s

这行日志已经在告诉你:JVM 现在更着急的是元空间压力,不是普通堆对象挤满。

类加载数量只增不减

我们用监控和 jcmd 补了一轮数据,看到的是:

时间Loaded ClassesUnloaded ClassesMetaspace
14:0026,3119,102248MB
15:2030,7749,131463MB
16:4035,1229,138816MB
17:0538,9079,1411.18GB

这组数据很关键。Metaspace 涨不一定等于 ClassLoader 泄漏,但发布动作后 loaded classes 阶梯式增加、unloaded 基本不动,已经足够把 ClassLoader 生命周期提到最高优先级。

哪些方向是怎么被排掉的

那次我们没有直接喊“肯定是 ClassLoader 泄漏”,而是先把几个很像的方向排掉。

不是 Direct Memory 或线程数失控

jcmd <pid> VM.native_memory summary 里,ThreadCodeInternal 都比较稳定,没有一块自己突飞猛进。线程数也始终在 240 到 250 之间。

所以 RSS 在涨,不是因为线程栈暴涨,也不像 堆外内存上涨把容器逼死时,先留哪些证据? 那种堆外先冲上去的现场。

不是普通业务对象把 Heap 顶高

Heap Dump 里当然也能看到一堆 byte[]StringHashMap,但它们解释不了两件事:

  • 为什么发布后 Metaspace 立刻抬头
  • 为什么已加载类数量一轮轮增加却不卸载

也就是说,Dump 这时候不是为了查“大对象谁最大”,而是为了查:哪些 ClassLoader 还活着,它们为什么没死。

真正把问题钉死的,是 Heap Dump 里的 ClassLoader 持有链

我们在第二轮 Full GC 后抓了 Heap Dump,再用 MAT 看 ClassLoader 相关实例。

最后最值钱的不是 Histogram,而是 Dominator Tree 里一组异常显眼的对象:

groovy.lang.GroovyClassLoader$InnerLoader  retained heap: 612 MB
org.codehaus.groovy.runtime.callsite.CallSiteClassLoader retained heap: 248 MB

沿 Path to GC Roots 往上追,持有链非常直白:

RuleScriptManager
  -> ConcurrentHashMap<RuleVersion, CompiledScript>
    -> GroovyClassLoader$InnerLoader

继续看线程栈和代码差异,又补上了第二个钉子:

  • 每次发布都会新建一组 GroovyClassLoader
  • 旧版本规则会被放进 versionHistory 里保留
  • 调度线程的 contextClassLoader 也一直挂着旧 loader
  • 所以规则版本虽然下线了,旧的 ClassLoader 还是活着

到这一步,ClassLoader 泄漏已经不是猜测,而是完整链条:

  1. 发布新规则
  2. 新建一批编译类和 GroovyClassLoader
  3. 旧版本对象被静态 Map 和线程上下文继续持有
  4. 类无法卸载
  5. Metaspace 和已加载类数量一轮轮抬高

那次修复,为什么不是简单调大 MaxMetaspaceSize

如果只是把 MaxMetaspaceSize 调大,最多只是把事故往后推。

我们最后做的是三件更对路的动作:

  • 规则版本历史只保留最近两版,不再整份常驻
  • 发布完成后显式清理旧 GroovyClassLoader 引用,并关闭相关资源
  • 修掉调度线程里遗留的 contextClassLoader

真正验证判断成立的,不是“服务没再报错”,而是下面这组回落顺序:

  • 第四轮规则发布后,Loaded Classes 仍会上升,但 Unloaded Classes 终于开始同步增加
  • Metaspace 在一轮 Full GC 后从 1.1GB 回到 320MB 左右
  • 再连续发 5 轮规则,Metaspace 不再呈阶梯状上行
  • GC 日志里的 Metadata GC Threshold 消失

这一步很重要。因为 Metaspace 问题最容易出现“重启好了”的错觉。只有发布几轮后还不再持续积债,才能说明你处理的是生命周期,而不是单次现场。

所以,什么样的现场最该先怀疑 ClassLoader 泄漏

如果把那次值班判断压成一句话,我会记成:

Heap 没什么戏,但 Metaspace、已加载类数量和发布动作一起往上叠,先去查 ClassLoader 生命周期。

尤其是这几种组合同时出现时:

  • Metaspace 持续上涨,Heap 却不算突出
  • 发布、热加载、脚本执行之后更明显
  • Loaded Classes 一直涨,Unloaded Classes 很少动
  • GC 日志出现 Metadata GC Threshold

这时候再按 heap 教程那条线硬查,通常只会越查越模糊。

真正能把方向拉直的,是把“哪次动作后开始涨”“哪些类没有卸载”“谁还在持有旧 ClassLoader”放进同一条链里看。Metaspace 问题难查,不是因为它玄,而是因为很多人直到现场都很明确了,还在用 heap 的语言理解它。