04类加载器的详解
03讲解了什么是类的声明周期,本文章将详细讲解类加载器的内容。
类加载器(ClassLoader)是 Java 虚拟机提供给应用程序去获取类和接口字节码数据的技术。它只参与类加载过程中的"字节码获取并加载到内存"这一部分:通过加载字节码数据放入内存转换成 byte[],接下来调用虚拟机底层方法将 byte[] 转换成方法区和堆中的数据。本地接口 JNI(Java Native Interface)允许 Java 调用其他语言编写的方法,在 Hotspot 类加载器中主要用于调用 Java 虚拟机中 C++ 编写的方法。
类加载器的应用场景非常广泛:企业级应用中的 SPI 机制、类的热部署、Tomcat 的类隔离,以及大量面试题中涉及的"什么是双亲委派机制""如何打破双亲委派机制"等,都离不开类加载器;在线上问题排查时,还可以使用 Arthas 不停机解决线上故障。
1.类加载器的分类
总的分为两类:一类是 Java 代码实现的,一类是 Java 虚拟机底层源码实现的。源代码位于 Java 虚拟机的源码中,实现语言与虚拟机底层语言一致,比如 Hotspot 使用 C++;而所有 Java 中实现的类加载器都需要继承抽象类 ClassLoader,JDK 中默认提供了多种处理不同渠道的类加载器,程序员也可以根据需求自己定制。

类加载器的设计 JDK8 和 8 之后的版本差别较大,JDK8 及之前的版本中默认的类加载器有如下几种:
- 底层源码实现的启动类加载器 Bootstrap
- 扩展类加载器 Extension
- 应用程序类加载器 Application
其中启动类加载器加载 Java 中最核心的类,扩展类加载器允许扩展 Java 中比较通用的类,应用程序类加载器加载应用使用的类。类加载器的详细信息可以在 Arthas 中通过 classloader 命令查看,该命令可以查看 classloader 的继承树、urls、类加载信息,并可以使用 classloader 去 getResource。
1.1 启动类加载器
启动类加载器(Bootstrap ClassLoader)是由 Hotspot 虚拟机提供的、使用 C++ 编写的类加载器。默认加载 Java 安装目录 /jre/lib 下的类文件,比如 rt.jar、tools.jar、resources.jar 等。
通过启动类加载器去加载用户 jar 包有两种方式:
- 放入
jre/lib下进行扩展:不推荐,尽可能不要去更改 JDK 安装目录中的内容,即使放进去由于文件名不匹配的问题也不会正常地被加载。 - 使用参数进行扩展:推荐,使用
-Xbootclasspath/a:jar包目录/jar包名进行扩展。
1.2 扩展类加载器与应用程序类加载器
扩展类加载器和应用程序类加载器都是 JDK 中提供的、使用 Java 编写的类加载器。它们的源码都位于 sun.misc.Launcher 中,是一个静态内部类,继承自 URLClassLoader,具备通过目录或者指定 jar 包将字节码文件加载到内存中的能力。
其继承体系为:ClassLoader(抽象类,定义了类加载器的具体行为模式,通过 JNI 调用底层的 Java 虚拟机方法)→ SecureClassLoader(使用证书机制提升类加载的安全性)→ URLClassLoader(利用 URL 获取目录下或者指定的 jar 包进行加载,获取其字节码数据)→ Extension / Application。

扩展类加载器(Extension Class Loader)默认加载 Java 安装目录 /jre/lib/ext 下的类文件。和启动类加载器一样,如果想通过扩展类加载器去加载用户 jar 包,建议使用 -Djava.ext.dirs=jar包目录 进行扩展。这种方式会覆盖掉原始目录,可以用 ;(Windows)或 :(macOS/Linux)追加上原始目录。应用程序类加载器(Application Class Loader)则加载 classpath 下的类文件。
Arthas 中类加载器的加载路径可以通过 classloader -c hash值 查看:

2.双亲委派机制
在 Java 中如何使用代码的方式去主动加载一个类呢?有两种方式:
方式 1:使用 Class.forName 方法,使用当前类的类加载器去加载指定的类。
方式 2:获取到类加载器,通过类加载器的 loadClass 方法指定某个类加载器加载。
// 方式1:使用当前类的类加载器去加载
Class.forName("com.itheima.my.A");
// 方式2:指定某个类加载器去加载
ClassLoader loader = Demo.class.getClassLoader();
loader.loadClass("com.itheima.my.A");2.1 parent 父类加载器
每个 Java 实现的类加载器中保存了一个成员变量叫"父"(Parent)类加载器,可以理解为它的上级,并不是继承关系。应用程序类加载器的 parent 父类加载器是扩展类加载器,而扩展类加载器的 parent 是空。启动类加载器使用 C++ 编写,没有上级类加载器——parent 是 null 就代表该类的父类是 Bootstrap。

类加载器的继承关系可以在 Arthas 中通过 classloader -t 查看:

2.2 双亲委派流程
双亲委派机制指的是:自底向上查找是否加载过,再由顶向下进行加载。
具体过程是:在加载一个类时,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。如果 parent 为 null,则会提交给启动类加载器处理。如果所有的父类加载器都无法加载该类,则由当前类加载器自己尝试加载,看上去是自顶向下尝试加载。第二次再去加载相同的类,仍然会向上进行委派,如果某个类加载器加载过就会直接返回。

以 com.itheima.my.A 这个不在任何加载器目录中的类为例:自底向上查找时,三个类加载器都"没有加载过";自顶向下尝试加载时,启动类加载器和扩展类加载器都"不在我的加载目录中",最终由应用程序类加载器"加载成功"。而 com.itheima.my.B 这个位于 classpath 中的类,则同样由应用程序类加载器加载成功。
2.3 双亲委派机制解决的问题
双亲委派机制解决了三个问题:
- 如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?由启动类加载器加载,根据双亲委派机制,它的优先级是最高的。
- 在自己的项目中去创建一个
java.lang.String类,会被加载吗?不能,会交由启动类加载器加载在rt.jar包中的 String 类。这样可以防止核心类被重写。 - 这几个类加载器彼此之间存在关系吗?应用类加载器的父类加载器是扩展类加载器,扩展类加载器没有父类加载器,但是会委派给启动类加载器加载。
这样就能 1.避免重复加载,2.保证类加载的安全性——通过双亲委派机制,让顶层的类加载器去加载核心类,避免恶意代码替换 JDK 中的核心类库,比如 java.lang.String,确保核心类库的完整性和安全性;同时避免同一个类被多次加载,上层的类加载器如果加载过类,就会直接返回该类。
3.打破双亲委派机制
打破双亲委派机制主要有以下三种方法:自定义类加载器并重写 loadClass 方法、利用线程上下文类加载器加载类(如 JDBC 和 JNDI 等)、以及历史上 OSGi 框架实现的同级之间委托加载机制。
3.1 自定义类加载器
一个 Tomcat 程序中是可以运行多个 Web 应用的,如果这两个应用中出现了相同限定名的类,比如 Servlet 类,Tomcat 要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载 Web 应用 1 中的 MyServlet 之后,Web 应用 2 中相同限定名的 MyServlet 类就无法被加载了。

Tomcat 使用了自定义类加载器来实现应用之间类的隔离,每一个应用会有一个独立的类加载器加载对应的类。
先来分析 ClassLoader 的原理,ClassLoader 中包含了 4 个核心方法,双亲委派机制的核心代码就位于 loadClass 方法中:

四个方法分别是:
public Class<?> loadClass(String name) // 类加载的入口,提供双亲委派机制,内部会调用 findClass
protected Class<?> findClass(String name) // 由类加载器子类实现,获取二进制数据并调用 defineClass
protected final Class<?> defineClass(String name, byte[] b, int off, int len) // 校验类名,调用虚拟机底层方法将字节码加载到内存
protected final void resolveClass(Class<?> c) // 执行类生命周期中的连接阶段打破双亲委派机制的核心就是将 loadClass 中下面这一段代码重新实现:
// parent 等于 null 说明父类加载器是启动类加载器,直接调用 findBootstrapClassOrNull
// 否则调用父类加载器的加载方法
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
// 父类加载器爱莫能助,我来加载!
if (c == null)
c = findClass(name);
需要补充说明的是,自定义类加载器默认的父类加载器是 AppClassLoader。以 JDK8 为例,ClassLoader 类中提供了构造方法设置 parent 的内容,这个构造方法由另外一个构造方法调用,其中父类加载器由 getSystemClassLoader 方法设置,该方法返回的就是 AppClassLoader。
正确地去实现一个自定义类加载器的方式是重写
findClass方法,这样不会破坏双亲委派机制;只有重写loadClass方法才会将双亲委派机制的代码去除,从而打破双亲委派机制。
两个自定义类加载器加载相同限定名的类,不会冲突吗?不会冲突,在同一个 Java 虚拟机中,只有相同类加载器 + 相同的类限定名才会被认为是同一个类。可以在 Arthas 中使用 sc -d 类名 的方式查看具体情况。
3.2 线程上下文类加载器
这种方法常用于 Java 自己实现一些底层功能,比如实现 JDBC。
JDBC 中使用了 DriverManager 来管理项目中引入的不同数据库的驱动,比如 mysql 驱动、oracle 驱动。DriverManager 类位于 rt.jar 包中,由启动类加载器加载;而依赖中的 mysql 驱动对应的类,由应用程序类加载器来加载。
这就违反了双亲委派机制:一个已经被父加载器(Bootstrap ClassLoader)成功加载的类(DriverManager),在它的初始化代码(静态块)或方法执行过程中,需要去加载一个具体的 JDBC 驱动类。按照严格的双亲委派规则,这个加载请求应该首先委托给 DriverManager 自己的加载器,也就是 Bootstrap ClassLoader。但 Bootstrap ClassLoader 根本不认识 classpath 下的 MySQL 的 jar 包,所以会加载失败,导致 JDBC 完全无法使用。

那 DriverManager 怎么知道 jar 包中要加载的驱动在哪儿?答案是 DriverManager 使用了 SPI 机制。SPI 全称为 Service Provider Interface,是 JDK 内置的一种服务提供发现机制。其工作原理是:在 ClassPath 路径下的 META-INF/services 文件夹中,以接口的全限定名来命名文件名,对应的文件里面写该接口的实现;然后使用 ServiceLoader 加载实现类。
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
SPI 中是如何获取到应用程序类加载器的?答案是 SPI 中使用了线程上下文中保存的类加载器进行类的加载,这个类加载器一般是应用程序类加载器。完整流程如下:
- 启动类加载器加载
DriverManager。 - 在初始化
DriverManager时,通过 SPI 机制加载 jar 包中的 mysql 驱动。 - SPI 中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。
这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制。

其实"线程上下文类加载器是否算是打破了双亲委派机制"是有争议的,这种说法主要出自周志明的《深入理解Java虚拟机》。也有观点认为 JDBC 只是在 DriverManager 加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制,没有真正打破。这个部分我们不细究,感兴趣的读者可以自行查找相关争议的说法,这里我们只讲解什么是线程上下文类加载器。
3.3 OSGi 模块化
历史上,OSGi 模块化框架实现了一套新的类加载器机制,它存在同级之间的类加载器委托加载——允许同级之间委托进行类的加载。OSGi 还使用类加载器实现了热部署的功能,热部署指的是在服务不停止的情况下,动态地更新字节码文件到内存中。

不过 OSGi 已经基本不再使用了,感兴趣的读者可以自行查找资料了解。
4.JDK9之后的类加载器
JDK8 及之前的版本中,扩展类加载器和应用程序类加载器的源码位于 rt.jar 包中的 sun.misc.Launcher.java。

由于 JDK9 引入了 module 的概念,类加载器在设计上发生了很多变化:
- 启动类加载器使用 Java 编写,位于
jdk.internal.loader.ClassLoaders类中。Java 中的BootClassLoader继承自BuiltinClassLoader,实现从模块中找到要加载的字节码资源文件。启动类加载器依然无法通过 Java 代码获取到,返回的仍然是 null,保持了统一。

- 扩展类加载器被替换成了平台类加载器(Platform Class Loader)。平台类加载器遵循模块化方式加载字节码文件,所以继承关系从
URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。
