类加载器与双亲委派:Bootstrap/Platform/Application、破坏委派与隔离(056)

JVM原理与调优约 14 分钟阅读7,707 字符
知识库文档 57 / 251

一、类加载器不只是"加载 class 文件"

上一篇我们讨论了类加载的生命周期,其中"加载"阶段的第一步是"通过全限定名获取类的二进制字节流"。这个步骤的具体执行者,就是类加载器(ClassLoader)。JVM 规范并不限制字节流从哪里来——class 文件、JAR 包、网络、数据库、运行时动态生成——只要类加载器能提供符合规范的字节流,JVM 就能加载。这种灵活性是 Java 动态性的基石之一。

但类加载器的作用远不止"读取字节流"。它定义了 Java 类加载的命名空间和可见性规则:同一个类加载器下,类名必须唯一;不同类加载器加载的类,即使全限定名相同,也被视为两个不同的类。这意味着 ClassLoader 不仅决定了类"从哪里来",还决定了类"是谁"。

二、JDK 中的三层类加载器

从 JDK 9 开始(模块化改造后),Java 平台内置了三层类加载器。在 JDK 8 及之前,这三层分别是 Bootstrap、Extension、Application;JDK 9 之后,Extension 被 Platform 取代,但核心逻辑不变。

Bootstrap ClassLoader(引导类加载器)

引导类加载器是 JVM 的一部分,由 C++ 实现(在 HotSpot 中),负责加载 Java 核心类库——即 <JAVA_HOME>/lib 目录下的 rt.jar(JDK 8 及之前)或 java.base 模块(JDK 9+)中的类。这些类包括 java.lang.String、java.lang.Object、java.util.ArrayList 等所有 Java 程序的基础类。

在 Java 代码中,引导类加载器被表示为 null——当你调用 String.class.getClassLoader() 时,返回的是 null。这是因为引导类加载器不是 Java 语言实现的,无法用一个 java.lang.ClassLoader 对象来表示。

Platform ClassLoader(平台类加载器 / JDK 8 及之前为 Extension ClassLoader)

平台类加载器(JDK 9+)负责加载 Java 平台中的扩展类,包括 java.sql、java.xml、java.scripting 等模块。在 JDK 8 及之前,它被称为扩展类加载器(Extension ClassLoader),负责加载 <JAVA_HOME>/lib/ext 目录下的类。

平台类加载器由 Java 实现,是 jdk.internal.loader.ClassLoaders$PlatformClassLoader 的实例。它可以通过 ClassLoader.getPlatformClassLoader() 方法获取。

Application ClassLoader(应用程序类加载器 / 系统类加载器)

应用程序类加载器负责加载用户类路径(-classpath 或 -cp)上的所有类,也就是我们编写的应用程序代码。它是 jdk.internal.loader.ClassLoaders$AppClassLoader 的实例,可以通过 ClassLoader.getSystemClassLoader() 方法获取。

文章配图
文章配图

三、双亲委派模型:Java 类加载的核心机制

双亲委派模型(Parent Delegation Model)是 Java 类加载器工作的核心机制,也是面试中必问的经典问题。它的工作流程是:

当一个类加载器收到类加载请求时,它不会自己先尝试加载,而是把这个请求委派给父加载器。每一层都这样做,直到请求到达最顶层的引导类加载器。只有当父加载器反馈"无法加载这个类"(在它的搜索范围内找不到)时,子加载器才会尝试自己加载。

用代码来描述,ClassLoader.loadClass() 的逻辑大致如下(简化版):

java
protected Class<?> loadClass(String name, boolean resolve) {
    // 1. 检查是否已经加载过
    Class<?> c = findLoadedClass(name);
    if (c != null) return c;
    
    // 2. 委派给父加载器
    try {
        if (parent != null) {
            c = parent.loadClass(name, false);
        } else {
            c = findBootstrapClassOrNull(name);
        }
    } catch (ClassNotFoundException e) {
        // 父加载器无法加载,继续
    }
    
    // 3. 父加载器加载不到,自己加载
    if (c == null) {
        c = findClass(name);
    }
    return c;
}

双亲委派模型解决了什么问题?

双亲委派模型的核心价值在于保证 Java 核心类库的类型安全。设想一个场景:如果没有双亲委派,用户编写了一个 java.lang.String 类并放在 classpath 中,应用程序类加载器就可能加载这个"假冒"的 String 类,从而破坏 Java 核心类库的安全性。有了双亲委派,java.lang.String 的加载请求会一路委派到引导类加载器,引导类加载器会加载官方版本,用户自定义的 java.lang.String 永远不会被加载。

文章配图
文章配图

四、破坏双亲委派模型的三种经典场景

双亲委派模型不是强制性的约束,而是 Java 设计者推荐的类加载器实现方式。在 Java 的发展历史中,有三种经典的"破坏"双亲委派的场景:

场景一:JDBC 驱动的 SPI 机制

双亲委派模型的第一个"破坏者"是 JDBC。JDBC 的核心接口(如 java.sql.Driver)定义在 java.sql 模块中,由引导类加载器/平台类加载器加载。但具体的 JDBC 驱动实现(如 MySQL 的 com.mysql.cj.jdbc.Driver)是由厂商提供的第三方 JAR 包,放在 classpath 上,由应用程序类加载器加载。

根据双亲委派模型,引导类加载器加载的类无法"看到"应用程序类加载器加载的类(父加载器不能向下访问子加载器的类)。这就产生了一个矛盾:DriverManager(由引导类加载器加载)需要加载具体的 JDBC 驱动实现(由应用程序类加载器加载),但按双亲委派规则,它无法做到。

Java 的解决方案是线程上下文类加载器(Thread Context ClassLoader,简称 TCCL)。DriverManager 通过 Thread.currentThread().getContextClassLoader() 获取线程上下文类加载器(通常是应用程序类加载器),然后用它来加载具体的驱动实现。TCCL 本质上是"绕过双亲委派,让父加载器可以请求子加载器来加载类"。

场景二:Tomcat 的类加载隔离

Tomcat 作为 Web 容器,需要解决两个问题:(1)一个 Tomcat 实例中可能部署多个 Web 应用,每个应用使用的库版本可能不同(如 Spring 4 vs Spring 5),它们需要互相隔离。(2)多个 Web 应用之间需要共享一些公共库(如 Tomcat 自己的 Servlet API)。

Tomcat 的类加载器架构打破了双亲委派,采用了自定义的加载顺序:

  • 对于 Web 应用自己的类(WEB-INF/classes 和 WEB-INF/lib),Tomcat 的 WebappClassLoader 会先尝试自己加载,加载不到再委派给父加载器。这保证了每个 Web 应用的类版本隔离。
  • 对于 Java 核心类库,Tomcat 仍然遵循双亲委派,避免核心类被篡改。

文章配图
文章配图

场景三:OSGi 的模块化类加载

OSGi(Open Service Gateway Initiative)是一个动态模块化系统,它采用了网状结构的类加载器架构,而不是双亲委派的树状结构。在 OSGi 中,每个模块(Bundle)都有自己的类加载器,模块之间的依赖关系构成了一个网状的类加载拓扑。一个 Bundle 可以导出自己的类给其他 Bundle 使用,也可以导入其他 Bundle 导出的类。这种架构完全打破了双亲委派的层级顺序,但实现了更精细的类隔离和动态加载/卸载能力。

五、线程上下文类加载器(TCCL):双亲委派的"后门"

线程上下文类加载器是 Java 标准库中提供的一个"后门",专门用于解决双亲委派模型下"父加载器需要加载的类依赖于子加载器可以加载的类"的问题。

每个线程都有一个 contextClassLoader 属性,默认与创建该线程的父线程的上下文类加载器一致(主线程的默认值是应用程序类加载器)。SPI(Service Provider Interface)机制广泛使用 TCCL 来加载第三方实现,包括 JDBC、JNDI、JAXP、JBI 等。

TCCL 的使用模式是:框架代码(由引导类加载器加载)通过 Thread.currentThread().getContextClassLoader() 获取 TCCL,然后使用 TCCL 来加载具体的 SPI 实现类。这本质上是一种"反向委派"——不是从子到父的委派,而是从父到子的"借用"。

文章配图
文章配图

六、判断两个类是否"相等":类加载器命名空间

在 JVM 中,判断两个类是否"相等"(即是否属于同一个类型),需要同时满足两个条件:全限定名相同,且由同一个类加载器加载。即使两个类的全限定名完全相同,如果它们是由不同的类加载器加载的,JVM 也会认为它们是两个不同的类。

这个特性是类加载器实现命名空间隔离的基础。典型的使用场景是在 Tomcat 中:两个 Web 应用各自部署了不同版本的同一个库(如 com.example.Lib v1 和 com.example.Lib v2),Tomcat 通过为每个 Web 应用分配独立的 WebappClassLoader,确保这两个同名类被视为不同的类型,从而实现了版本隔离。

这也解释了为什么 instanceof 判断有时会失败:如果一个对象是通过一个类加载器加载的 ClassA 的实例,而你要判断的 ClassA 类是由另一个类加载器加载的,JVM 会认为它们是不同的类,instanceof 返回 false,甚至抛出 ClassCastException。

七、面试追问链

从"JVM 中有哪些内置的类加载器,各负责加载什么"这个基础问题出发,逐层追问:双亲委派模型的工作流程是什么,为什么这样设计;loadClass 和 findClass 的区别是什么,重写哪一个更安全;双亲委派模型在哪些场景下被破坏,为什么;线程上下文类加载器TCCL是什么,它解决了什么问题;Tomcat 为什么要打破双亲委派,它的类加载器是如何配置的;两个全限定名相同的类是否一定相等,类加载器命名空间如何影响类型判断;JDK 9 模块化对类加载器架构做了什么改变,Extension ClassLoader 为什么被 Platform ClassLoader 取代。

文章配图
文章配图

延伸阅读

  • 《深入理解 Java 虚拟机》(周志明)第 7.3 节,详细介绍了类加载器与双亲委派模型
  • Tomcat 官方文档 Class Loader HOW-TO,理解 Web 容器中的类加载器设计
  • 「04 JVM 原理与调优」系列下一篇(057)将深入 JVM 垃圾回收的基础概念与算法

问答(不计入阅读时长)

Q1:为什么**String.class.getClassLoader()**返回的是**null**?这是设计缺陷还是有意为之?

A:这是有意为之,不是设计缺陷。String 类由引导类加载器(Bootstrap ClassLoader)加载,而引导类加载器是由 C++ 实现的,不是 Java 语言实现的对象,无法用一个 java.lang.ClassLoader 实例来表示。因此,JVM 规范约定:对于由引导类加载器加载的类,getClassLoader() 返回 null。这不是 bug,而是一个约定——看到 null 就意味着"这个类是由引导类加载器加载的"。在面试中,如果被问到"如何判断一个类是由哪个类加载器加载的",正确答案是:调用 getClassLoader(),如果返回 null,说明是引导类加载器加载的;如果返回非 null,说明是平台类加载器或应用程序类加载器加载的。

Q2:双亲委派模型中,**loadClass()**和**findClass()**的区别是什么?如果要自定义类加载器,应该重写哪个方法?为什么?

A:loadClass() 实现了双亲委派的逻辑——先检查是否已加载,然后委派给父加载器,父加载器加载不到才调用 findClass()。findClass() 是实际执行"从某个来源获取字节流并定义类"的方法。如果要自定义类加载器,应该重写 findClass() 而不是 loadClass()。原因:重写 loadClass() 会破坏双亲委派模型(除非你明确知道自己在做什么,比如 Tomcat 的 WebappClassLoader);而重写 findClass() 只改变"从哪里加载类"的逻辑,仍然保留双亲委派的委派机制,更安全也更符合设计意图。模板方法模式在这里体现得很明显:loadClass() 是模板方法,findClass() 是需要子类实现的具体方法。

Q3:JDK 9 为什么把 Extension ClassLoader 改名为 Platform ClassLoader?这个改名背后反映了什么架构变化?

A:JDK 9 引入了 Java 平台模块化系统(JPMS),rt.jar 被拆分为多个模块(如 java.base、java.sql、java.xml 等)。在这个新架构下,Extension ClassLoader 不再只是加载"扩展目录"中的类,而是负责加载 Java 平台中除 java.base 之外的所有模块。因此,"Extension"这个名字不再准确——它不再是"扩展",而是"平台"的一部分。改名为 Platform ClassLoader 更准确地反映了它的职责:加载整个 Java 平台的所有模块(除了引导类加载器负责的 java.base)。此外,<JAVA_HOME>/lib/ext 目录在 JDK 9 中被废弃,不再作为扩展机制,这也是改名的一个原因。

Q4:线程上下文类加载器(TCCL)默认值是什么?它在什么场景下需要手动设置?如果设置不当会有什么后果?

A:新线程的 TCCL 默认继承自创建该线程的父线程的 TCCL。主线程的 TCCL 默认是应用程序类加载器(Application ClassLoader)。需要手动设置 TCCL 的典型场景:在 OSGi 容器中,每个 Bundle 有自己的类加载器,框架需要将 TCCL 设置为当前 Bundle 的类加载器,以保证 SPI 机制能正确加载 Bundle 中的 SPI 实现。如果设置不当(比如 TCCL 被设置为引导类加载器),SPI 机制将无法加载 classpath 上的第三方实现,导致 ServiceLoader 找不到任何实现类。在 Spring 等框架中,TCCL 通常被设置为应用程序类加载器,以确保框架能正确加载用户应用程序中的类。

Q5:Tomcat 的**WebappClassLoader**打破了双亲委派,具体是怎么打破的?它为什么要"先自己加载"而不是"先委派父加载器"?

A:Tomcat 的 WebappClassLoader 重写了 loadClass() 方法,改变了加载顺序:对于 WEB-INF/classes 和 WEB-INF/lib 中的类,先尝试自己加载,加载不到才委派父加载器。但对于 Java 核心类库(如 java.*),仍然遵循双亲委派,先委派给父加载器。这个设计的目的是实现 Web 应用之间的类隔离:每个 Web 应用都有自己的 WebappClassLoader,如果都先委派父加载器,那么所有 Web 应用共享的库(如 Spring)会由父加载器统一加载,导致版本冲突;先自己加载则确保每个 Web 应用使用自己的库版本,互相不干扰。但代价是:如果同一个库被多个 Web 应用加载,会浪费内存(每个类加载器各自加载一份)。

Q6:两个类的全限定名相同,是否意味着它们是同一个类?**instanceof**判断失败的原因可能是什么?

A:不一定。JVM 判断两个类是否相等,需要同时满足两个条件:全限定名相同,且由同一个类加载器加载。如果两个全限定名相同的类由不同的类加载器加载,JVM 会认为它们是两个不同的类型。instanceof 判断失败的典型场景:在 Tomcat 中,Web 应用 A 的类加载器加载了 com.example.User,Web 应用 B 的类加载器也加载了 com.example.User,如果 A 中的代码试图将 B 中的对象强制转换为 User 类型,虽然全限定名相同,但类加载器不同,JVM 会抛出 ClassCastException。这个特性也被用于实现"类加载器级别的沙箱隔离"——即使两个类名字相同,它们也无法互相访问私有成员或绕开类型检查。

Q7:OSGi 的类加载器架构和双亲委派模型有什么本质区别?为什么 OSGi 被称为"网状"而双亲委派是"树状"?

A:双亲委派模型是严格的树状结构——每个类加载器只有一个父加载器,委派方向是单向的(从子到父)。OSGi 的类加载器架构是网状结构——每个 Bundle 有自己的类加载器,Bundle 之间的依赖关系构成了一个图:Bundle A 可以导入 Bundle B 导出的包,Bundle B 可以导入 Bundle C 导出的包,一个 Bundle 可以同时导出包给多个 Bundle 使用。这种网状结构打破了双亲委派的单一路径,实现了更灵活的类可见性控制。但代价是:网状结构比树状结构更复杂,调试类加载问题(如 ClassNotFoundException 或 NoClassDefFoundError)时,需要排查整个依赖图,而不仅仅是沿委派链向上查找。

Q8:如果让你设计一个插件系统,允许多个插件使用不同版本的同一个库,你会如何利用类加载器来实现隔离?

A:核心思路是:为每个插件创建独立的类加载器实例,每个类加载器加载该插件自己的库版本。具体设计:(1)定义一个公共 API 接口,由父加载器(应用程序类加载器)加载,所有插件共享;(2)每个插件打包时包含自己的依赖库版本,放在独立的目录中;(3)为每个插件创建一个自定义类加载器,指定该插件目录为类加载路径;(4)插件类加载器先自己加载(破坏双亲委派),确保插件使用自己的库版本;(5)公共 API 接口仍由父加载器加载(遵循双亲委派),确保所有插件通过同一接口交互。这种设计就是 Tomcat 和 OSGi 的核心思想,也是类加载器隔离在实际工程中最经典的应用场景。注意事项:需要确保公共 API 接口的设计足够稳定,不会因为库版本差异导致接口不兼容。