在 Java 世界里,类加载器(ClassLoader)是 JVM 的核心组件之一,它负责将编译后的.class字节码文件加载到内存中,并转换为java.lang.Class对象。而加载的方式则是双亲委派机制。
接下来会围绕类的加载器 ,双亲委派机制这两方面讲解。
一. 类加载器
1.1 什么是类加载器
类加载器是 JVM 的一个重要组成部分,它的核心职责是根据类的全限定名(如java.lang.String),找到对应的字节码文件,并将其加载到内存中,生成对应的Class对象。
简单来说,类加载器就像是 JVM 的 "搬运工",负责把磁盘上或网络上的字节码 "搬" 到内存里,让 JVM 能够执行这些代码。
1.2 类加载器的加载对象
类加载器加载的对象是Java 字节码文件(.class 文件),但不仅仅局限于本地磁盘上的文件。理论上,任何可以转换为字节数组的数据源都可以作为类加载器的加载来源,包括:
- 本地文件系统中的
.class文件 - JAR 包或 ZIP 包中的
.class文件 - 网络传输的字节流(如从 HTTP 服务器下载)
- 数据库中存储的字节数据
- 动态生成的字节码(如 ASM、CGLIB 等框架)
1.3 JDK 中的类加载器体系
从 JDK 9 开始,Java 对类加载器体系进行了重大调整,将原来的 "启动类加载器 - 扩展类加载器 - 应用程序类加载器" 三层结构,改为 "启动类加载器 - 平台类加载器 - 应用程序类加载器" 三层结构。
下面是 JDK 9 及以后版本的类加载器体系:
|-----------------------------------|----------|-------------|-----------------------------------------------|-----------------------------------------------|
| 类加载器名称 | 实现语言 | 父加载器 | 加载范围 | 示例 |
| 启动类加载器(Bootstrap ClassLoader) | C++ | null | JDK 核心类库(JAVA_HOME/jmods目录下的java.base等模块) | java.lang.*、java.util.*、java.io.*等 |
| 平台类加载器(Platform ClassLoader) | Java | Bootstrap | JDK 平台模块(除java.base外的其他内置模块) | java.sql.*、java.xml.*、jdk.management.*等 |
| 应用程序类加载器(Application ClassLoader) | Java | Platform | 应用程序 classpath 下的类 | 我们自己编写的业务代码、Maven/Gradle 引入的第三方依赖 |
| 自定义类加载器(Custom ClassLoader) | Java | Application | 自定义来源的类 | 加密的字节码文件、网络下载的类、热部署的类 |
1.4 类加载器示例代码
让我们通过一个简单的例子,看看不同类是由哪个类加载器加载的:
java
public class ClassLoaderDemo {
public static void main(String[] args) {
// 1. 启动类加载器加载的类(输出null,因为是C++实现的)
System.out.println("String类的加载器:" + String.class.getClassLoader());
// 2. 平台类加载器加载的类
System.out.println("Driver类的加载器:" + java.sql.Driver.class.getClassLoader());
// 3. 应用程序类加载器加载的类
System.out.println("当前类的加载器:" + ClassLoaderDemo.class.getClassLoader());
// 4. 获取系统类加载器(即应用程序类加载器)
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println("系统类加载器:" + systemClassLoader);
// 5. 查看类加载器的父子关系
System.out.println("应用程序类加载器的父加载器:" + systemClassLoader.getParent());
System.out.println("平台类加载器的父加载器:" + systemClassLoader.getParent().getParent());
}
}
java
String类的加载器:null
Driver类的加载器:jdk.internal.loader.ClassLoaders$PlatformClassLoader@6d06d69c
当前类的加载器:jdk.internal.loader.ClassLoaders$AppClassLoader@24d46ca6
系统类加载器:jdk.internal.loader.ClassLoaders$AppClassLoader@24d46ca6
应用程序类加载器的父加载器:jdk.internal.loader.ClassLoaders$PlatformClassLoader@6d06d69c
平台类加载器的父加载器:null
启动类加载器(Bootstrap ClassLoader)不是一个 Java 对象,而是 JVM 的 C++ 原生实现 。它在 Java 层面没有对应的java.lang.ClassLoader实例,因此所有指向它的引用都会显示为null。
二、双亲委派模型:类加载的 "规矩"
2.1 什么是双亲委派模型
双亲委派模型(Parent Delegation Model)是 Java 类加载器的核心设计原则,它定义了类加载器之间的协作方式。
核心思想 :当一个类加载器收到类加载请求时,它首先不会自己尝试加载这个类,而是将请求委托给父类加载器 去完成。这个委托过程会沿着类加载器的层级结构递归向上传递,直到到达最顶层的启动类加载器。只有当父类加载器无法完成加载请求时,子类加载器才会尝试自己加载。
简单来说,就是 "儿子先问爸爸,爸爸问爷爷,爷爷都加载不了,儿子再自己干"。
2.2 双亲委派模型的完整执行流程
让我们通过一个具体的例子,看看加载**com.example.MyClass**的完整流程:
- 检查是否已加载 :应用程序类加载器首先检查自己的缓存中是否已经加载过
com.example.MyClass,如果已经加载,直接返回该 Class 对象。 - 委托给父类加载器:如果没有加载过,应用程序类加载器将请求委托给它的父加载器 ------ 平台类加载器。
- 继续向上委托:平台类加载器同样先检查自己的缓存,如果没有加载过,再将请求委托给它的父加载器 ------ 启动类加载器。
- 顶层加载器尝试加载 :启动类加载器在自己的加载范围(JDK 核心类库)中查找
com.example.MyClass,显然找不到,于是返回加载失败。 - 子加载器尝试加载:平台类加载器收到父加载器的失败反馈后,在自己的加载范围(JDK 平台模块)中查找,仍然找不到,返回加载失败。
- 最终加载 :应用程序类加载器收到父加载器的失败反馈后,在自己的加载范围(应用程序 classpath)中查找,找到对应的
.class文件,加载并返回 Class 对象。
2.3 源码分析:ClassLoader.loadClass ()
双亲委派模型的核心逻辑就实现在java.lang.ClassLoader类的loadClass()方法中:
java
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 1. 加锁,保证线程安全
synchronized (getClassLoadingLock(name)) {
// 2. 首先检查该类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 3. 如果有父加载器,委托给父加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 4. 如果没有父加载器(说明是启动类加载器),调用启动类加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 5. 父加载器抛出ClassNotFoundException,说明父加载器无法加载
}
if (c == null) {
// 6. 父加载器无法加载,调用自己的findClass方法尝试加载
long t1 = System.nanoTime();
c = findClass(name);
// 记录加载信息
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
// 7. 如果需要解析,进行类的链接操作
if (resolve) {
resolveClass(c);
}
return c;
}
}
从源码可以清晰地看到双亲委派的逻辑:
- 先检查缓存
- 再委托父类
- 父类加载失败,自己再加载
2.4 经典例子:为什么自定义 java.lang.String 不会被加载
这是面试中最常问的一个问题,也是双亲委派模型安全性的最好体现。
假设我们在自己的项目中创建了一个java.lang.String类:
java
package java.lang;
public class String {
static {
System.out.println("我是自定义的String类!");
}
public String() {
System.out.println("自定义String类的构造方法被调用了!");
}
}
然后在主类中使用这个类:
java
public class TestCustomString {
public static void main(String[] args) {
java.lang.String str = new java.lang.String();
System.out.println(str.getClass().getClassLoader());
}
}
运行结果:
java
null
为什么没有输出我们自定义的内容?
因为当我们创建java.lang.String对象时,类加载请求会被委托给启动类加载器。启动类加载器在 JDK 核心类库中找到了官方的java.lang.String类并加载了它,根本不会加载我们自定义的那个类。
这就是双亲委派模型的安全保障:Java 核心类库永远由启动类加载器加载,防止恶意代码篡改核心 API。
2.5 双亲委派模型的优缺点
优点
-
避免类的重复加载
- 父加载器已经加载过的类,子加载器不会再重复加载,节省了内存空间,提高了加载效率。
- 保证了同一个类在 JVM 中只有一个实例。
-
保护程序安全,防止核心 API 被篡改
- 如上面的例子所示,恶意代码无法替换 Java 核心类库中的类,保证了 Java 平台的安全性。
- 防止 "类欺骗" 攻击,确保核心类的完整性。
-
类的层次划分清晰
- 不同层级的类加载器负责加载不同范围的类,职责明确,便于管理。
缺点
-
灵活性不足
- 检查类是否加载的过程是单向的,父加载器无法看到子加载器加载的类。
- 这在某些场景下会带来问题,比如 SPI 机制。
-
无法满足复杂的类隔离需求
- 在大型应用或框架中,可能需要加载不同版本的同一个类,而双亲委派模型要求一个类只能被一个类加载器加载。
三、打破双亲委派模型
虽然双亲委派模型有很多优点,但它并不是一个强制的模型,而是一个推荐的模型。在某些特殊场景下,我们需要打破双亲委派模型来满足特定的需求。
3.1 为什么需要打破双亲委派模型
常见的需要打破双亲委派模型的场景:
-
SPI 机制(服务提供者接口)
- 如 JDBC、JNDI 等,接口由 JDK 核心类库定义(由启动类加载器加载),但实现类由第三方提供(由应用程序类加载器加载)。
- 按照双亲委派模型,启动类加载器加载的接口无法看到应用程序类加载器加载的实现类。
-
Web 容器的应用隔离
- 如 Tomcat、Jetty 等 Web 容器,需要在同一个 JVM 中部署多个 Web 应用,每个应用可能依赖不同版本的同一个库。
- 如果使用标准的双亲委派模型,所有应用会共享同一个类加载器,导致版本冲突。
-
热部署与动态加载
- 如 Spring Boot 的热部署、JRebel 等工具,需要在不重启 JVM 的情况下,重新加载修改后的类。
- 这需要创建新的类加载器来加载新的字节码,替换旧的类加载器。
-
插件化开发
- 如 OSGi、Eclipse 插件系统等,需要实现插件的动态安装、卸载和更新。
- 每个插件都有自己独立的类加载器,实现类的隔离。
3.2 打破双亲委派模型的三种主要方法
方法一:重写 loadClass () 方法
这是最直接、最彻底的打破方式。通过重写ClassLoader类的loadClass()方法,改变类加载的顺序,让子类加载器先尝试自己加载,加载失败再委托给父类加载器。
代码示例:
java
public class CustomClassLoader extends ClassLoader {
private String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已经加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 先尝试自己加载
try {
c = findClass(name);
} catch (ClassNotFoundException e) {
// 3. 自己加载失败,再委托给父类加载器
if (getParent() != null) {
c = getParent().loadClass(name);
} else {
c = findBootstrapClassOrNull(name);
}
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 从指定路径读取类文件的字节码
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
// 将字节码转换为Class对象
return defineClass(name, classData, 0, classData.length);
}
private byte[] loadClassData(String className) {
// 将全限定名转换为文件路径
String fileName = classPath + "/" + className.replace('.', '/') + ".class";
try (InputStream is = new FileInputStream(fileName);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
return bos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
}
使用示例:
java
public class BreakParentDelegationDemo {
public static void main(String[] args) throws Exception {
// 创建自定义类加载器
CustomClassLoader classLoader1 = new CustomClassLoader("/path/to/classes1");
CustomClassLoader classLoader2 = new CustomClassLoader("/path/to/classes2");
// 用不同的类加载器加载同一个类
Class<?> clazz1 = classLoader1.loadClass("com.example.MyClass");
Class<?> clazz2 = classLoader2.loadClass("com.example.MyClass");
// 验证两个Class对象不是同一个
System.out.println("clazz1 == clazz2: " + (clazz1 == clazz2));
System.out.println("clazz1的类加载器:" + clazz1.getClassLoader());
System.out.println("clazz2的类加载器:" + clazz2.getClassLoader());
}
}
运行结果:
java
clazz1 == clazz2: false
clazz1的类加载器:com.example.CustomClassLoader@1b6d3586
clazz2的类加载器:com.example.CustomClassLoader@4554617c
可以看到,同一个类被两个不同的类加载器加载,生成了两个不同的 Class 对象,这在标准的双亲委派模型中是不可能的。
方法二:使用线程上下文类加载器
线程上下文类加载器(Thread Context ClassLoader)是 Java 为了解决 SPI 问题而引入的一种机制。
每个线程都有一个上下文类加载器,可以通过Thread.currentThread().getContextClassLoader()获取和设置。默认情况下,线程的上下文类加载器就是应用程序类加载器。
工作原理:
- 启动类加载器加载的 SPI 接口(如
java.sql.Driver),通过线程上下文类加载器来加载第三方的实现类。 - 这样就打破了双亲委派模型的单向性,让父加载器能够看到子加载器加载的类。
JDBC 中的应用示例:
java
// DriverManager是由启动类加载器加载的
public class DriverManager {
static {
// 加载JDBC驱动
loadInitialDrivers();
}
private static void loadInitialDrivers() {
// 获取线程上下文类加载器(默认是应用程序类加载器)
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 使用上下文类加载器加载META-INF/services/java.sql.Driver文件中配置的驱动类
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class, cl);
// ...
}
}
方法三:破坏类加载器的父子关系
通过自定义类加载器的父子关系,或者直接使用defineClass()方法来加载类,绕过正常的委派流程。
这种方式比较底层,一般不推荐使用,除非你非常清楚自己在做什么。
四、总结
类加载器和双亲委派模型是 JVM 的核心机制,理解它们对于深入学习 Java、排查类加载相关的问题以及开发框架都至关重要。
核心要点回顾:
-
类加载器负责将字节码加载到内存中生成 Class 对象,JDK 9 及以后有三层内置类加载器:启动类加载器、平台类加载器和应用程序类加载器。
-
双亲委派模型的核心是 "先委托父类,再自己加载",它保证了类的唯一性和 Java 平台的安全性。
-
双亲委派模型的优点:避免类重复加载、防止核心 API 被篡改、职责清晰。
-
双亲委派模型的缺点:灵活性不足、无法满足复杂的类隔离需求。
-
打破双亲委派模型的方法 :重写
loadClass()方法、使用线程上下文类加载器、破坏父子关系。 -
常见的打破场景:SPI 机制、Web 容器的应用隔离、热部署、插件化开发。
需要注意的是,打破双亲委派模型是一把双刃剑,它可以解决特定场景下的问题,但也可能带来类版本冲突、内存泄漏等问题。在实际开发中,我们应该根据具体需求,谨慎使用。