学类加载器的时候,我被“谁加载谁“绕了好几天
刚开始学 JVM 类加载机制的时候,我最头疼的不是"加载过程分几步",而是"这几层类加载器到底谁是谁的爸爸"。启动类加载器、扩展类加载器、应用类加载器、自定义类加载器……名字一堆,关系图一画就乱。而且学完之后我一度觉得这东西离业务代码很远,平时写 Spring Boot 又用不到。后来踩了几个坑才明白,类加载器的设计直接关系到 Java 的安全性和动态性,不搞懂不行。
类加载器到底是什么,为什么需要分层
类加载器的任务很简单:把一个类的二进制字节码读进 JVM,转换成java.lang.Class对象。但问题在于,Java 程序运行的时候,会从很多不同的地方加载类。
比如java.lang.String是从 JDK 核心库来的,你项目里引的第三方 jar 是从 Maven 仓库来的,你自己写的类在 target/classes 里。JVM 需要区分这些类的来源,不能让一个来自不可信来源的类冒充核心类。这就是分层设计的出发点。
你可以把类加载器想象成一层一层的安检。最内层由 JVM 自己把守,只放行最核心的类。越往外,信任度越低,但灵活性越高。
四层类加载器,每层的职责不一样
1. 启动类加载器(Bootstrap Class Loader):JVM 自己管的
这是所有类加载器里地位最高的一个。它负责加载 Java 最核心的类库。
它加载什么:
在 Java 8 及以前,它加载的是jre/lib/rt.jar。rt.jar里装的是java.lang.*、java.util.*、java.io.*这些你每天都在用的类。从 Java 9 开始,rt.jar被移除了,核心类库以模块化的形式存放在$JAVA_HOME/lib/modules里,比如java.base模块。
它特殊在哪:
启动类加载器不是用 Java 写的,而是 C++ 实现的,是 JVM 的一部分。所以在 Java 代码里你拿不到它的对象,调用getClassLoader()返回的是null。
我第一次看到这个设计的时候有点懵:返回 null 不会空指针吗?后来才知道这算是 JVM 约定好的信号,看到 null 就知道这个类是 Bootstrap ClassLoader 加载的,不是 Bug。
2. 平台类加载器(Platform / Extension Class Loader):中间层
这一层的名字在 Java 8 和 Java 9+ 不一样。
Java 8 及以前:扩展类加载器(Extension Class Loader)
它负责加载jre/lib/ext目录下的扩展 jar 包。你也可以通过java.ext.dirs系统属性指定额外的扩展目录。这个设计本意是让 JDK 可以在不修改核心库的前提下扩展功能。
Java 9 及以后:平台类加载器(Platform Class Loader)
Java 9 模块化之后,jre/lib/ext目录和java.ext.dirs属性都被移除了。新的平台类加载器负责加载 JDK 中除核心模块之外的那些平台模块,比如java.sql、java.xml这些。
一个容易混淆的点:
在 Java 层面,这个加载器的parent字段是null。但它的委派逻辑仍然会把请求先交给 Bootstrap ClassLoader。这里的 “parent = null” 只是表示"在 Java 层面没有一个对应的 ClassLoader 对象作为父加载器",不表示它不向上委派。我当时被这个搞混过,看到 parent 是 null 以为它就是顶层了,结果发现它还会向上抛给启动类加载器。
3. 应用程序类加载器(Application Class Loader):你用得最多的
这个加载器负责加载你的项目里写的类和引用的第三方 jar 包。具体来说就是 ClassPath(类路径)和模块路径上的所有类。
你可以通过ClassLoader.getSystemClassLoader()拿到它。在 Java 9+ 里它的父加载器是 Platform Class Loader,在 Java 8 里是 Extension Class Loader。
平时写代码的时候,你几乎感觉不到它的存在,但你写的每个类的加载都是经过它的。
4. 自定义类加载器(Custom Class Loader):给你自己玩的
如果你觉得上面三层的加载方式满足不了你的需求,你可以自己写一个 ClassLoader,继承java.lang.ClassLoader就行。
什么时候需要自己写?
- 类文件不在文件系统上,而是从网络上下载
- 类文件被加密了,需要在加载时解密
- 类文件存在数据库里
- 你想对加载的字节码做增强(比如一些 APM 工具就是这么做的)
我刚开始觉得自定义类加载器是八股文里才会出现的东西。后来用了一次 Spring Boot 的 DevTools,发现它就是用不同的类加载器来隔离重新加载的类和原有的类,才知道这东西在实际项目里真的在用。
层级关系和双亲委派模型
四层加载器的层级关系是这样的:
Bootstrap ClassLoader(顶层,C++ 实现) ↑ 委派 Platform / Extension ClassLoader ↑ 委派 Application ClassLoader ↑ 委派 Custom ClassLoader(开发者自定义)注意箭头的方向是向上委派,不是向下查找。
双亲委派到底是怎么工作的
当一个类加载器收到加载一个类的请求时,它不会自己先去尝试加载,而是先把请求交给它的父加载器。父加载器又交给它的父加载器……一直交到最顶层的启动类加载器。启动类加载器尝试加载,如果加载不到,它返回"我干不了",然后下一级加载器再尝试。
用一个具体的例子走一遍:
假设你要加载com.example.MyService。应用类加载器收到了请求。
- 应用类加载器说:“我先问问我爸爸能不能加载。”
- 它把请求交给了平台类加载器(Java 9+ 的话)。
- 平台类加载器也说:“我再问问我爸爸。”
- 平台类加载器把请求交给 Bootstrap ClassLoader。
- Bootstrap ClassLoader 尝试加载
com.example.MyService。它只负责核心类库,找不到这个类。 - Bootstrap ClassLoader 返回"我找不到"。
- 平台类加载器自己尝试加载。它只负责平台模块,也找不到。
- 平台类加载器返回"我也找不到"。
- 应用类加载器终于自己出手了。它在自己负责的 ClassPath 上找到了
com.example.MyService,加载成功。
这个流程走下来,你会发现问题:每一层都要先问爸爸,层层向上,再层层向下。这不是很慢吗?
为什么非要这么设计
双亲委派看起来绕,但它的核心目的只有一个:保证核心类的唯一性。
如果不用双亲委派,你可以在自己的代码里写一个java.lang.String,然后用自己的类加载器加载。JVM 就分不清哪个是真正的 JDK 核心类,哪个是你自己写的。这种混乱会带来安全性问题:想象一下有人写了一个假的javax.crypto.Cipher,在里面做手脚。
双亲委派的解决方案是:不管谁要加载java.lang.String,请求都会一路向上传到 Bootstrap ClassLoader。Bootstrap ClassLoader 加载了真正的java.lang.String,然后返回给下层。下层就不会再尝试用自己的版本了。这样就保证了全 JVM 范围内,java.lang.String只有一份。
我第一次理解这个设计意图的时候,觉得还是挺巧妙的。用了一个简单的"向上问"的规则,就解决了类重复和类安全两个问题。代价只是在加载过程中多走几层委派,而类加载本身是低频操作,这点性能损失可以忽略。
双亲委派不是万能的
但这个模型也有解决不了的问题。Java SPI(Service Provider Interface)就是一个典型例子。
SPI 的机制是:定义一个接口(比如java.sql.Driver),接口在核心库里,但实现类(比如 MySQL 的驱动)在第三方 jar 里。Bootstrap ClassLoader 加载了java.sql.Driver,但当它尝试加载实现类的时候,它找不到,因为它根本不负责 ClassPath。
Java 的解决方案是Thread.currentThread().getContextClassLoader(),拿到当前线程的上下文类加载器(通常是应用类加载器),让"爸爸"用"儿子"的加载器去加载类。这就是所谓的"打破双亲委派"。
Tomcat 这样的 Web 容器也要打破双亲委派。每个 Web 应用需要独立加载自己的类,互不干扰。你部署了两个 war 包,里面都有同名的类,不能互相覆盖。Tomcat 的做法是给每个 Web 应用创建独立的类加载器,优先自己加载,加载不到才交给父加载器,跟双亲委派的方向刚好反过来。
不同版本之间的变化
学习类加载器的时候,Java 8 和 Java 9+ 的差异让我多花了不少时间。因为网上的很多博客还在讲 Java 8 的体系,而我现在用的已经是 Java 17 了。
| 项目 | Java 8 及以前 | Java 9 及以后 |
|---|---|---|
| 核心类库 | jre/lib/rt.jar | $JAVA_HOME/lib/modules(模块化运行时镜像) |
| 第二层加载器 | Extension Class Loader | Platform Class Loader |
| 扩展目录 | jre/lib/ext | 已移除 |
| 系统属性 | java.ext.dirs | 已移除 |
最核心的变化是 Java 9 的模块化系统(Project Jigsaw)。它不仅改了类加载器的名字,还改了类库的存储方式和访问权限。用旧的博客文章学新版本的 JVM,确实需要小心配对版本。
学完之后,我觉得需要记住的
把类加载器的体系过了一遍之后,有几个体会比较深:
1. 分层是手段,安全才是目的。类加载器的分层不是为了好看,而是为了让核心类不被冒充。双亲委派用极简的"向上问"规则实现了这一点。
2. null 不等于没有。Bootstrap ClassLoader 在 Java 代码里用 null 表示,但它是真实存在的。在 Java 里看到getClassLoader()返回 null,就知道这类的来头最大。
3. "打破双亲委派"不是 bug,是 feature。SPI、Web 容器、热部署工具,它们需要打破双亲委派不是因为模型设计得不好,而是因为有些场景天然就不符合"向上问"的逻辑。框架需要自己决定怎么加载。
4. 版本很重要。Java 9 改了类加载器的命名和结构,连rt.jar都没了。如果还在看 Java 8 的博客学现在的 JVM,有些地方需要对得上号。
如果你也在学类加载器,建议先动手写一个简单的自定义 ClassLoader。不用复杂,覆写findClass()方法,从文件系统或者网络加载一个类就行。做一遍之后,你对"委派"和"加载"的理解会比只看文章深很多。