GC 篇讲的是"对象怎么回收",本文讲的是"类怎么进来"——一个 文件从磁盘上的字节流,到内存中可以被 出来的 Class 对象,中间要经历什么?为什么 Tomcat 能在同一台机器上部署两个不同版本的 Spring?为什么 和 长得像却完全是两回事?这一切的答案都藏在类加载机制里。本文把加载过程、双亲委派、破坏双亲委派的经典案例、自定义类加载器一次讲透。
一、类的生命周期:从字节流到可用对象
一个类的完整生命周期是:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。前五个阶段合起来就是"类加载过程",其中验证、准备、解析又合称连接(Linking)。
注意:加载、验证、准备、初始化这四个阶段的开始顺序是确定的,但不要求同步完成——验证、准备、解析常常交叉进行(解析可以在初始化之后才做,这是 Java 支持运行时绑定的基础)。
1.1 加载(Loading)阶段做什么
加载阶段只做三件事:
- 通过全限定名获取字节流:从 jar、网络、动态代理生成,甚至 JSP 编译产物中来——来源不限,这是自定义类加载器的发挥空间;
- 解析为方法区的运行时数据结构;
- 在堆中生成一个
java.lang.Class对象,作为方法区这个类的访问入口。
1.2 验证与准备
验证:确保字节流符合 JVM 规范,防止恶意构造的字节码搞垮虚拟机。包括文件格式验证(魔数 0xCAFEBABE)、元数据验证、字节码验证、符号引用验证四步。
准备:为类的静态变量分配内存并赋"零值",注意还不是代码里写的值:
public static int value = 123;
// 准备阶段:value = 0
// 初始化阶段:value = 123
public static final int CONST = 123;
// 编译期常量,准备阶段直接 = 123(ConstantValue 属性)
| 变量类型 | 准备阶段的值 | 原因 |
|---|---|---|
static int value = 123 |
0 | 赋值动作在 <clinit>() 中执行 |
static final int CONST = 123 |
123 | 编译期常量,有 ConstantValue 属性 |
static String s = "abc" |
null | 引用类型零值是 null |
1.3 初始化:<clinit>() 与线程安全
初始化阶段执行编译器收集而来的 <clinit>() 方法——它是静态变量赋值语句 + 静态代码块按源码顺序合并而成。几个关键规则:
<clinit>()与类的构造器(<init>())不同,JVM 保证它只被执行一次且加锁——这也是"静态内部类单例模式"线程安全的原理;- 父类的
<clinit>()保证先于子类执行; - 接口的
<clinit>()不需要先执行父接口的,只有真正用到父接口的变量时才触发; - 如果
<clinit>()抛异常,这个类会被标记为错误状态,之后再引用只会抛NoClassDefFoundError,不会重试初始化(生产上常见的坑)。
1.4 什么时候才会初始化:主动引用 vs 被动引用
JVM 规范只规定了六种主动引用会触发初始化,其余都是被动引用(不触发):
| 主动引用(触发初始化) | 被动引用(不触发) |
|---|---|
new、getstatic、putstatic、invokestatic 四条字节码指令 |
通过子类引用父类的静态字段(只初始化父类) |
反射调用(Class.forName()) |
定义数组:MyClass[] arr = new MyClass[10] |
| 初始化子类时父类还没初始化 | 引用编译期常量(已进常量池) |
| JVM 启动的主类(main 所在类) | —— |
MethodHandle 句柄对应的类 |
—— |
| 接口含 default 方法时实现类初始化 | —— |
class Parent {
public static String A = "parent";
static { System.out.println("Parent init"); }
}
class Child extends Parent {
public static String B = "child";
static { System.out.println("Child init"); }
}
Parent p = new Child(); // 打印 Parent init、Child init
System.out.println(Child.A); // 只打印 Parent init!静态字段只有直接定义它的类才被初始化
MyClass[] arr = new MyClass[10]; // 不打印任何初始化信息,数组类 [LMyClass 由 JVM 直接生成
二、类加载器与双亲委派模型
2.1 四层类加载器
| 类加载器 | 实现语言 | 负责加载 | 备注 |
|---|---|---|---|
| Bootstrap ClassLoader | C++(JVM 自身) | JAVA_HOME/lib 核心类库,如 java.lang.* |
JVM 中没有对应对象,String.class.getClassLoader() 返回 null |
| Extension / Platform ClassLoader | Java | lib/ext 或 JDK 9 模块平台部分 |
JDK 9 起改名为 Platform |
| Application ClassLoader | Java | classpath 上的类,即我们自己写的类 | ClassLoader.getSystemClassLoader() |
| 自定义 ClassLoader | Java | 特定来源:加密文件、网络、热部署 | 继承 ClassLoader,覆写 findClass() |
System.out.println(String.class.getClassLoader()); // null(Bootstrap)
System.out.println(ClassLoader.getPlatformClassLoader()); // platform
System.out.println(Main.class.getClassLoader()); // AppClassLoader
System.out.println(Main.class.getClassLoader().getParent()); // platform
2.2 双亲委派:工作流程与设计动机
双亲委派的核心逻辑一句话:收到类加载请求,先一路向上委托给父加载器,父加载器反馈无法完成时,才由自己加载。
// ClassLoader.loadClass 的核心逻辑(简化)
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
// 1. 查缓存,已加载的直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
if (parent != null) {
c = parent.loadClass(name, false); // 2. 先委托父加载器
} else {
c = findBootstrapClassOrNull(name); // 3. 没有父了,找 Bootstrap
}
if (c == null) {
c = findClass(name); // 4. 父加载不了,自己加载
}
}
return c;
}
}
双亲委派不是强制约束,而是 默认实现推荐的模式。它解决两个问题:① 避免重复加载——同一个类只会被同一个加载器加载一次,保证全 JVM 中类的唯一性;② 安全——你自己写一个 也无法替换核心类,因为请求永远先到 Bootstrap,核心版本永远优先。
记住一个推论:类的相等性 = 全限定名相同 + 定义类加载器相同。同一个 class 文件被两个加载器各加载一次,得到的是两个不相等的 Class,互相赋值会抛 ClassCastException。
三、破坏双亲委派:SPI、Tomcat 与热部署
3.1 线程上下文类加载器:JDBC 的破局
双亲委派有个天然矛盾:核心库(Bootstrap 加载)需要回调用户代码(Application 加载)。典型就是 JDBC——java.sql.DriverManager 在 rt.jar 里由 Bootstrap 加载,但具体的 com.mysql.cj.jdbc.Driver 在用户 classpath 上,Bootstrap 根本"看"不到。
解法是线程上下文类加载器(Thread Context ClassLoader):JDK 提供一个后门,让父加载器可以"借用"当前线程持有的加载器(默认是 Application ClassLoader)去加载 SPI 实现类——这是父类加载器请求子类加载器完成加载,双亲委派被破坏:
// DriverManager 内部(简化)
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ServiceLoader.load 内部:
// ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 用线程上下文加载器去加载 META-INF/services 里注册的实现
3.2 Tomcat:隔离优先
Tomcat 的 WebappClassLoader 则是另一种破坏:先自己加载,加载不到再委托父加载器。这是有意为之——每个 Web 应用要用独立的 WebappClassLoader,实现:
- 依赖隔离:A 应用用 Spring 5,B 应用用 Spring 6,互不干扰;
- 热部署:卸载旧的 WebappClassLoader 再建新的,整个应用的类全部换血,无需重启 Tomcat。
3.3 JDK 9 模块化之后
JPMS 引入后,"三层加载器"变成 Bootstrap + Platform + Application,加载逻辑有所变化(部分类由 Bootstrap 直接从模块加载),但双亲委派的整体骨架依然保留,自定义类加载器的写法没有变化。
四、自定义类加载器与热部署实战
4.1 标准写法:只覆写 findClass
public class CryptoClassLoader extends ClassLoader {
private final String classDir;
public CryptoClassLoader(String classDir, ClassLoader parent) {
super(parent); // 保留双亲委派
this.classDir = classDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] data = loadClassData(name);
if (data == null) throw new ClassNotFoundException(name);
// defineClass 是加载阶段的核心:字节流 → Class 对象
return defineClass(name, data, 0, data.length);
}
private byte[] loadClassData(String name) {
String path = classDir + File.separator
+ name.replace('.', File.separatorChar) + ".class";
try (InputStream in = new FileInputStream(path);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = in.read(buf)) != -1) out.write(buf, 0, len);
return out.toByteArray();
} catch (IOException e) {
return null;
}
}
}
要点:覆写 而不是 ——loadClass 负责双亲委派流程,findClass 负责真正的"找字节码"。只有要主动破坏委派(如 Tomcat、热部署)时才覆写 loadClass。
4.2 热部署:重新加载一个"新"的类
public static Class<?> hotSwap(String classDir, String className) throws Exception {
// 每次热部署都 new 一个新的加载器
CryptoClassLoader loader = new CryptoClassLoader(classDir,
CryptoClassLoader.class.getClassLoader());
return loader.loadClass(className);
}
热部署生效的前提是类能被卸载,而类卸载的三个条件极其苛刻:
- 该类所有实例都已被 GC;
- 加载该类的 ClassLoader 实例已被 GC;
- 对应的
Class对象没有任何地方引用。
所以热部署的正确姿势是:换掉加载器 → 丢掉旧引用 → 让 GC 完成类的卸载。只要有一处静态变量或 ThreadLocal 还抓着旧 Class,旧类就永远卸不掉,Metaspace 就会持续增长。
五、高频问题排查:三个"找不到类"的错误
| 错误 | 含义 | 典型原因 | 排查方向 |
|---|---|---|---|
ClassNotFoundException |
受查异常,显式加载时找不到 | Class.forName() 路径写错、依赖没打进 jar |
检查 classpath、 fat jar 是否包含该类 |
NoClassDefFoundError |
Error,编译期存在、运行期缺失 | 依赖 scope 配成 provided、<clinit>() 抛过异常、冲突 jar 被裁剪 |
看首次异常日志里是否有 ExceptionInInitializerError |
NoSuchMethodError |
运行期方法缺失 | jar 包冲突,编译时的版本和运行时加载的版本不一致 | mvn dependency:tree 找冲突,排除旧版本 |
// 一个隐蔽的 NoClassDefFoundError 现场
public class Config {
static {
if (System.getenv("ENV") == null) {
throw new IllegalStateException("ENV not set"); // 首次初始化失败
}
}
}
// 第一次使用:ExceptionInInitializerError(真实原因)
Config c = new Config();
// 之后所有使用:NoClassDefFoundError(只报"找不到",不再重试初始化)
六、避坑清单
- 静态变量赋值分两步走:准备阶段是零值,初始化阶段才是代码值——单例模式饿汉式写
private static Singleton s = new Singleton()时,构造器里不要依赖另一个还没赋值的静态变量。 <clinit>()只执行一次且失败不重试:静态块里别写可能抛异常的代码(连不上数据库、读不到配置),否则整个类永久"报废",报的还是误导性的NoClassDefFoundError。- 判断两个类是否相同,除了全限定名还要看类加载器:跨加载器的 Class 强转会抛
ClassCastException,Tomcat 多应用部署时最容易遇到。 - 自定义类加载器优先覆写
findClass,别一上来就覆写loadClass破坏委派,除非真的要做隔离或热部署。 - SPI 场景理解线程上下文类加载器:写框架代码要加载用户实现类时,用
Thread.currentThread().getContextClassLoader(),而不是getClass().getClassLoader()。 - 热部署必须检查类卸载三条件:Metaspace 持续上涨往往是旧 ClassLoader 没被 GC,用 jmap 查
ClassLoader实例数。 - 依赖冲突统一用 maven-enforcer-plugin 阻断:
NoSuchMethodError九成是版本冲突,与其靠经验排查不如构建期直接失败。 - 不要用自定义类加载器加载
java.*:Bootstrap 优先,写也白写;而且以java.开头的类名会被 JVM 直接拒绝(SecurityException)。
JVM GC 篇 + 本文,运行时的两大主线——"类怎么进来"和"对象怎么出去"——就齐了。下一篇计划聊聊 JVM 内存分配与逃逸分析,把对象创建的另一面补上。