Metaspace 一直涨但堆没 OOM,怎么判断是不是 ClassLoader 泄漏?
Heap 还算平,Metaspace 和已加载类数量却一轮轮往上叠时,别再沿 heap 那条线硬查。把发布动作、类加载数量、GC 日志和 ClassLoader 持有关系放在一起,方向会快很多。
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 的,是这三个同步信号:
- 每次上涨都跟发布动作对齐,不跟流量高峰对齐
- 已加载类数量一轮轮增加,但 unloaded classes 基本不动
- 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 exhausted、humongous 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 Classes | Unloaded Classes | Metaspace |
|---|---|---|---|
| 14:00 | 26,311 | 9,102 | 248MB |
| 15:20 | 30,774 | 9,131 | 463MB |
| 16:40 | 35,122 | 9,138 | 816MB |
| 17:05 | 38,907 | 9,141 | 1.18GB |
这组数据很关键。Metaspace 涨不一定等于 ClassLoader 泄漏,但发布动作后 loaded classes 阶梯式增加、unloaded 基本不动,已经足够把 ClassLoader 生命周期提到最高优先级。
哪些方向是怎么被排掉的
那次我们没有直接喊“肯定是 ClassLoader 泄漏”,而是先把几个很像的方向排掉。
不是 Direct Memory 或线程数失控
jcmd <pid> VM.native_memory summary 里,Thread、Code、Internal 都比较稳定,没有一块自己突飞猛进。线程数也始终在 240 到 250 之间。
所以 RSS 在涨,不是因为线程栈暴涨,也不像 堆外内存上涨把容器逼死时,先留哪些证据? 那种堆外先冲上去的现场。
不是普通业务对象把 Heap 顶高
Heap Dump 里当然也能看到一堆 byte[]、String、HashMap,但它们解释不了两件事:
- 为什么发布后 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 泄漏已经不是猜测,而是完整链条:
- 发布新规则
- 新建一批编译类和 GroovyClassLoader
- 旧版本对象被静态 Map 和线程上下文继续持有
- 类无法卸载
- 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 的语言理解它。