Metaspace OOM 排查实录:一次 Spring 热部署爆掉 256 M 元空间

Metaspace OOM 排查实录:一次 Spring 热部署爆掉 256 M 元空间

(类加载器泄漏 + CGLib 代理堆积 + MAT 手把手定位,新手也能跟做)

关键词:Metaspace OOM、类加载器泄漏、CGLib、Spring 热部署、MAT

阅读时长:15 min

源码版本:OpenJDK 17 + SpringBoot 2.7

适合:1~5 年 Java 开发、线上 OOM 排查、面试常问「元空间满了怎么办」


一、现象:服务热部署 3 次后直接罢工

业务背景

SpringBoot 热部署组件 spring-boot-devtools,本地改一行代码 Ctrl+F9 自动重启。

监控告警

复制代码
java.lang.OutOfMemoryError: Metaspace
Dumping heap to java_pidxxxx.hprof ...
  • 启动参数:-XX:MaxMetaspaceSize=256m
  • 第一次启动占用 45 m → 第二次 98 m → 第三次 256 m 直接崩
  • 老年代、堆内存 几乎没涨只有 Metaspace 直线上升

二、小白 3 分钟:什么是 Metaspace?

面试答案 一句话
存什么? 类元数据:类名、字段、方法、字节码、常量池
在哪? 本地内存(Native Memory),不在 Java 堆
上限? 默认 无上限 ,但启动可设 -XX:MaxMetaspaceSize
何时回收? 类卸载时:所有实例已死 + 类加载器已死 + 无引用

结论:
类加载器活着 → 它加载的所有类占用的 Metaspace 不会释放

反复热部署 = 反复造新的类加载器 → Metaspace 只增不减


三、第一次定位:把类加载器拍下来

① 启动参数一键打开「类卸载日志」

bash 复制代码
-XX:+UnlockDiagnosticVMOptions \
-XX:+TraceClassLoading \
-XX:+TraceClassUnloading \
-XX:+PrintGCDetails \
-Xlog:gc+metaspace*=debug

② 观察控制台(摘关键)

复制代码
[0.123s][info][class,load]  org.example.controller.DemoController  source: file:/tmp/demo-0.0.1-SNAPSHOT.jar
[0.234s][info][class,load]  org.springframework.cglib.proxy.Factory  source: generated
...
[3.456s][info][gc,metaspace]  Metaspace: 45.2 M used, 256 M max

第三次重启后

复制代码
[9.876s][info][gc,metaspace]  Metaspace: 256.0 M used, 256 M max
[9.877s][warn][gc,metaspace]  Metaspace allocation failed

类加载 34017 次,卸载 0 次 → 一根毛都没卸载!


四、MAT 手把手:3 步找出「谁没卸载」

① 拿到 hprof dump 文件

启动失败时 JVM 自动生成 java_pidxxxx.hprof,也可手动:

bash 复制代码
jmap -dump:format=b,file=meta.hprof <pid>

② 用 MAT(Memory Analyzer Tool)打开

下载 70 M 绿色包:https://www.eclipse.org/mat

③ 点菜单 → 「Class Loader Explorer」

一眼看到:

Class Loader Loaded Classes Objects Retained Heap
RestartClassLoader@0x12345 10843 0 186 M
RestartClassLoader@0x6789a 10843 0 186 M
RestartClassLoader@0xbcdef 10843 0 186 M

3 个 RestartClassLoader 实例全部活着 ,但它们加载的类占 558 M(> 256 M)→ OOM


五、深度:为什么 RestartClassLoader 没死?

源码跟踪 spring-boot-devtools:

java 复制代码
public class RestartClassLoader extends URLClassLoader {
    // 省略
    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
        // 1. 优先自己加载
        Class<?> c = findLoadedClass(name);
        if (c == null && isExcluded(name)) {
            c = super.loadClass(name, resolve);  // 委派给 parent
        }
        // 2. 非 excluded → 自己加载 → 生成全新 Class 实例
        return findClass(name);
    }
}

问题 1:isExcluded 默认放过哪些?

所有业务类com.xxx.yyy)(Spring 默认规则)

问题 2:热重启流程

  1. 新代码到达 → 创建 新的 RestartClassLoader
  2. 旧加载器 仍有 1 个外部引用DefaultListableBeanFactorybeanClassLoader
  3. 旧加载器 无法被 GC → 它加载的 10843 个类 全部留在 Metaspace
    重复 3 次 → 3×186 M ≈ 558 M → 256 M 上限直接炸

六、复现:10 行代码让 Metaspace 涨 100 M

java 复制代码
@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
        // 模拟多次热重启
        for (int i = 0; i < 10; i++) {
            restart();
        }
    }
    static void restart() {
        // 新建一个自定义加载器
        ClassLoader tmp = new URLClassLoader(new URL[0], DemoApplication.class.getClassLoader());
        // 加载 200 个代理类
        for (int j = 0; j < 200; j++) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(DemoService.class);
            enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> null);
            enhancer.setClassLoader(tmp);   // 关键:用临时加载器
            enhancer.create();              // 生成代理类
        }
        // 故意不释放 tmp 引用 → 类无法卸载
    }
}

启动参数:

bash 复制代码
-XX:MaxMetaspaceSize=100m -XX:+PrintGCDetails -Xlog:gc+metaspace=debug

结果

启动后 第 4 次循环 → Metaspace 占用 99 M → OOM


七、修复:3 个动作让 Metaspace 降回 45 M

① 代码层:用完即走,解除引用

java 复制代码
static void restart() {
    ClassLoader tmp = new URLClassLoader(...);
    ... // 加载代理类
    // 关键:方法结束 tmp 不再被任何强引用 → GC 可卸载
    //方法1
    Enhancer.registerCallbacks(tmp, null);   // 把缓存置空
    //方法2
    enhancer.setUseCache(false);   // 关键:关闭缓存
    //方法3
    List<Object> holders = new ArrayList<>();
    for (int j = 0; j < 200; j++) {
        Enhancer enhancer = new Enhancer();
        enhancer.setUseCache(false);      // ① 关闭缓存
        enhancer.setSuperclass(DemoService.class);
        enhancer.setCallback((MethodInterceptor) (o, m, args, p) -> null);
        enhancer.setClassLoader(tmp);
        holders.add(enhancer.create());   // ② 先收集代理对象
    }
    // ③ 显式释放
    holders.clear();     // 代理对象没了 → 类可卸载
    tmp = null; 
}

② Spring 层:关闭 devtools 或排除业务包

yaml 复制代码
# application.yml
spring.devtools.restart.exclude: com.example.**

让业务类 走系统类加载器,不再被 RestartClassLoader 加载

③ 参数层:允许类卸载 + 打印卸载日志

bash 复制代码
-XX:+UnlockDiagnosticVMOptions \
-XX:+TraceClassUnloading \
-XX:+CMSClassUnloadingEnabled  (CMS 时)\
-XX:+ClassUnloadingWithConcurrentMark (G1/ZGC)

重启后日志:

复制代码
[15:23:45.678][info][class,unload]  unloading class com.example.DemoService$$EnhancerByCGLIB$$123456 0x00000007c0020000

34017 个类 → 卸载 33981 个 ,Metaspace 从 256 M 回落到 45 M


八、可视化:MAT 再看一次

指标 修复前 修复后
Loaded Classes 34 017 5 236
Class Loader 实例 3 1
Metaspace 使用 256 M 45 M
OOM

九、思维导图:Metaspace OOM 排查 5 步法

复制代码
1. 看日志  ──►  2. 算类数  ──►  3. 找加载器  ──►  4. 解除引用  ──►  5. 验卸载
│              │             │             │             │
Xlog           jcmd          MAT           代码/配置     Metaspace
Metaspace      stat          图表          排除/释放     下降

十、面试 5 连问(标准答案)

Q1 Metaspace OOM 常见原因?

A:反复创建类加载器 + 未释放;动态代理/CGLib/反射生成类过多;JSP 热部署

Q2 为什么类加载器死了类才能卸载?

A:JVM 规范:类卸载 = 所有实例已死 + 类加载器已死 + 无引用

Q3 如何查看 Metaspace 实时使用?

A:jstat -gc pid 2000 看 MU 列;或 jcmd pid VM.native_memory summary

Q4 MAT 怎么看 Class Loader 泄漏?

A:Class Loader Explorer → 按 Retained Heap 排序 → Dominator Tree 找 GC Root 引用链

Q5 一句话避免 Metaspace 泄漏?

不要保留自定义 ClassLoader 引用,用完即走,让 GC 能卸载


十一、小结:一句话背走

「Metaspace 涨 = 类没卸载 = 加载器还活着」

三板斧:解除强引用 → 排除热加载 → 开日志验卸载,256 M → 45 M 立竿见影!


十二、下集预告

《一次 Full GC 频繁,老年代 90 % 却几乎没垃圾:RSet 泄漏案》

将带你深入 G1 Remembered Set 机制,用 gc+remset*=trace 日志定位巨型卡表,敬请期待!

相关推荐
会飞的小蛮猪12 小时前
SkyWalking运维之路(Java探针接入)
java·运维·经验分享·容器·skywalking
通域12 小时前
解决启动IDEA后CPU 及内存占用过高配置调整
java·ide·intellij-idea
一袋米扛几楼9812 小时前
【软件安全】C语言特性 (C Language Characteristics)
java·c语言·安全
m0_7482480213 小时前
《详解 C++ Date 类的设计与实现:从运算符重载到功能测试》
java·开发语言·c++·算法
aloha_78913 小时前
测试开发工程师面经准备(sxf)
java·python·leetcode·压力测试
我命由我1234514 小时前
Java 并发编程 - Delay(Delayed 概述、Delayed 实现、Delayed 使用、Delay 缓存实现、Delayed 延迟获取数据实现)
java·开发语言·后端·缓存·java-ee·intellij-idea·intellij idea
北城以北888814 小时前
SSM--MyBatis框架之缓存
java·缓存·intellij-idea·mybatis
kyle~14 小时前
算法数学---差分数组(Difference Array)
java·开发语言·算法
zhaomx198914 小时前
Spring 事务管理 Transaction rolled back because it has been marked as rollback-only
数据库·spring
曹朋羽14 小时前
Spring EL 表达式
java·spring·el表达式