深入理解 Java 类加载机制与双亲委派模型
在 Java 的世界里,有一句著名的口号:“一次编写,到处运行(Write Once, Run Anywhere)”。支撑这一特性的核心基石之一,就是 JVM 的类加载机制。
与 C/C++ 需要在编译时进行链接不同,Java 将类的加载、连接和初始化放在了运行期。这种动态加载机制赋予了 Java 极大的灵活性(如反射、热部署、动态代理等)。
本文将详细拆解类的生命周期,深入剖析类加载的五个阶段,并重点讲解大名鼎鼎的双亲委派模型。
一、类的生命周期
一个 Java 类从被加载到 JVM 内存中,到最终被卸载出内存,它的生命周期共分为 7 个阶段:
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。而解析阶段在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定(晚期绑定)特性。
二、类加载的详细过程与代码示例
1. 加载(Loading)
在这个阶段,JVM 需要完成三件事情:
2. 验证(Verification)
确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,保证虚拟机的安全(如检查魔数 0xCAFEBABE、版本号等)。
3. 准备(Preparation)
为类变量(被 static 修饰的变量)分配内存并设置零值。
- 注意:这里不包含实例变量。
- 注意:如果是 final 修饰的常量,JVM 会在准备阶段直接将其赋值为真实值。
4. 解析(Resolution)
将常量池内的符号引用替换为直接引用的过程。
5. 初始化(Initialization)
初始化阶段是执行类构造器 <clinit>() 方法的过程。在这个阶段,JVM 才真正开始执行类中定义的 Java 程序代码(如静态变量的真实赋值、静态代码块)。
🔍 【代码示例:准备阶段 vs 初始化阶段】
为了直观理解准备阶段和初始化阶段的区别,请看下面这段代码:
public class ClassInitDemo {
// 1. 准备阶段:分配内存,赋零值(0)
// 2. 初始化阶段:赋真实值(10)
public static int value = 10;
// 1. 准备阶段:因为是 final 常量,直接赋真实值(20)
public static final int CONST_VALUE = 20;
// 静态代码块在初始化阶段执行
static {
System.out.println("ClassInitDemo 正在初始化…");
value = 30; // 覆盖之前的 10
}
}
在类加载的准备阶段,value 会被赋值为 0,CONST_VALUE 直接赋值为 20。
在初始化阶段,执行 <clinit>() 方法,value 会先被赋值为 10,然后执行静态代码块,打印提示信息,最后 value 被改为 30。
🔍 【代码示例:什么时候会触发初始化?】
JVM 严格规定了只有主动引用才会触发初始化。我们可以通过被动引用的例子来验证。
场景一:通过子类引用父类的静态字段
class Parent {
static { System.out.println("Parent 初始化"); }
public static int value = 100;
}
class Child extends Parent {
static { System.out.println("Child 初始化"); }
}
public class Test {
public static void main(String[] args) {
// 只输出 "Parent 初始化",不会输出 "Child 初始化"
// 因为对于静态字段,只有直接定义这个字段的类才会被初始化
System.out.println(Child.value);
}
}
场景二:通过数组定义来引用类
public class Test {
public static void main(String[] args) {
// 不会触发 Parent 的初始化,只会生成一个 [LParent; 的数组类型对象
Parent[] parents = new Parent[10];
}
}
场景三:引用 static final 常量
class Constants {
static { System.out.println("Constants 初始化"); }
public static final String HELLO = "Hello World";
}
public class Test {
public static void main(String[] args) {
// 不会触发 Constants 初始化
// 因为在编译阶段,HELLO 已经存入 Test 类的常量池中,属于编译期优化
System.out.println(Constants.HELLO);
}
}
三、类加载器(ClassLoader)
类加载器负责将 .class 文件加载到 JVM 中。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 JVM 中的唯一性。
JVM 提供了以下三种系统提供的类加载器:
- C++ 实现,加载 <JAVA_HOME>\\lib 目录下的核心类库(如 rt.jar)。
- 无法被 Java 程序直接引用(在代码中获取会返回 null)。
- Java 实现,加载 <JAVA_HOME>\\lib\\ext 目录下的类库。
- Java 实现,默认的类加载器,负责加载用户 ClassPath 上的类。
四、双亲委派模型(Parent Delegation Model)
1. 什么是双亲委派模型?
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。
工作流程:
当一个类加载器收到类加载的请求时,它不会自己先去尝试加载,而是把这个请求委派给父类加载器去完成。每一层都是如此。
只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
流程图:
自定义类加载器
|
(委派请求) —> 应用程序类加载器
|
(委派请求) —> 扩展类加载器
|
(委派请求) —> 启动类加载器
|
[找到] <———————————- (尝试加载)
| |
[加载成功] [找不到]
|
[子加载器尝试加载] <—————————– (回退)
🔍 【代码示例:查看类加载器的委派层级】
我们可以通过 getClassLoader() 方法来观察类的加载器层级:
public class ClassLoaderDemo {
public static void main(String[] args) {
// 1. String 类由启动类加载器加载,Java 中获取不到,返回 null
ClassLoader loader1 = String.class.getClassLoader();
System.out.println("String 的类加载器: " + loader1); // 输出: null
// 2. 我们自己写的类,由应用程序类加载器加载
ClassLoader loader2 = ClassLoaderDemo.class.getClassLoader();
System.out.println("ClassLoaderDemo 的类加载器: " + loader2);
// 输出: sun.misc.Launcher$AppClassLoader@…
// 3. 获取应用程序类加载器的父加载器(扩展类加载器)
System.out.println("AppClassLoader 的父加载器: " + loader2.getParent());
// 输出: sun.misc.Launcher$ExtClassLoader@…
// 4. 扩展类加载器的父加载器是启动类加载器,获取不到
System.out.println("ExtClassLoader 的父加载器: " + loader2.getParent().getParent());
// 输出: null
}
}
2. 双亲委派模型的优点
🔍 【代码示例:验证双亲委派的安全机制】
假设我们在自己的项目中,恶意或出于好奇,创建了一个与 JDK 核心类同名的类 java.lang.String:
package java.lang;
public class String {
static {
System.out.println("我是黑客自己写的 String 类!");
}
public static void main(String[] args) {
System.out.println("尝试执行自定义的 String 类");
}
}
如果你尝试运行这个 main 方法,程序会报错(或者根本加载不到我们写的这个类)。
原因就在于双亲委派模型:当 AppClassLoader 收到加载 java.lang.String 的请求时,它会一路委派给 Bootstrap ClassLoader。Bootstrap 发现这是核心类,直接加载了 JDK 真正的 String 类,我们写的这个假冒类根本没有机会被加载。这极大地保证了 JVM 的安全。
3. 如何打破双亲委派模型?(进阶)
双亲委派模型并不是一个强制性的约束,在以下场景中,它的机制被“打破”了:
- 核心接口 java.sql.Driver 由 Bootstrap ClassLoader 加载。但具体的数据库驱动(如 com.mysql.cj.jdbc.Driver)由 AppClassLoader 加载。
- 为了解决父加载器无法看到子加载器加载的类的问题,引入了线程上下文类加载器(Thread Context ClassLoader),由父加载器“逆向委派”给子加载器去加载。
- Tomcat 为了实现应用隔离(不同 Web 应用依赖不同版本的库)和热部署,采用了破坏双亲委派的自定义类加载器架构。它优先加载本地 Web 应用目录下的类,只有在找不到时才委派给父类加载器。
五、总结
| 加载过程 | 加载 -> 验证 -> 准备 -> 解析 -> 初始化 |
| 准备阶段 | 为 static 变量分配内存并赋零值(final 常量直接赋真实值) |
| 初始化 | 执行 <clinit>(),包括静态变量赋值和静态代码块 |
| 类加载器 | Bootstrap、Extension、Application |
| 双亲委派 | 收到请求先委派给父类,父类无法加载再由子类加载 |
| 双亲委派优点 | 保证核心类库安全、避免类重复加载 |
| 打破双亲委派 | 线程上下文类加载器(SPI)、Tomcat 等自定义容器 |
掌握类加载过程与双亲委派模型,不仅能帮你从容应对面试,更能让你在排查 ClassNotFoundException、NoClassDefFoundError、ClassCastException(同一个类被不同加载器加载导致不相等)等疑难杂症时,做到对症下药。
网硕互联帮助中心






评论前必须登录!
注册