类加载与双亲委派机制
类加载与双亲委派机制
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):

每个阶段的主要工作如下:
加载
- 通过类的全限定名获取二进制字节流;
- 将字节流中的静态存储结构转化为方法区的运行时数据结构;
- 在堆中生成一个
java.lang.Class对象,作为方法区数据的访问入口。
不过数组类比较特殊,由 JVM 直接创建,不是通过类加载器加载。其元素类型由类加载器加载。
验证
确保 Class 文件的字节流符合 JVM 规范,不危害安全。分为四个阶段:
- 文件格式验证:如魔数、版本号、常量池常量类型等;
- 元数据验证:语义分析,如是否有父类、是否继承被禁止的类等;
- 字节码验证:数据流和控制流分析,确保方法体逻辑安全(如类型转换合法);
- 符号引用验证:在解析阶段进行,确保符号引用能被正确解析(如类是否存在、访问权限等)。
如果程序运行的全部代码已经反复使用验证过,可通过 -Xverify:none 关闭大部分验证以提升加载速度。
准备
- 为类变量(static 变量)分配内存并设置零值(如
int为0,reference为null); - 若变量被
final修饰且有ConstantValue属性,则直接初始化为指定值。
从概念上讲,static 变量所在的区域应该是方法区,但方法区只是逻辑上的概念,在 JDK8 及之后,类变量会随着 Class 对象一起存放在 Java 堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
解析
- 将常量池中的符号引用替换为直接引用(如内存地址、偏移量)。
- 解析对象:类/接口、字段、类方法、接口方法等;
invokedynamic指令的解析是动态的,延迟到运行时。
初始化
- 执行类构造器
<clinit>()方法,由编译器自动收集:- 类变量的赋值操作;
- 静态语句块
static {}。
- 特点:
- 按源码顺序执行;
- 父类的
<clinit>()先于子类执行; - 接口的
<clinit>()不会触发父接口初始化; - 多线程环境下,JVM 保证
<clinit>()被正确加锁同步。
类加载器
Java 虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。
类在 JVM 中的唯一性由类加载器 + 类全限定名共同决定。我曾经看到过一个 Spring 热部署造成的类加载问题,有人在项目中使用了热部署(spring-boot-devtools),在修改代码后未重新启动,代码报错,最后定位到原因是热部署加载类的类加载器和之前的类加载器不同。
双亲委派
类加载遵循大名鼎鼎的“双亲委派”模型,即类加载器发现未加载某个类时,会先委托给父加载器加载,若父加载器无法加载,再由自己尝试加载。
通过这段话我们能得出结论:类加载器是有层级的。

- 启动类加载器(Bootstrap):加载
rt.jar等核心库,C++ 实现。 - 扩展类加载器(Extension):加载
jre/lib/ext下的类。 - 应用程序类加载器(Application):加载
ClassPath下的类。
JDK9 开始,由于引入的模块化特性,不再有 ExtensionClassLoader,被 PlatformClassLoader 所替换。
还需要注意的一点是:所谓的父加载器,不是指子加载器继承(inherit)了父加载器,而是加载器中的 parent 属性(composite)指向的:
1 | |
该机制的核心目的是避免类的重复加载,保证核心类(如 java.lang.Object)的唯一性。
不过双亲模型只是一个规范,迄今为止已经被破坏过很多次了~
破坏双亲委派
首先来看“双亲委派”模型是如何实现的:
1 | |
- 代码第6行,检查这个类是否已经被加载过了;
- 代码第10-14行,如果父加载器存在,委托父加载器加载,如果父加载器都找不到会丢出
ClassNotFoundException; - 代码第24行,调用
findClass(name)抽象方法自己加载; - 代码第33行,如果需要,则完成类的链接(验证、准备、解析)。
一般情况下:resolve 参数是 false,等到真正使用时(如 Class.forName 可指定)才解析,以提高性能。
在 findClass(name) 中,会在特定目录下去寻找类文件,把.class 文件读入内存,最后调用 native 方法 defineClass 将字节数组转成 Class 对象:
1 | |
第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 的类加载器视图如下:

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