类加载器

首先回顾一下类加载过程。加载 -> 验证 -> 准备 -> 解析 -> 初始化

加载是类加载过程的第一步,主要完成下面 3 件事情:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

类加载器介绍

类加载器从 JDK 1.0 就出现了,最初只是为了满足 Java Applet(已经被淘汰,反正是一种嵌入在网页中的特殊程序,用于生成动态内容,跟 JSP 差不多) 的需要。

后来,慢慢成为 Java 程序中的一个重要组成部分,赋予了 Java 类可以被动态加载到 JVM 中并执行的能力。

类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。

每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。

来自官方文档

这部分是什么意思呢?

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
  • 每个 Java 类都有一个引用指向加载它的 ClassLoader
  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

一句话:类加载器是 JVM 的一部分,负责在运行时动态地将 Java 类的字节码(.class 文件)加载到内存中,并生成对应的 java.lang.Class 对象。

这个过程发生在 类加载阶段的“加载”(Loading)步骤

其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。本文只讨论其核心功能:加载类。

类加载器加载规则

JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。

用到什么,加载什么

对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。

这个检查是否被加载过,对于一个类加载器来说,是判断两个类是否有相同二进制名称

对于一个类加载器来说,相同二进制名称的类只会被加载一次。

或者这么说,只有在两个类是由同一个类加载器加载的前提下,比较两个类相等才有意义,因为即使这两个类来源于同一个 Class 文件,在同一个 JVM 上被加载,但是它们的类加载器不同,它们也是不同的类(这里的相等包括 类的 Class 对象的 equals() 方法,newInstance()方法等返回的结果是不是一样的)

JVM 内置的三大类加载器

Bootstrap ClassLoader

顾名思义,启动类加载器。是使用 C++ 实现的,属于 JVM 运行时的一部分

  • 加载路径

    • <JAVA_HOME>/lib 下的核心 JAR(如 rt.jar, resources.jar, charsets.jar 等)
    • 通过 -Xbootclasspath 指定的额外路径
  • 加载内容:Java 最核心的类,比如:

    • java.lang.Object
    • java.lang.String
    • java.util.ArrayList
    • java.io.File
    • 所有 java.* 包下的基础类(除部分模块化后的例外)

Extension ClassLoader

Extension ClassLoader,扩展类加载器,Java 9+ 改名为 Platform ClassLoader

主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。

父加载器虽然是Bootstrap ClassLoader,但 Java 中无法直接引用,所以其 parent 字段为 null

注意,JDK 9+ 移除了 ext/ 目录机制,引入 模块系统(JPMS),它现在负责加载 Java 平台模块,所以被改名成了 Platform ClassLoader,只有 java.base 模块仍由 Bootstrap ClassLoader 加载,因为它是所有模块的基础

AppClassLoader

应用程序类加载器 ,或者也叫 System ClassLoader

它的父加载器是 ExtensionClassLoader,它加载 由 -classpath-cp 指定的路径,即我们日常开发的 .class 文件和第三方 JAR,是我们写的绝大多数类的默认加载器。

自定义类加载器

除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。

每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoadernull的话,那么该类加载器的父类加载器是 BootstrapClassLoader

  • 为什么 获取到 ClassLoadernull就是 BootstrapClassLoader 加载的呢?

    这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,Bootstrap ClassLoader 不是 Java 对象,在 Java 中没有对应的实例,所以拿到的结果肯定是 null。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PrintClassLoaderTree {
public static void main(String[] args) {
ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader();

StringBuilder split = new StringBuilder("|--");
boolean needContinue = true;
while (needContinue){
System.out.println(split.toString() + classLoader);
if(classLoader == null){
needContinue = false;
}else{
classLoader = classLoader.getParent();
split.insert(0, "\t");
}
}
}
}
  • 从输出结果可以看出:
    • 我们编写的 Java 类 PrintClassLoaderTreeClassLoaderAppClassLoader
    • AppClassLoader的父 ClassLoaderExtClassLoader
    • ExtClassLoader的父ClassLoaderBootstrap ClassLoader,因此输出结果为 null

可以用以下代码验证哪些类由 Bootstrap 加载

1
2
3
4
5
6
7
8
public class CheckBootstrapClasses {
public static void main(String[] args) {
System.out.println("String: " + String.class.getClassLoader()); // null
System.out.println("Object: " + Object.class.getClassLoader()); // null
System.out.println("ArrayList: " + java.util.ArrayList.class.getClassLoader()); // null
System.out.println("PrintClassLoaderTree: " + PrintClassLoaderTree.class.getClassLoader()); // AppClassLoader
}
}

嗯嗯回到正题,前面提到,如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。

ClassLoader 类有两个关键的方法:

  • protected Class<?> loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。

    image-20260125094419346
  • protected Class<?> findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。

    image-20260125094456695

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

那么问题来了,什么是双亲委派模型

双亲委派模型

双亲委派模型介绍

类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。

ClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 虚拟机中被称为 “bootstrap class loader”的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。

从上面官方文档的介绍可以看出:

  • ClassLoader 类使用委托模型来搜索类和资源。
  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。

也就是说,不是谁“想”加载就加载,而是遵循一套严格的“先问上级行不行,上级不行自己上”的规则。这就是双亲委派模型

每个类加载器在“自己动手”之前,必须先让父加载器尝试。只有父加载器明确“找不到”,子加载器才自己加载。

我一直感觉中文翻译有点误导。“双亲”并不是指两个父级,而是指 “向上委托给父级” 的层级结构。

下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。

img

为什么要这样?

  • 安全性,防止用户自定义各种来替换 JDK 核心类(因为 String 加载请求会先到 Bootstrap,它已经在 rt.jar 中找到了,直接返回,根本不会让你的 AppClassLoader 有机会加载同名类
  • 避免重复加载,同一个类只会被一个 ClassLoader 加载一次。如果没有委派,AppClassLoader 和 ExtClassLoader 可能各自加载同一个类,导致 instanceof 判断失败等严重问题
  • 层次清晰,职责分明

双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的,但是通常情况下,我们不打破

另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。

因为在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承

双亲委派模型的执行流程

双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoaderloadClass() 中,相关代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// 首先,检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
// 如果为 null,则说明该类没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {
// 当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
// 当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 非空父类的类加载器无法找到相应的类,则抛出异常
}

if (c == null) {
// 当父类加载器无法加载时,则调用findClass方法来加载该类
// 用户可通过覆写该方法,来自定义类加载器
long t1 = System.nanoTime();
c = findClass(name);

// 用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
// 对类进行link操作
resolveClass(c);
}
return c;
}
}

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。

  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。

  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。

  • 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。

双亲委派模型的好处

上面说了,双亲委派模型是 Java 类加载机制的重要组成部分,它通过委派父加载器优先加载类的方式,实现了安全目标,避免类的重复加载和防止核心 API 被篡改。

别忘了,JVM 区分不同类的依据是类名加上加载该类的类加载器,即使类名相同,如果由不同的类加载器加载,也会被视为不同的类。也就是说,你一个类被两个不同的类加载器加载了,会判定为不一样,这会出大问题。

双亲委派模型确保核心类总是由 BootstrapClassLoader 加载,保证了核心类的唯一性。

然而,即使攻击者绕过了双亲委派模型,Java 仍然具备更底层的安全机制来保护核心类库。ClassLoaderpreDefineClass 方法会在定义类之前进行类名校验。任何以 "java." 开头的类名都会触发 SecurityException,阻止恶意代码定义或加载伪造的核心类。

打破双亲委派模型

自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法

因为,上面源码提到了,把这个请求委派给父类加载器去完成的本质上是调用父加载器 loadClass()方法来加载类

重写 loadClass()方法之后,我们就可以改变传统双亲委派模型的执行流程。

例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。

我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。

Tomcat 的类加载器的层次结构

从图中的委派关系中可以看出:

  • CommonClassLoader作为 CatalinaClassLoaderSharedClassLoader 的父加载器。CommonClassLoader 能加载的类都可以被 CatalinaClassLoaderSharedClassLoader 使用。因此,CommonClassLoader 是为了实现公共类库(可以被所有 Web 应用和 Tomcat 内部组件使用的类库)的共享和隔离。
  • CatalinaClassLoaderSharedClassLoader 能加载的类则与对方相互隔离。CatalinaClassLoader 用于加载 Tomcat 自身的类,为了隔离 Tomcat 本身的类和 Web 应用的类。SharedClassLoader 作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类,但是在Tomcat的默认配置下catalina.properties配置文件的shared.loader=值为空,所以SharedClassLoader 并不生效,SharedClassLoader 实际上会退化为 CommonClassLoaderSharedClassLoader比较合适用来加载多个web应用间共享的类库,比如整个公司级别的监控、日志等。
  • 每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader。各个 WebAppClassLoader 实例之间相互隔离,进而实现 Web 应用之间的类隔。

单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载那些它的子级的加载器才能加载的类。

  • 比如,SPI 中,SPI 的接口(如 java.sql.Driver)是由 Java 核心库提供的,由BootstrapClassLoader 加载。而 SPI 的实现(如com.mysql.cj.jdbc.Driver)是由第三方供应商提供的,它们是由应用程序类加载器或者自定义类加载器来加载的。默认情况下,一个类及其依赖类由同一个类加载器加载。所以,加载 SPI 的接口的类加载器(BootstrapClassLoader)也会用来加载 SPI 的实现。按照双亲委派模型,BootstrapClassLoader 是无法找到 SPI 的实现类的,因为它无法委托给子类加载器去尝试加载。

    JDK 9+ 之后引入模块化,JDBC API 被拆分到 java.sql 模块中,不再是 BootstrapClassLoader 直接加载,而是由 PlatformClassLoader 加载。

  • 再比如,Tomcat 启动 其类启动器SharedClassLoader加载 $ CATALINA_HOME/lib 下的共享 JAR(如 spring-core.jar),每个 Web 应用(如 myapp.war)有自己的 WebAppClassLoader,加载 WEB-INF/classesWEB-INF/lib 下的业务类,也就是一般情况下我们编写的业务代码,但是,我们通常会在我们的业务代码中使用 Spring 的注解

    1
    2
    3
    4
    @Service
    public class UserService {
    // 使用了 Spring 的 @Service 注解
    }
    • @Service 是由 SharedClassLoader 加载的(来自 spring-core.jar)
    • UserService 是由 WebAppClassLoader 加载的(在你的 WAR 包里)

    矛盾出现了:Spring 框架(由 SharedClassLoader 加载)在运行时需要动态加载你的 UserService 类,但 SharedClassLoader 的加载路径 不包含 WEB-INF/classes!根据双亲委派,它也无法“向下”委托给 WebAppClassLoader(委派只能向上!)

针对第二种情况,我们需要让高层框架(Spring)放弃用自己的类加载器,转而使用当前线程绑定的“应用专属”类加载器,这也就是 线程上下文类加载器 的核心思想

1
ClassLoader cl = Thread.currentThread().getContextClassLoader();

每个 Thread 对象内部有一个私有字段:

image-20260125100552286

默认值是主线程的上下文加载器 AppClassLoader,新创建的线程 继承父线程的上下文类加载器,Web 容器会在处理每个 Web 应用请求前,显式设置该线程的上下文类加载器为WebAppClassLoader

那么 Spring 的典型做法就是这样

1
2
3
4
5
6
7
8
// Spring 源码中的典型做法
public Class<?> loadClass(String className) throws ClassNotFoundException {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl == null) {
cl = ClassLoader.getSystemClassLoader(); // fallback
}
return cl.loadClass(className); // 用 WebAppClassLoader 加载 UserService!
}

这是一种 “逆向委派”,本质上打破了严格的父子层级限制,实现了 跨类加载器的协作,允许跨层级加载。

那么,讲解是以第二种情况讲解的,但是第一种情况的 JDBC 也可以这样被解决,只不过 JDK 的更新模块化顺手把这个事情鼓捣好了,以前 JDBC 也是这么干的