一、JVM启动:三级仓储管理员体系
当一个 Java 程序启动时,JVM 并不会一次性加载所有类,而是按需加载。负责这项工作的,就是类加载器 (ClassLoader)。我们可以把它们想象成一个分工明确的三级仓储管理员体系。
1. Bootstrap ClassLoader (启动类加载器)
角色:顶级仓储管理员 (总库管理员)。
职责:负责加载 Java 最核心的类库,它们是 JVM 运行的基础。例如 java.lang.String
, java.util.List
等。
特殊性:它不是一个 Java 类,而是由 C++ 实现,内嵌在 JVM 中。因此,在 Java 代码中尝试获取它的引用会返回 null
。
它的 Classpath (加载路径):由系统属性 sun.boot.class.path
指定,通常指向 %JRE_HOME%/lib/rt.jar
(Java 8) 或 JRE 内部的模块文件 (Java 9+)。
2. Extension / Platform ClassLoader (扩展/平台类加载器)
角色:二级仓储管理员 (区域库管理员)。
职责:负责加载 Java 的一些扩展功能库。
名称变更:在 Java 8 及以前被称为 Extension ClassLoader,从 Java 9 开始,由于模块化系统的引入,被 Platform ClassLoader 取代。
它的 Classpath (加载路径):由系统属性 java.ext.dirs
指定,通常指向 %JRE_HOME%/lib/ext
目录下的所有 JAR 包。
3. Application ClassLoader (应用程序类加载器)
角色:一线仓储管理员 (我们应用的专属管理员)。
职责:负责加载我们自己编写的应用程序类以及项目依赖的第三方 JAR 包。这是我们打交道最多的 ClassLoader。
它的 Classpath (加载路径):大名鼎鼎的 java.class.path
系统属性!它的值就是我们常说的 **classpath**。这个 classpath 是在你启动程序时动态构建的,来源包括:
- 执行 `java -cp` 或 `-classpath` 参数指定的所有路径。
- 在 IDE 中运行时,IDE 自动将项目的编译输出目录 (如 `target/classes`) 和所有 Maven/Gradle 依赖的 JAR 包路径拼接起来,形成一个超长的 classpath。
二、双亲委派机制:一套严谨的汇报制度
这套“仓储管理员体系”有一套严格的工作流程,被称为“双亲委派机制”(Parent Delegation Model)。
核心思想:当一个类加载器收到加载类的请求时,它不会自己先去尝试加载,而是先把这个请求**层层向上委托**给父加载器去完成,直到顶层的 Bootstrap ClassLoader。只有当所有父加载器都表示“我的仓库里没有这个货”时,子加载器才会自己动手去自己的路径下查找。
工作流程图解:
为什么需要这套机制?
- 安全,防止核心API被篡改:想象一下,如果没有这套机制,有人可以自己写一个
java.lang.String
类,并放在 classpath 里。如果 AppClassLoader 先加载,那么 JVM 的核心 String 类就被恶意替换了,后果不堪设想。双亲委派保证了所有对java.lang.String
的加载请求最终都会被顶层的 Bootstrap ClassLoader 处理,确保了核心类的安全。 - 避免重复加载:如果两个类加载器都加载了同一个类,那么在 JVM 内部会存在两个不同的 Class 对象。这会导致类型转换异常等问题。双亲委派保证了一个类只会被一个加载器加载一次。
三、加载过程演示:一个 Demo 见真章
类的生命周期包括加载、验证、准备、解析、初始化等阶段。我们关注的“加载”是第一步。那么类到底在什么时候被加载呢?
加载时机:Java 虚拟机规范并没有严格规定类的“加载”必须在什么时候发生,但“初始化”阶段是被严格规定的。一个类的初始化会在首次“主动使用”它时触发。主动使用包括:
new
一个类的实例 (最常见)。- 访问类的静态变量或调用静态方法。
- 通过反射调用
Class.forName("...")
。 - 初始化一个类的子类。
在初始化之前,加载、验证、准备等阶段必须已经完成。所以,我们可以近似认为,**类的加载发生在第一次被真正使用之前**。
示例代码
假设我们有一个自定义类 com.myapp.Developer
。
package com.myapp;
public class Developer {
public Developer() {
System.out.println("Developer instance created by: " + this.getClass().getClassLoader());
}
}
主程序如下:
public class ClassLoaderDemo {
public static void main(String[] args) throws ClassNotFoundException {
System.out.println("--- 1. 探索核心类的加载器 ---");
// String 是核心类库,看看是谁加载的
System.out.println("String.class.getClassLoader() -> " + String.class.getClassLoader());
System.out.println("\n--- 2. 探索我们自己类的加载器 ---");
// new 一个实例,触发 Developer 类的加载和初始化
Developer dev = new Developer();
System.out.println("Developer.class.getClassLoader() -> " + dev.getClass().getClassLoader());
System.out.println("\n--- 3. 查看加载器层级关系 ---");
ClassLoader appClassLoader = ClassLoaderDemo.class.getClassLoader();
System.out.println("App ClassLoader: " + appClassLoader);
System.out.println("Parent of App: " + appClassLoader.getParent());
System.out.println("Grandparent of App: " + appClassLoader.getParent().getParent());
}
}
加载过程追踪
1. 加载 java.lang.String
- AppClassLoader 收到加载
java.lang.String
的请求。 - 它委托给父加载器 Platform ClassLoader。
- Platform ClassLoader 继续委托给它的父加载器 Bootstrap ClassLoader。
- Bootstrap ClassLoader 在自己的“总库” (
rt.jar
等) 中查找,**找到了!** - 加载成功,返回 Class 对象。由于 Bootstrap 是 C++ 实现的,所以
String.class.getClassLoader()
返回null
。
2. 加载 com.myapp.Developer
(通过 new Developer()
触发)
- AppClassLoader 收到加载
com.myapp.Developer
的请求。 - 它委托给父加载器 Platform ClassLoader。
- Platform ClassLoader 在它的“区域库” (
ext
目录) 中查找,没找到,告诉 AppClassLoader:“我这没有”。 - Platform ClassLoader 继续委托给 Bootstrap ClassLoader。
- Bootstrap ClassLoader 在它的“总库”里也找不到,告诉 Platform ClassLoader:“我这也没有”。
- 现在,请求回到了 AppClassLoader。父加载器们都表示无能为力,于是 AppClassLoader 开始在自己的地盘 (我们设置的 classpath) 中查找。
- 它在
target/classes
目录下找到了com/myapp/Developer.class
文件,**加载成功!** - 所以,
Developer.class.getClassLoader()
返回的是 AppClassLoader 的实例。
四、自定义与打破:规则之外的世界
1. 为什么要自定义 ClassLoader?
在某些高级场景下,我们需要自定义类加载器来实现特殊功能:
- 热部署/热加载:例如,在服务器不重启的情况下,更新一个类的实现。这需要创建一个新的 ClassLoader 来加载新版本的类。
- 代码加密:为了保护代码,可以先将 `.class` 文件加密,然后用自定义的 ClassLoader 在加载时进行解密。
- 从非标准来源加载:从网络、数据库或其他任何地方加载类的字节码。
2. 如何实现 (遵循双亲委派)
最标准的做法是继承 java.lang.ClassLoader
并重写 findClass()
方法。loadClass()
方法中已经包含了双亲委派的逻辑,我们不应该去碰它。
import java.io.*;
public class CustomClassLoader extends ClassLoader {
private String rootPath;
public CustomClassLoader(String rootPath) {
this.rootPath = rootPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 将类名转换为文件路径
String filePath = rootPath + File.separator + name.replace('.', File.separatorChar) + ".class";
try {
// 2. 读取加密或非标准来源的字节码
byte[] classBytes = loadClassBytes(filePath);
if (classBytes == null) {
throw new ClassNotFoundException(name);
}
// 3. 调用 defineClass 将字节数组转换为 Class 对象
return defineClass(name, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("Failed to load class " + name, e);
}
}
private byte[] loadClassBytes(String filePath) throws IOException {
// 在这里实现从文件、网络读取字节码,甚至解密的逻辑
InputStream is = new FileInputStream(filePath);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int b;
while ((b = is.read()) != -1) {
baos.write(b);
}
is.close();
return baos.toByteArray();
}
}
3. 如何打破双亲委派机制?
在某些极端情况下,我们需要“反向委托”。即子加载器需要加载一个类,而这个类又依赖于父加载器无法访问的类。最典型的例子是 **JDBC 的 SPI (Service Provider Interface) 机制**。
JDBC 场景:java.sql.DriverManager
是由 Bootstrap ClassLoader 加载的。但它需要加载由第三方厂商提供的、位于 AppClassLoader classpath 下的数据库驱动 (如 com.mysql.cj.jdbc.Driver
)。顶层的 Bootstrap 无法“向下”看到底层的 AppClassLoader,这该怎么办?
答案是使用**线程上下文类加载器 (Thread Context ClassLoader)**,这是一种巧妙的“打破”方式。
而另一种更直接的打破方式,就是**重写 loadClass()
方法**,改变其原有的委托顺序。
public class DisruptiveClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 先检查这个类是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 打破规则!自己先尝试加载
try {
c = findClass(name);
} catch (ClassNotFoundException e) {
// 自己找不到,再交给父加载器 (回归正常流程)
}
if (c == null) {
if (getParent() != null) {
c = getParent().loadClass(name);
} else {
// 如果父加载器也没有,就到此为止
throw new ClassNotFoundException(name);
}
}
}
return c;
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// ... 自定义加载逻辑 ...
return super.findClass(name); // or throw exception
}
}
Tomcat 的 WebappClassLoader 就是一个典型的打破双亲委派的例子。它会优先加载自己 Web 应用目录 (WEB-INF/classes
和 WEB-INF/lib
) 下的类,以实现不同 Web 应用之间的类隔离。
五、专家洞察与实践要点
1. 类加载器与 `instanceof` 的陷阱
在 JVM 中,一个类的唯一性是由**它的全限定名和加载它的 ClassLoader** 共同决定的。这意味着,即使是同一个 .class
文件,如果被两个不同的 ClassLoader 实例加载,那么你得到的将是两个完全不同、互不兼容的 Class 对象。
这将导致 instanceof
判断返回 false
,并且在类型转换时抛出 ClassCastException
。这是在热部署和复杂插件化系统中非常常见的、难以排查的“坑”。
2. 资源加载 `getResourceAsStream`
ClassLoader 不仅能加载类,还能加载资源文件(如配置文件、图片)。class.getResourceAsStream("my.properties")
是最常用的资源加载方式。它的查找路径也遵循与类加载相似的委派模型,从当前类的 ClassLoader 开始向上查找。
3. Java 9+ 模块化系统 (JPMS) 的影响
Java 9 的模块化系统对类加载机制进行了重构。Extension ClassLoader 被 Platform ClassLoader 替代。AppClassLoader 的父加载器变成了 Platform ClassLoader。更重要的是,模块之间存在明确的可见性规则,一个模块默认无法访问另一个模块未导出的包,这在某种程度上是比双亲委派更严格的隔离机制。