类加载机制
Java 的类加载机制决定了一个 .class 文件如何被加载到 JVM 中,并转化为可以使用的 Class 对象。理解它有助于排查 ClassNotFoundException、NoClassDefFoundError,以及理解 SPI、热部署等高级特性。
宏观视角:一个 Java 程序的启动全貌
很多人学习类加载时都有这样一个疑问:“当我们运行一个 Java 程序时,JVM 是一口气把代码里写到的所有类全都加载进内存吗?”
答案是:并不是,JVM 采用的是按需加载(懒加载 / Lazy Loading)。
整体加载时间线
- 入口启动:你输入
java Main,JVM 进程启动。 - 加载最基础类:JVM 会首先加载 JDK 中最底层的核心类(如
java.lang.Object、java.lang.String等),因为没有它们 Java 压根无法运行。 - 加载主类(Main):接着,JVM 找到你要执行的那个包含
main方法的主类(即Main.class),把它加载进内存并初始化。 - 按需触发:开始执行
main()方法的代码。在此之后往下走,只有当代码真正在运行期“碰”到某个类时,才会去触发那个类的加载。
代码直观示例:
public class Main {
public static void main(String[] args) {
System.out.println("程序启动!");
boolean flag = true;
if (flag) {
// JVM 运行到这里发现需要 new 一个 A,如果此时 A 还没加载过,就会立刻去找 A.class 加载
A a = new A();
} else {
// 因为执行不到这里,B 类虽然就在代码文本里写着,
// 但在整个程序的生命周期中,B.class 根本不会被加载进内存!
B b = new B();
}
}
}
[!NOTE] 形象化比喻:“点菜” vs “自助餐” JVM 的类加载不是自助餐(一进门把所有菜全端上来),而是点菜模式。 你坐下(JVM 启动),服务员先上茶水餐具(核心基类和 Main 主类)。之后你看着菜单(代码逻辑往后执行),点什么菜(
new哪个对象、调用哪个类的静态变量等),后厨才立刻去准备对应的食材(把对应的.class文件加载进内存)。如果菜单上的某道菜你一辈子没点,后厨就永远不需要准备它。 优势:现代应用动辄依赖成千上万个类,如果一口气全加载,启动会非常慢且白白浪费大量内存。
类的生命周期
加载 连接 初始化 使用 卸载
│ ┌─────┼──────┐ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
Load Verify Prepare Resolve Initialize Use Unload
验证 准备 解析
1. 加载(Loading)
- 通过类的全限定名获取二进制字节流(
.class文件) - 将字节流转化为方法区的运行时数据结构
- 在堆中生成一个
java.lang.Class对象,作为方法区数据的访问入口
字节流来源不限于文件——可以来自 JAR、网络、动态生成(如动态代理)、加密文件等。
2. 验证(Verification)
确保字节码格式合法、语义正确、不会危害 JVM 安全:
| 验证阶段 | 检查内容 |
|---|---|
| 文件格式 | 魔数(CAFEBABE)、版本号 |
| 元数据 | 是否有父类、是否继承了 final 类 |
| 字节码 | 操作数栈类型匹配、跳转指令合法 |
| 符号引用 | 引用的类、字段、方法是否存在 |
3. 准备(Preparation)
为类变量(static 变量)分配内存并设置零值:
// 准备阶段
public static int value = 123; // 此时 value = 0(不是 123)
public static final int CONST = 123; // 此时 CONST = 123(常量直接赋值)
注意:
- 实例变量不在此阶段分配(它们随对象在堆中分配)
static final的编译期常量在准备阶段就赋真值- 普通 static 变量赋零值,真正赋值在初始化阶段的
<clinit>中
4. 解析(Resolution)
将常量池中的符号引用替换为直接引用:
- 符号引用:用字符串描述引用的目标(如
java/lang/Object) - 直接引用:指向内存中目标的指针、偏移量或句柄
5. 初始化(Initialization)
执行类构造器 <clinit>() 方法——收集所有 static 变量的赋值和 static 块,按源码顺序执行:
public class Example {
static int a = 1; // ┐
static { // │ 按顺序合并为 <clinit>()
a = 2; // │
b = 3; // │ 此时 b 可以赋值(前向引用)
} // │
static int b = 4; // ┘ 最终 a=2, b=4
}
JVM 保证 <clinit>() 的线程安全性——如果多个线程同时初始化一个类,只有一个线程执行 <clinit>(),其他线程阻塞等待。这也是单例模式用静态内部类实现线程安全的原因。
什么时候触发初始化?(主动引用 vs 被动引用)
在 Java 中,类什么时候被加载进内存(Load)是有一定弹性的,但类什么时候被初始化(Initialization,即执行 <clinit> 并赋初值)是被 JVM 严格规定的。
JVM 规范严格规定了有且只有 6 种情况必须立即对类进行“初始化”,这被称为主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
[!NOTE] 形象化比喻:“正式开张” vs “蹭热度” 可以把“类”看成是一个待营业的明星经纪公司,而“初始化(执行
<clinit>)”就意味着这家公司正式开张营业,剪彩放炮,分配真实资源。 主动引用:相当于有客户带真金白银上门谈大生意(比如要签约明星、要调用核心机密),公司无法敷衍,必须立刻开张营业! 被动引用:相当于路人只是蹭个热度、打听点小道消息。为了省钱省事,JVM 规定这种行为不配让公司特意开张(不触发类的初始化)。
必须“开张”的情况(主动引用)
只要发生以下情况,类必须马上执行初始化:
| 情况 | 对应比喻 | 代码示例 |
|---|---|---|
1. 遇到 new、读写非 final 的静态字段、调用静态方法 |
客户点名要见明星、买机密、用公司的公车 | new MyClass()、MyClass.value = 1、MyClass.doSomething() |
| 2. 使用反射调用 | 上级领导带着文件来查户口了,必须开门 | Class.forName("com.example.MyClass") |
| 3. 初始化子类时,发现父类还没初始化 | 小弟要开张了,做大哥的必须先摆平(先初始化父类)爹没生出来哪有儿子 | 先初始化它的父类 |
4. 虚拟机启动时包含 main 方法的主类 |
公司总部,必须第一个带头营业 | java Main 里的 Main 类 |
| 5. JDK 7+ 动态语言支持 | 解析 REF_getStatic 等方法句柄时 |
方法句柄调用的目标类 |
| 6. JDK 8+ 的接口包含 default 方法时,它的实现类被初始化了 | 合同变了,默认条款生效,必须通知 | 含有默认方法的接口 |
不值得“开张”的情况(被动引用)
除了上述规定,其他的“蹭热度”行为都不会触发类的初始化 <clinit>:
// 情景 1:通过小弟找大哥的资产
// 外人看似是通过子类在请求,但请求的资产是父类所有的。
// 结果:只触发负责处理这事的“父类”初始化,小弟(子类)不需要开张(不触发子类的初始化)!
System.out.println(SubClass.parentStaticField);
// 情景 2:只是盖了空的大楼(数组定义)
// 相当于告诉系统“我需要 10 个 MyClass 存放空间”。
// 结果:只是在堆里建了一个数组对象,至于里面的元素(MyClass)连影子都没有,更不配让 MyClass 专门为你开张!
MyClass[] arr = new MyClass[10];
// 情景 3:看大家都知道的公开报纸(引用编译期 final 常量)
// MyClass 里的 static final int CONSTANT = 42 在编译时就已经被刻在调用者的文件里了(常量内联)。
// 结果:调用者想看这个数字直接看自己的缓存就行了,根本不需要大老远跑到 MyClass 公司去问,因此决不去打扰它开张!
System.out.println(MyClass.CONSTANT);
类加载器
三层类加载器
Bootstrap ClassLoader (C++ 实现,不是 Java 类)
│ 加载 JAVA_HOME/lib 下的核心类(rt.jar 等)
│
▼
Extension ClassLoader (JDK 9+ 改名 Platform ClassLoader)
│ 加载 JAVA_HOME/lib/ext 下的扩展类
│
▼
Application ClassLoader(也叫 System ClassLoader)
│ 加载 classpath 下的应用类
│
▼
自定义 ClassLoader
加载自定义路径的类
[!TIP] 通俗理解:classpath 究竟是个啥? 很多人对
classpath(类路径)只知其名不知其意。你可以把 Application ClassLoader 想象成一个专门跑腿找食材的厨师,而classpath就是你给这个厨师递交的地址清单。厨师虽然会处理食材(加载
.class文件),但他不知道你的食材放在哪。于是你通过配置classpath告诉他:“以后遇到代码里需要的类,就去清单上列的这些目录或者 .jar 包里面找。”
- 厨师的执行动作:当代码里执行到
new User()时,厨师就会亮出classpath清单,从第一个地址开始逐一翻找User.class文件。一旦在某个地址找到了,就立马拿回来用,后面的地址就不看了。如果翻遍了清单上所有的地址都没找到,厨师就会向你抛出一个经典的ClassNotFoundException。- 它在日常开发中的体现:
- 在 IDEA 里点击 Run 时:IDEA 在后台默默帮你把
target/classes/(你自己写的代码编译后的目录)以及 Maven 下载的一大堆.jar包的本地路径拼接成了一个超长的classpath清单,喂给了 JVM。- 在命令行运行程序时:就是我们经常写的
java -cp ./lib/*:./bin Main(-cp就是 classpath 的缩写,大白话就是告诉 JVM 去./lib目录下和./bin目录下去取.class字节码)。
双亲委派模型
加载请求: 加载 com.example.MyClass
│
Application ClassLoader
"我自己能加载吗?不知道,先问父亲"
│ 委派给父加载器
▼
Extension ClassLoader
"我自己能加载吗?不知道,先问父亲"
│ 委派给父加载器
▼
Bootstrap ClassLoader
"我的范围里没有这个类"
│ 返回:加载失败
▼
Extension ClassLoader
"我的范围里也没有"
│ 返回:加载失败
▼
Application ClassLoader
"在 classpath 下找到了!我来加载"
│
加载成功 ✓
核心逻辑:
protected Class<?> loadClass(String name, boolean resolve) {
// 1. 检查类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 委派给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name); // 到顶了
}
} catch (ClassNotFoundException e) {
// 父加载器找不到
}
if (c == null) {
// 3. 父加载器都找不到,自己加载
c = findClass(name);
}
}
return c;
}
为什么要双亲委派?
- 安全性:防止用户自定义
java.lang.String替换核心类 - 唯一性:保证同一个类只被加载一次——同一个类被不同类加载器加载会被视为不同的类
- 层次性:核心类由核心加载器加载,确保一致性
打破双亲委派的场景
| 场景 | 方式 | 原因 |
|---|---|---|
| SPI(JDBC、JNDI) | Thread Context ClassLoader | 核心类需要加载应用类(反向依赖) |
| OSGi | 网状委派 | 模块化需要不同版本共存 |
| 热部署(Tomcat) | 自定义类加载器 | 每个 Web 应用需要隔离 |
| JDK 9 模块系统 | 模块化加载 | 模块间依赖管理 |
SPI 是什么?它为什么要打破双亲委派?
**SPI(Service Provider Interface,服务提供者接口)**可以理解为一种“官方制定标准,第三方负责实现”的插件机制。最典型的例子就是 JDBC 连接数据库。
[!NOTE] 形象化比喻:“中央政府” 与 “民间企业” 的尴尬 中央政府(Bootstrap ClassLoader):负责加载 JDK 最核心的类(即
rt.jar里的那些类)。 地方市场(Application ClassLoader):负责加载民间企业开发的东西(也就是我们classpath下引入的那些第三方.jar包)。
- 制定标准:中央政府(JDK)在
rt.jar中颁布了连接数据库的标准规范,即java.sql.Driver接口,还有统一的管理工具java.sql.DriverManager。- 民间响应:MySQL 和 Oracle 这些第三方“民间企业”一看,纷纷按照标准实现了具体的连接逻辑,并把代码打包成
mysql-connector-java.jar扔到了地方市场(classpath)上。- 遇到尴尬(双亲委派的死局): 有一天,你在代码里调用了核心库的方法:
DriverManager.getConnection()。 因为DriverManager这个类是 JDK 自带的,它自然是由中央政府(Bootstrap ClassLoader)加载的。当它在运行中发现需要动态去加载具体的 MySQL 驱动类(如com.mysql.cj.jdbc.Driver)时,问题来了! 按照严格的双亲委派模型(自底向上委派),中央政府(Bootstrap)处于最顶层,它的眼里只有自己的rt.jar核心库。它根本看不见、也无权去加载放在地方市场(classpath)上的民间 MySQL 驱动。更要命的是,双亲委派只允许“儿子求老爸办事”,绝不允许“老爸反过来求儿子办事”。- 打破委派(解决方案): 怎么办?为了破这个局,Java 引入了一个“作弊神器”——线程上下文类加载器(Thread Context ClassLoader)。 当
DriverManager(高层加载器)需要加载第三方驱动时,它直接拿起对讲机,获取当前线程的上下文加载器(默认这玩意就是地方市场的 Application ClassLoader)。然后绕开层级规矩,强行使唤这个底层的加载器去把 MySQL 的类加载进来!
核心本质: 双亲委派模型是严格的单向自底向上。但在 SPI 场景中,核心层代码(高层加载器加载的类)不可避免地要调用应用层代码(底层加载器加载的类),形成了反向依赖。为了让高层能够“逆向”调用底层的加载器,只能打破双亲委派。
伪代码演示打破过程:
// 核心类 DriverManager(Bootstrap 加载)
public class DriverManager {
static {
// 这是一段处于高层的核心代码,它想去加载底层的具体实现类
// 如果按照规矩用自己的类加载器,绝对找不到 mysql 的 Driver
// 【打破规矩的时刻】
// 它拿到当前线程里挂载的那个“底层类加载器”(Application ClassLoader)
ClassLoader callerCL = Thread.currentThread().getContextClassLoader();
// 然后让这个底层加载器去帮它加载 classpath 下的类!
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class, callerCL);
}
}
自定义类加载器
继承 ClassLoader,重写 findClass() 方法:
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@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 = classPath + "/" + name.replace('.', '/') + ".class";
try (InputStream is = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
} catch (IOException e) {
return null;
}
}
}
注意:重写 findClass() 而不是 loadClass()——这样仍然遵守双亲委派。只有需要打破双亲委派时才重写 loadClass()。
应用场景
- 热部署:丢弃旧的类加载器,创建新的来加载更新后的类
- 加密保护:加密 class 文件,在自定义类加载器中解密
- 类隔离:不同模块使用不同类加载器,隔离依赖冲突
Tomcat 的类加载架构
Tomcat 必须打破双亲委派,因为多个 Web 应用可能需要不同版本的同一个库:
Bootstrap
│
Extension
│
Application
│
Common ClassLoader(Tomcat 公共库)
┌────┴────┐
Catalina Shared
ClassLoader ClassLoader(Web 应用共享库)
┌───┼───┐
WebApp1 WebApp2 WebApp3
ClassLoader(每个应用独立)
WebApp ClassLoader 的加载顺序:
- 先在自己的
/WEB-INF/classes和/WEB-INF/lib中查找(打破双亲委派) - 找不到再委派给父加载器
这样 WebApp1 和 WebApp2 可以使用不同版本的同一个库而不冲突。
生产环境核心踩坑点
| 问题 | 答案要点 |
|---|---|
| 类加载的过程? | 加载 → 验证 → 准备 → 解析 → 初始化 |
| 什么是双亲委派?为什么需要? | 先委派父加载器,保证安全性和唯一性 |
| 什么情况下会打破双亲委派? | SPI、Tomcat、OSGi、热部署 |
| SPI 为什么要打破双亲委派? | 核心类需要加载 classpath 的实现类,用线程上下文类加载器 |
| 如何自定义类加载器? | 继承 ClassLoader,重写 findClass() |
| 准备阶段和初始化阶段的区别? | 准备:赋零值;初始化:执行 <clinit>() 赋真值 |
<clinit>() 是线程安全的吗? |
是,JVM 保证只有一个线程执行 |