Java 堆内存正常却报 Metaspace OOM?类加载器泄漏的定位与修复

举报
yd_243555368 发表于 2026/11/13 00:16:45 2026/11/13
【摘要】 堆只用 30%,却在凌晨反复抛 OutOfMemoryError: Metaspace,调大 MaxMetaspaceSize 三天后又崩。用 jstat -gc 确认 MU 只涨不降,jmap -clstats 发现数百个 DelegatingClassLoader(每个加载约 400 个反射生成的类)。根因是逐条 BeanUtils.copyProperties 触发反射膨胀 + 每次请求 n

现象:堆只用了 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 数量,再看代码里的反射与动态代理。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。