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:热重启流程
- 新代码到达 → 创建 新的 RestartClassLoader
- 旧加载器 仍有 1 个外部引用 (
DefaultListableBeanFactory的beanClassLoader) - 旧加载器 无法被 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 日志定位巨型卡表,敬请期待!