欢迎光临
我们一直在努力

双亲委派说了无数遍,但为什么 SPI 必须打破它?——类加载器从根讲清

文章目录

  • "双亲委派说了无数遍,但为什么 SPI 必须打破它?"——类加载器从根讲清
    • 一、类加载的五个阶段
    • 二、类加载器三层结构
    • 三、双亲委派:流程、源码、为什么要这样设计
      • 3.1 一个请求怎么走
      • 3.2 源码长这样
      • 3.3 为什么要这样设计
    • 四、类的唯一标识:不是全限定名说了算
    • 五、打破双亲委派的第一种方式:重写 loadClass
    • 六、SPI:为什么"不得不"打破双亲委派
      • 6.1 矛盾在哪
      • 6.2 怎么解决:线程上下文类加载器
      • 6.3 除了 JDBC,还有哪些 SPI?
    • 七、Tomcat:第三次打破双亲委派
      • 7.1 不打破会怎样
      • 7.2 Tomcat 的加载器架构
    • 八、破坏双亲委派的时间线
    • 九、两个易混淆概念
      • 9.1 ClassNotFoundException vs NoClassDefFoundError
      • 9.2 loadClass vs forName
    • 十、总结

“双亲委派说了无数遍,但为什么 SPI 必须打破它?”——类加载器从根讲清

你背过无数遍:Bootstrap → Platform → Application,先向上委派,失败再回落。然后面试官问:SPI 为什么要打破双亲委派?你卡住了。

因为只背了流程,没想清楚加载器到底解决了什么问题。

这一篇从类加载过程讲到双亲委派,再讲到 SPI、Tomcat 怎么打破它,彻底捋干净。


一、类加载的五个阶段

加载 → 验证 → 准备 → 解析 → 初始化
↖ 链接 ↗

阶段干什么关键产出
加载 读 .class 字节码,在方法区生成 Klass 元数据,堆上生成 Class<?> 对象 Klass + Class 对象
验证 检查魔数 0xCAFEBABE、版本号、类型安全 通过或 VerifyError
准备 静态变量分配内存、赋类型零值 static int a = 3 此时 a = 0
解析 常量池符号引用 → 直接引用(方法地址、字段偏移量) 晚期解析,用到才转
初始化 执行 <clinit>:静态赋值 + 静态代码块 = 3 此时才生效

二、类加载器三层结构

Bootstrap ClassLoader(C++ 实现,JVM 内建)
↑ 爹
Platform ClassLoader(JDK 8 叫 Extension,JDK 9 改 Platform)
↑ 爹
Application ClassLoader(classpath 下你写的代码)

加载器管什么
Bootstrap 核心类库:java.lang.*、java.util.* 等
Platform JDK 扩展模块:java.sql 等
Application 你的代码、classpath 下的 jar

JDK 9 的变化:Extension ClassLoader 被废弃,换成 Platform ClassLoader。rt.jar 被拆分成多个 .jmod 模块——原因是单一的 rt.jar 太庞大,不利于模块化裁剪。


三、双亲委派:流程、源码、为什么要这样设计

3.1 一个请求怎么走

// 你要加载 com.foo.User
Class<?> c = ClassLoader.getSystemClassLoader().loadClass("com.foo.User");

loadClass("com.foo.User")
→ 先查缓存:findLoadedClass("com.foo.User") → 没有
→ 委派给爹 Platform
→ Platform 委派给 Bootstrap
→ Bootstrap:不在我的管辖范围,加载不了
→ Platform:也不在我管,加载不了
→ Application:我来,去 classpath 找 → 找到了,定义成 Class 对象

3.2 源码长这样

protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
// 第一步:查缓存,加载过了直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 第二步:交给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name); // 到顶了,交给 Bootstrap
}
} catch (ClassNotFoundException ignored) { }
// 第三步:爹不行,自己来
if (c == null) {
c = findClass(name);
}
}
return c;
}
}

3.3 为什么要这样设计

防止核心类被篡改。

你完全可以写一个:

package java.lang;
public class String {
// 恶意代码:偷偷把用户密码发到远程服务器
}

编译出来一个 java.lang.String 放 classpath 下。如果没有双亲委派,ApplicationClassLoader 去 classpath 加载到你的假 String——核心类库被打穿。

有了双亲委派就没事:

要加载 java.lang.String
→ Application:爹,你装这个?
→ Bootstrap:java.lang.* 归我管,rt.jar 有真货,已经装好了
→ 直接返回真 String
→ 你的假 String 根本没机会被加载

另一个好处是复用。 java.lang.String 被 Bootstrap 加载一次,方法区里只存一份 Klass,所有子加载器共享,不重复占内存。


四、类的唯一标识:不是全限定名说了算

类的身份 = 全限定类名 + 类加载器实例

同一个 com.foo.User,被不同加载器加载 → JVM 认为是两个不同的类。

// 这两个 User 全限定名都是 com.foo.User
// 但分别被两个不同的 WebAppClassLoader 加载 → 两个不同的类
User.class.equals(userFromApp2) // false

这就是 Tomcat 能做应用隔离的底层原理——每个应用用一个 WebAppClassLoader,类的名字虽然一样,但加载器不同,类空间完全隔开。


五、打破双亲委派的第一种方式:重写 loadClass

public class MyClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 不先委派父加载器,自己先加载
Class<?> c = findClass(name);
if (c != null) return c;
// 自己找不到才走双亲委派
return super.loadClass(name);
}
}

重写 findClass() 不算破坏,只是换个地方找字节码。重写 loadClass() 才是破坏——改了查找顺序。

但即使你写了一个加载器,java.* 开头的类你照样加载不了——defineClass() 方法里有 Prohibited package name: java.lang 的保护,这是最后一道防线。


六、SPI:为什么"不得不"打破双亲委派

6.1 矛盾在哪

JDBC 的场景:
java.sql.DriverManager → 在 rt.jar,由 Bootstrap 加载
com.mysql.cj.jdbc.Driver → 在 classpath,由 AppClassLoader 加载

DriverManager 要加载 MySQL 驱动:
→ Bootstrap 里的代码要去加载 classpath 下的类
→ 但按双亲委派,Bootstrap 是爹,只能往上,不能"往下"找 AppClassLoader
→ Bootstrap 找不到 MySQL 驱动!

6.2 怎么解决:线程上下文类加载器

// ServiceLoader 内部
public static <S> ServiceLoader<S> load(Class<S> service) {
// 拿到当前线程的上下文类加载器——就是 AppClassLoader
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(service, cl);
}

本质:爹(Bootstrap)借用儿子(AppClassLoader)去加载类。 打破单向委派。

线程上下文类加载器的几个特性:

  • 每个线程有自己的 contextClassLoader,存在 Thread 类的字段里
  • main 线程默认是 AppClassLoader
  • 可以通过 setContextClassLoader() 手动设置

6.3 除了 JDBC,还有哪些 SPI?

JNDI、JCE、JAXB、SLF4J……只要接口在核心库、实现在 classpath,都用这套来打破双亲委派。


七、Tomcat:第三次打破双亲委派

7.1 不打破会怎样

Tomcat 同时部署 App1(Spring 5)和 App2(Spring 6)。如果走标准委派,父加载器会先加载 Spring 5——那 App2 也没得选,全应用共用一个版本。谁先部署就谁赢,后部署的炸。

7.2 Tomcat 的加载器架构

Bootstrap → JVM 核心类

System → classpath

Common → Tomcat 全局共享

┌─────── WebApp1 ───────┐ ┌─────── WebApp2 ───────┐
│ WebAppClassLoader │ │ WebAppClassLoader │
│ /WEB-INF/classes │ │ /WEB-INF/classes │
│ /WEB-INF/lib/*.jar │ │ /WEB-INF/lib/*.jar │
│ (Spring 5) │ │ (Spring 6) │
└───────────────────────┘ └───────────────────────┘

每个 Web 应用独立的 WebAppClassLoader,优先从自己的 /WEB-INF/ 加载:

  • Bootstrap(别动核心类)
  • 自己的 /WEB-INF/classes
  • 自己的 /WEB-INF/lib/*.jar
  • 自己找不到,再往上交给 Common → System → Bootstrap
  • 和标准双亲委派反过来了:先自己加载,不行再找爹。

    这样 App1 和 App2 各用各的 Spring 版本,同名的类因为不同加载器加载而被当成不同的类,互不影响。


    八、破坏双亲委派的时间线

    次序场景为什么
    第一次 JDK 1.2 前遗留代码 当时没有 findClass(),只能重写 loadClass()
    第二次 SPI(JDBC 等) Bootstrap 里接口需要加载 classpath 里的实现类
    第三次 Web 容器(Tomcat) 多应用类隔离 + 热部署
    第四次 OSGi 模块网状依赖,动态加载/卸载 Bundle
    第五次 JDK 9 模块化 废弃 Extension,引入 Platform + module path

    九、两个易混淆概念

    9.1 ClassNotFoundException vs NoClassDefFoundError

    ClassNotFoundExceptionNoClassDefFoundError
    类型 Exception Error
    场景 运行时显式加载:Class.forName("com.mysql.Driver") 编译时有,运行时链接阶段找不到
    典型原因 类路径漏了 jar jar 冲突 / 静态初始化失败

    // ClassNotFoundException:jar 没在 classpath
    Class.forName("com.mysql.cj.jdbc.Driver");

    // NoClassDefFoundError:编译时有,运行时 jar 被删了
    // 编译时 import SomeClass,运行时这个类不见了

    9.2 loadClass vs forName

    ClassLoader.loadClass("com.foo.User"); // 只加载,不初始化(不触发 <clinit>)
    Class.forName("com.foo.User"); // 加载 + 初始化(触发 <clinit>)

    JDBC 里 Class.forName("com.mysql.cj.jdbc.Driver") 就是利用初始化触发静态代码块来注册驱动。


    十、总结

  • 类加载五个阶段:加载→验证→准备→解析→初始化。准备阶段只赋零值,初始化才执行 <clinit>。
  • 双亲委派就是先交给爹,爹说不管再自己来。核心目的两个:防篡改、复用一个类只存一份 Klass。
  • 重写 findClass 不叫破坏,那只是换个地方找字节码。重写 loadClass 才叫破坏,因为改了查找顺序。
  • SPI 不得不打破:Bootstrap 里的接口要加载 classpath 里的实现,只能用线程上下文类加载器——爹借用儿子。
  • Tomcat 打破是为了隔离:每个应用一个 WebAppClassLoader,优先本地加载,不同应用同名的不同版本类可以共存。
  • 类的唯一标识 = 全限定名 + 类加载器,这点是理解一切隔离、打破、委派的基石。
  • 点赞收藏,下次面试问到类加载器,直接翻这篇就够了。

    赞(0)
    未经允许不得转载:171主机测评 » 双亲委派说了无数遍,但为什么 SPI 必须打破它?——类加载器从根讲清
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址