在 Java 应用的生产环境中,元空间(Metaspace)OOM是让人头疼的高频问题,尤其是重启后短暂恢复、但反复复现的场景,90% 的根源都是类加载器泄漏(ClassLoader Leak)。
这不是简单的内存溢出,而是类加载器无法被 GC 回收,导致它加载的所有类、静态变量、元数据永久占用元空间,最终撑爆内存。今天我们就从原理、复现、定位到解决,彻底吃透这个问题。
一、先搞懂:元空间 & 类加载器泄漏
1. 元空间是什么?
JDK8 及以后,永久代(PermGen)被移除,元空间取而代之,用于存储:
- 类的元数据(类名、方法、字段、注解)
- 静态变量
- 常量池
- 方法编译后的代码
它默认使用本地内存,不受堆内存大小限制,但会受系统总内存限制,一旦泄漏就会触发
java.lang.OutOfMemoryError: Metaspace。2. 类加载器泄漏的核心原理
Java 的类加载机制是双亲委派模型,但每个类都持有加载它的类加载器引用,而类加载器也持有所有它加载的类的引用。
正常情况下:类加载器 → 加载的类 → 类的实例,三者都无引用时,会被 GC 一起回收,元空间释放。
泄漏的本质:类加载器被外部对象强引用持有,导致无法被 GC,它加载的所有类永远驻留元空间,反复部署 / 加载就会持续占用元空间,最终溢出。
二、动手复现:类加载器泄漏导致元空间 OOM
光看理论不够,我们用一段极简代码复现这个问题,直观感受泄漏的过程。
场景说明
自定义类加载器加载一个测试类,然后故意保留类加载器的引用,模拟泄漏,最终触发元空间 OOM。
1. 测试类(待加载的类)
先写一个空的测试类,编译成
TestClass.class,放到指定目录(如/tmp/classes):java
运行
public class TestClass {
// 空类,仅用于测试加载
}
2. 自定义类加载器
java
运行
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
/**
* 自定义类加载器:模拟打破双亲委派,加载指定目录的类
*/
public class CustomClassLoader extends ClassLoader {
// 类文件所在目录
private final String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
try {
// 类名转文件路径
String classFilePath = classPath + File.separator + className.replace(".", "/") + ".class";
FileInputStream fis = new FileInputStream(classFilePath);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = fis.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
// 字节码转Class对象
byte[] classBytes = baos.toByteArray();
fis.close();
baos.close();
return defineClass(className, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("类加载失败:" + className, e);
}
}
}
3. 泄漏触发代码(核心)
java
运行
import java.util.ArrayList;
import java.util.List;
/**
* 类加载器泄漏 → 元空间OOM 复现
*/
public class ClassLoaderLeakDemo {
// 全局List:强引用持有类加载器,导致泄漏!
private static final List<ClassLoader> CLASS_LOADER_LEAK = new ArrayList<>();
public static void main(String[] args) throws Exception {
int count = 0;
while (true) {
// 每次循环创建新的自定义类加载器
CustomClassLoader classLoader = new CustomClassLoader("/tmp/classes");
// 加载测试类(类名需和实际一致)
Class<?> testClass = classLoader.loadClass("TestClass");
System.out.println("第" + (++count) + "次加载类:" + testClass.getName() + ",加载器:" + classLoader);
// 模拟泄漏:把类加载器放入全局List,强引用无法释放
CLASS_LOADER_LEAK.add(classLoader);
// 加速OOM:释放局部引用,强制GC(但类加载器仍被List持有)
testClass = null;
classLoader = null;
System.gc();
}
}
}
4. JVM 参数配置(限制元空间,快速复现)
运行时添加 JVM 参数,限制元空间大小,快速触发 OOM:
plaintext
-XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=64m
5. 运行结果
程序会持续打印加载日志,最终抛出:
plaintext
java.lang.OutOfMemoryError: Metaspace
泄漏原因总结
CLASS_LOADER_LEAK这个全局静态 List强引用了所有自定义类加载器,导致每次循环创建的类加载器都无法被 GC,它加载的TestClass元数据永久占用元空间,反复加载后元空间耗尽。三、生产中常见的泄漏场景
实际项目中不会像 demo 一样故意写泄漏,常见隐蔽场景:
- 热部署 / 反复重启:Tomcat、Spring Boot DevTools 热部署时,旧类加载器未被释放
- 插件化开发:插件卸载后,插件类加载器被主程序引用
- 线程池泄漏:自定义类加载器加载的类,创建的线程放入全局线程池
- ThreadLocal 泄漏:线程池中的线程,ThreadLocal 持有类加载器引用
- 静态集合缓存:静态 List/Map 持有加载器、类或实例
四、快速定位:类加载器泄漏排查步骤
生产环境遇到元空间 OOM,按以下步骤定位:
1. 开启元空间监控
添加 JVM 参数,打印类加载 / 卸载信息:
plaintext
-XX:+TraceClassLoading -XX:+TraceClassUnloading
观察日志:如果只加载、不卸载,基本确定泄漏。
2. 导出堆转储文件(Heap Dump)
OOM 时自动导出 dump 文件,添加参数:
plaintext
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/metaspace.hprof
3. 用 MAT 分析泄漏
使用Eclipse MAT工具打开 dump 文件:
- 搜索自定义类加载器(如
CustomClassLoader) - 查看GC Roots(GC 根引用)
- 找到强引用链,定位泄漏代码(如静态集合、线程池)
4. 关键命令辅助
bash
运行
# 查看元空间使用情况
jstat -gcmetacapacity <PID>
# 查看已加载类数量
jmap -clstats <PID>
五、根治:类加载器泄漏解决方案
1. 切断强引用(核心)
- 静态集合用完及时清空,避免永久持有类加载器 / 实例
- 插件卸载时,手动移除所有引用
- 线程池使用完关闭,或使用
ThreadLocal.remove()清理
2. 正确使用线程池
禁止用全局线程池执行自定义类加载器加载的类的任务,如需使用,任务执行完毕后手动清理引用。
3. 热部署优化
- Spring Boot DevTools 排除不需要热部署的包
- Tomcat 关闭不必要的自动重载,升级 Tomcat 版本(修复类加载器泄漏 bug)
4. 手动释放类加载器
自定义类加载器使用完毕后,置空所有引用,强制 GC:
java
运行
// 释放引用
classLoader = null;
// 清理静态集合
CLASS_LOADER_LEAK.clear();
// 强制GC
System.gc();
5. JVM 参数调优
根据业务调整元空间大小,避免频繁 GC:
plaintext
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m
六、总结
类加载器泄漏是元空间 OOM 的头号元凶,核心是类加载器被强引用无法 GC,导致加载的类元数据永久占用元空间。
排查核心:找 GC Roots → 切断强引用链;解决关键:及时释放引用、规范使用线程池、避免静态集合滥用。
生产中遇到元空间 OOM,先排查类加载器泄漏,90% 的问题都能迎刃而解。