Java 堆内存正常却报 Metaspace OOM?类加载器泄漏的定位与修复
现象:堆只用了 30%,却在凌晨抛 Metaspace OOM
一个跑了两年的 Spring Boot 报表服务,某天凌晨开始每小时崩一次,重启就好。看监控很反常:
- 堆内存(heap used)稳定在 30% 左右,远没到 limit
- 但容器 RSS 一路涨,最后被 OOMKilled
- 日志里有这一行:
java.lang.OutOfMemoryError: Metaspace
很多人第一反应是"Metaspace 不够,调大 -XX:MaxMetaspaceSize 就行了"。这次我们从 256m 调到 512m,坚持了三天又崩——因为根本问题是类在无限重复加载,给多少空间都会填满。
第一步:先确认是 Metaspace 在涨,不是堆
jstat -gc 的输出里,Metaspace 相关的是 MC(当前容量)和 MU(已用),单位 KB:
jstat -gc <pid> 1000 10
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU
0.0 1024.0 0.0 1024.0 41984.0 38640.2 174080.0 52326.4 228352.0 214918.7 31232.0 29108.5
关键看 MU 是否持续单向增长且不回落。堆的 OU(老年代使用)会随 Full GC 下降,Metaspace 的 MU 只有类被卸载才会降——类卸载的前提是加载它的 ClassLoader 被回收,而这个条件比想象中苛刻得多。
再补一条:jstat -gcmetacapacity <pid> 看 MCMN/MCMX/MCSS/MCSK,确认是否真的顶到 MaxMetaspaceSize 上限。
第二步:找出是谁在疯狂生成类
jmap -clstats <pid> 会列出所有类加载器及其加载的类数量,按加载数排序:
jmap -clstats 12345 | head -20
class_loader classes bytes parent_loader alive? type
<bootstrap> 2814 4182656 null live <internal>
sun.misc.Launcher$AppClassLoader@4b67cf4d 6124 8921440 <bootstrap> live
org.springframework.boot.loader.LaunchedURLClassLoader@1a2b3c 4890 7118200 ... live
jdk.internal.reflect.DelegatingClassLoader@1f2e3d4c 418 512000 <bootstrap> dead
jdk.internal.reflect.DelegatingClassLoader@2a3b4c5d 407 498000 <bootstrap> dead
jdk.internal.reflect.DelegatingClassLoader@3c4d5e6f 396 485000 <bootstrap> dead
...(数百个 DelegatingClassLoader)
这段输出直接给出答案:几百个 DelegatingClassLoader,每个加载 400 个左右的类,每个约 500KB。这就是 Metaspace 被吃光的真凶。
DelegatingClassLoader 是 JVM 为反射调用生成字节码(inflation)时创建的临时类加载器。正常情况下它会被回收,但如果出现下面三种情况,它就会堆积:
- 反射调用同一方法超过 15 次,JVM 触发字节码膨胀(
-Dsun.reflect.inflationThreshold=15阈值),此后走生成的 accessor - 生成的类被缓存或持有引用,ClassLoader 就无法回收
- 大量不同的反射调用目标(比如对成千上万个不同对象做 BeanUtils.copyProperties)会生成大量 accessor
第三步:定位到具体代码
这个服务的代码里有两处高危写法:
// 写法 1:每条记录都用反射拷贝
for (Row row : rows) { // rows 可能有几十万条
BeanUtils.copyProperties(row, dto); // 内部走 PropertyDescriptor 缓存 + 反射
}
// 写法 2:动态代理每次都新建 ClassLoader
public Object wrap(Object target) {
return Proxy.newProxyInstance(
new URLClassLoader(new URL[0], getClass().getClassLoader()), // 每次新 loader!
target.getClass().getInterfaces(), handler);
}
写法 2 是典型的"自定义 ClassLoader 泄漏":每个 URLClassLoader 都会新建,且被 Proxy 生成的类强引用,永远不会被回收。写法 1 则在反射调用次数上无限放大。
第四步:三种修法,按代价从低到高
1. 关闭反射膨胀(最快,但要看场景)
-Dsun.reflect.inflationThreshold=2147483647
# 或彻底关闭:-Dsun.reflect.noInflation=true
设置后反射直接走 native 调用,不生成字节码,Metaspace 立刻止涨。代价是反射调用性能下降约 10~30%,对大多数业务可接受。但这是治标——如果还有自定义 ClassLoader 泄漏,换个地方继续涨。
2. 替换反射调用(治本,推荐)
// 改造前:逐条 copyProperties
for (Row row : rows) {
BeanUtils.copyProperties(row, dto);
}
// 改造后:手写 setter 或用 MapStruct 编译期生成
for (Row row : rows) {
dto.setId(row.getId());
dto.setAmount(row.getAmount());
dto.setCreateTime(row.getCreateTime());
}
编译期生成代码零反射开销,Metaspace 完全不涨。数据量大的批处理场景收益最明显。
3. 干掉自建 ClassLoader
// 改造前:每次 new 一个 URLClassLoader
// 改造后:共用一个静态 loader,且用完 close()
private static final ClassLoader SHARED = MyService.class.getClassLoader();
public Object wrap(Object target) {
return Proxy.newProxyInstance(SHARED, target.getClass().getInterfaces(), handler);
}
原则:ClassLoader 是昂贵且长生命周期的对象,绝不能在循环或请求里 new。确实需要动态加载的场景,用完必须 close() 并断开所有引用。
第五步:验证与监控
- 验证:改造后连续压测 4 小时,
jstat -gc的 MU 应稳定在 30~60MB 不再爬升,jmap -clstats的DelegatingClassLoader数量应在 20 以内 - 监控:用 JMX 的
java.lang:type=MemoryPool,name=Metaspace拿Usage.used,配告警阈值(比如超过MaxMetaspaceSize的 80%) -XX:MaxMetaspaceSize不要设成无限:设上限能让问题暴露成 OOM 而不是慢慢吃掉整机内存导致更难查的 OOMKilled- 加
-XX:+HeapDumpOnOutOfMemoryError,Metaspace OOM 时堆 dump 里也能看到类是哪个 loader 加载的
效果
改造后:Metaspace 稳定在 48MB(改造前 20 天涨到 512MB 顶格崩),容器 RSS 从 1.8G 降到 900MB,凌晨定时任务再没崩过。真正的修法是把反射和自定义 ClassLoader 的生成量压下来,而不是给它更大的空间。
一句话记住:Metaspace 只涨不降 = 类在被重复加载,先看 jmap -clstats 的 class_loader 数量,再看代码里的反射与动态代理。
- 点赞
- 收藏
- 关注作者
评论(0)