类加载与双亲委派机制

类加载与双亲委派机制

Java 在刚刚诞生之时曾经提出过一个非常著名的宣传口号“一次编写,到处运行 (Write Once,Run Anywhere)。为了实现这个口号,各种不同平台的 Java 虚拟机,以及所有平台都支持一种程序存储格式—字节码(Byte Code)。

需要指出的一点是:商业企业和开源机构已经在 Java 语言之外发展出一大批运行在 Java 虚拟机之上的语言,如 Kotlin、Clojure、Groovy、JRuby、JPython、Scala 等,Java 虚拟机不只具有平台无关性,同样具有语言无关性。

Class结构

Java 虚拟机不与包括 Java 语言在内的任何程序语言绑定,它只与 “Class文件” 这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集、符号表以及若干其他辅助信息。

任何一个 Class 文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中,动态代理)。

《Java 虚拟机规范》中定义了相应的文件结构,解读可以参见:详解 Java 的类文件结构

即使没有看过类的结构定义,想必在自定义通信协议或处理文件时也听过“魔数”的概念。

每个 Class 文件的头 4 个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。不仅是 Class 文件,很多文件格式标准中都有使用魔数来进行身份识 别的习惯,譬如图片格式,如 GIF 或者 JPEG 等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。

除了魔数外,还有主版本号、次版本号、常量池、访问标志等信息。

类的加载

Java 虚拟机把描述类的数据从 Class 文件(文件未必存储在磁盘,而是一串二进制字节流,包括但不限于从网络、数据库、内存中获取)加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制。

一个类型从加载到卸载,生命周期包括加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称 为连接(Linking):

image-20260521152156038

每个阶段的主要工作如下:

加载

  1. 通过类的全限定名获取二进制字节流;
  2. 将字节流中的静态存储结构转化为方法区的运行时数据结构;
  3. 在堆中生成一个 java.lang.Class 对象,作为方法区数据的访问入口。

不过数组类比较特殊,由 JVM 直接创建,不是通过类加载器加载。其元素类型由类加载器加载。

验证

确保 Class 文件的字节流符合 JVM 规范,不危害安全。分为四个阶段:

  1. 文件格式验证:如魔数、版本号、常量池常量类型等;
  2. 元数据验证:语义分析,如是否有父类、是否继承被禁止的类等;
  3. 字节码验证:数据流和控制流分析,确保方法体逻辑安全(如类型转换合法);
  4. 符号引用验证:在解析阶段进行,确保符号引用能被正确解析(如类是否存在、访问权限等)。

如果程序运行的全部代码已经反复使用验证过,可通过 -Xverify:none 关闭大部分验证以提升加载速度。

准备

  • 为类变量(static 变量)分配内存并设置零值(如 int 为0,referencenull);
  • 若变量被 final 修饰且有 ConstantValue 属性,则直接初始化为指定值。

从概念上讲,static 变量所在的区域应该是方法区,但方法区只是逻辑上的概念,在 JDK8 及之后,类变量会随着 Class 对象一起存放在 Java 堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

解析

  • 将常量池中的符号引用替换为直接引用(如内存地址、偏移量)。
  • 解析对象:类/接口、字段、类方法、接口方法等;
  • invokedynamic 指令的解析是动态的,延迟到运行时。

初始化

  • 执行类构造器 <clinit>() 方法,由编译器自动收集:
    • 类变量的赋值操作;
    • 静态语句块 static {}
  • 特点:
    • 按源码顺序执行;
    • 父类的 <clinit>() 先于子类执行;
    • 接口的 <clinit>() 不会触发父接口初始化;
    • 多线程环境下,JVM 保证 <clinit>() 被正确加锁同步。

类加载器

Java 虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

类在 JVM 中的唯一性由类加载器 + 类全限定名共同决定。我曾经看到过一个 Spring 热部署造成的类加载问题,有人在项目中使用了热部署(spring-boot-devtools),在修改代码后未重新启动,代码报错,最后定位到原因是热部署加载类的类加载器和之前的类加载器不同。

双亲委派

类加载遵循大名鼎鼎的“双亲委派”模型,即类加载器发现未加载某个类时,会先委托给父加载器加载,若父加载器无法加载,再由自己尝试加载

通过这段话我们能得出结论:类加载器是有层级的

image-20260521154127786

  • 启动类加载器(Bootstrap):加载 rt.jar 等核心库,C++ 实现。
  • 扩展类加载器(Extension):加载 jre/lib/ext 下的类。
  • 应用程序类加载器(Application):加载 ClassPath 下的类。

JDK9 开始,由于引入的模块化特性,不再有 ExtensionClassLoader,被 PlatformClassLoader 所替换。

还需要注意的一点是:所谓的父加载器,不是指子加载器继承(inherit)了父加载器,而是加载器中的 parent 属性(composite)指向的:

1
2
3
public abstract class ClassLoader {
private final ClassLoader parent;
}

该机制的核心目的是避免类的重复加载,保证核心类(如 java.lang.Object)的唯一性。

不过双亲模型只是一个规范,迄今为止已经被破坏过很多次了~

破坏双亲委派

首先来看“双亲委派”模型是如何实现的:

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
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
  • 代码第6行,检查这个类是否已经被加载过了;
  • 代码第10-14行,如果父加载器存在,委托父加载器加载,如果父加载器都找不到会丢出 ClassNotFoundException
  • 代码第24行,调用 findClass(name) 抽象方法自己加载;
  • 代码第33行,如果需要,则完成类的链接(验证、准备、解析)。

一般情况下:resolve 参数是 false,等到真正使用时(如 Class.forName 可指定)才解析,以提高性能。

findClass(name) 中,会在特定目录下去寻找类文件,把.class 文件读入内存,最后调用 native 方法 defineClass 将字节数组转成 Class 对象:

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
protected Class<?> findClass(String cn) throws ClassNotFoundException {
// no class loading until VM is fully initialized
if (!VM.isModuleSystemInited())
throw new ClassNotFoundException(cn);

// find the candidate module for this class
LoadedModule loadedModule = findLoadedModule(cn);
Class<?> c = null;
if (loadedModule != null) {
// attempt to load class in module defined to this loader
if (loadedModule.loader() == this) {
c = findClassInModuleOrNull(loadedModule, cn);
}
} else {
// search class path
if (hasClassPath()) {
c = findClassOnClassPathOrNull(cn);
}
}

// not found
if (c == null)
throw new ClassNotFoundException(cn);
return c;
}

第12行的 findClassInModuleOrNull 和第17行的 findClassOnClassPathOrNull 最后都会调用 ``defineClass

通过分析类加载的代码,打破双亲委派机制其实很简单,我们自定义类加载器,重写 loadClass(String name) 方法即可。

Tomcat

首先思考一下 Tomcat 为什么要打破双亲委派机制?

一方面,Tomcat 作为实现了 Servlet 规范的 Web 容器,必然有自己的核心业务类,因此需要优先加载 web 应用目录下的类,只要这个类不覆盖 JRE 核心类,这也是 Servlet 规范的要求。Tomcat 保证了自己的实现不会覆盖 JRE 核心类。

另一方面,Tomcat 是可以部署多个 Web 应用的,如果多个 Web 应用使用了同一个依赖的不同版本,双亲委派机制是无法满足的(当加载第二个版本的依赖时,会发现类的全限定名已存在,加载失败)。

按照这个逻辑,思考一下 Tomcat 该如何实现?

遇到一个类时查找自己是否加载过:

  • 如果加载过,直接返回;
  • 如果没有加载过,委托 ExtensionClassLoader 加载(避免覆盖核心类, ExtensionClassLoader 加载时自然会找更上层的类加载器):
    • 如果已经加载过,直接返回;
    • 如果没有加载过,就尝试自己加载:
      • 如果加载成功,返回;
      • 如果加载失败,委托给父加载器 AppClassLoader 加载:
        • 加载成功,返回;
        • 加载失败,丢出异常。

Tomcat 的这一行为把用到的非 JRE 核心类优先自己加载,加载失败再委托 AppClassLoader 父加载器加载,打破了传统的双亲委派机制。

父类加载器为什么会失败?父类加载器只能加载指定路径或指定包的类,因此可能失败。

这个逻辑在 Tomcat 源码的 WebappClassLoader 类中,Tomcat 每个 Web 应用都会有一个 WebappClassLoader

Tomcat 的设计其实没有这么简单,虽然说 WebappClassLoader 做到了加载每个 Web 应用自己的类,但 Web 应用也有需要共享的类(例如二者都用了 Spring5,如果一直分开加载会内存膨胀),在这种需要共享的场景就需要共同的父加载器;另外 Tomcat 本身也是也是一个 Java 程序,因此它需要加载自己的类和依赖的 JAR 包,最终 Tomcat 的类加载器视图如下:

image-20260521164448269

SharedClassLoader 作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类;CatalinaClassloader专门来加载 Tomcat 自身的类;CommonClassLoader 加载 Tomcat 需要和 Web 应用共享的类。

Thread Context ClassLoader

设计是美好的,需求是残酷的~

双亲委派的设计,造成的情况是:子类加载器加载的类可以看到父类加载器加载的类,但父类加载器加载的类看不到子类加载器加载的类

基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的 API 存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?

在 Spring 和 Tomcat 的多 Web 应用程序中,相同版本的 Spring 被 SharedClassLoader 加载,业务类被 WebAppClassLoarder 加载,当请求来时,Spring 需要创建业务 Bean,例如 UserService,但 UserService 是被 WebAppClassLoarder 加载的,Spring 是取不到的。

为了解决这样的困境,Java 的设计团队只好引入了一个设计:线程上下文类加载器 (Thread Context ClassLoader)。Tomcat 为每个 Web 应用创建一个 WebAppClassLoarder 类加载器,并在启动 Web 应用的线程里设置线程上下文加载器,这样 Spring 在启动时就将线程上下文加载器取出来,用来加载 Bean。

除了 Spring,JDBC、JNDI、JAXB、Java 的 SPI(Service Provider Interface)机制等都依赖线程上下文类加载器来实现“父调用子”的能力。

模块化系统

JDK9 之后,Java 引入了模块化的新特性,为了支持这个特性,类加载器也进行了一些改动。

首先,是扩展类加载器(ExtensionClassLoader)被平台类加载器(PlatformClassLoader)取代。整个 JDK 都基于模块化进行构建(原来的 rt.jar 和 tools.jar 被拆分成数十个 JMOD 文件),其中的 Java 类库就已天然地满足了可扩展的需求,那自然无须再保留 \lib\ext 目录,甚至 jdk 目录下已经没有 jre 文件夹了。

当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的新的破坏。


类加载与双亲委派机制
https://zhuwenjie0716.github.io/2026/05/23/类加载与双亲委派机制/
作者
Wenjie Zhu
发布于
2026年5月23日
许可协议