满足什么条件时,一个 Java 类会被卸载?
结论先行:类卸载要同时满足三个条件:该类的所有实例都已被回收、加载该类的 ClassLoader 已被回收、该类的 Class 对象没有任何引用(无反射调用、无静态字段被访问、无子类存活)。而且卸载发生在 Full GC 期间,粒度是「整个类加载器」,不是单个类——只要加载器活着,它加载的类就一个都卸不掉。
一、三个条件的关系
Class 对象 ── 被谁引用?
├── 反射调用:Method/Field/Constructor 缓存 → 不满足
├── 静态字段被访问:Class 的 mirror 被持有 → 不满足
├── 该类还有实例存活 → 不满足
├── 该类的子类还在用 → 不满足
└── 加载它的 ClassLoader 仍被强引用 → 不满足(最常见)
三个条件全满足 + Full GC 扫描到 → 卸载
| 条件 | 检查方式 | 典型破坏者 |
|---|---|---|
| 实例全部回收 | jmap -histo 中该类实例数为 0 | 长生命周期缓存持有对象 |
| ClassLoader 被回收 | MAT 中 loader 无 GC Root 路径 | 静态字段持有 loader、线程 TCCL、JNI 全局引用 |
| Class 对象无引用 | 无反射缓存、无 Class.forName 结果留存 | 框架的反射缓存、MethodHandle |
二、为什么粒度是加载器
JVM 内部用「类加载器 + 类名」唯一确定一个类。类卸载时方法区里的元数据是按加载器整体释放的,所以热部署框架(如 Tomcat、Spring Boot DevTools)都会为每次部署新建一个 ClassLoader,丢掉旧 loader 的引用,等 Full GC 后整批类一起卸载。这也是为什么动态生成类太多会撑爆元空间——只要 loader 没被回收,生成的类就全留着。
public class UnloadDemo {
static class Holder {
static byte[] payload = new byte[4 * 1024 * 1024];
static { System.out.println("Holder 初始化"); }
}
public static void main(String[] args) throws Exception {
java.net.URLClassLoader loader = new java.net.URLClassLoader(
new java.net.URL[]{new java.io.File("plugin/").toURI().toURL()},
UnloadDemo.class.getClassLoader());
Class<?> c = loader.loadClass("com.example.Plugin");
Object instance = c.getDeclaredConstructor().newInstance();
System.out.println("加载器:" + c.getClassLoader());
c = null; // 断开 Class 引用
instance = null; // 断开实例引用
loader.close();
loader = null; // 断开加载器引用(关键)
System.gc(); // 需要 Full GC 才可能卸载
Thread.sleep(300);
System.out.println("已释放引用,等待卸载");
}
}
三、观察卸载
# JDK 8:打印类卸载
java -XX:+TraceClassUnloading -cp . UnloadDemo
# JDK 9+:统一日志
java -Xlog:class+unload=info -cp . UnloadDemo
# 看元空间与已加载类数量变化
jcmd <pid> GC.class_stats | grep -c "com/example"
jstat -gcmetacapacity <pid> 1000 5
四、常见追问
| 追问 | 回答要点 |
|---|---|
| 类卸载一定需要 Full GC 吗 | 是的,只有整堆回收(含元空间扫描)时才可能判定类不可达 |
| 为什么 JDK 自带的类卸不掉 | 由启动类加载器加载,而启动类加载器永远存活,条件二不满足 |
| 动态代理类会卸载吗 | 会,前提是生成它的加载器(如 Proxy 用的自定义 loader)被回收;JDK 8 的 Proxy 类缓存在 WeakCache 中 |
| 什么现象说明类没卸载 | Metaspace 持续增长、jcmd GC.class_stats 中类数量只增不减、Full GC 后元空间不回落 |
| 热部署为什么容易 Metaspace OOM | 每次部署新建 loader,旧 loader 被线程、静态变量或 Timer 线程引用,类就永远留着 |
小结
「实例没了、加载器没了、Class 也没人引用」三条同时成立,才会在 Full GC 时卸载,而且是以加载器为单位批量释放。理解这一点,就能解释热部署框架的设计以及元空间泄漏的根因。