ClassLoader · loadClass · findLoadedClass · parent · findClass · 启动 / 平台 / 应用 · 类的身份 · ClassCastException · ServiceLoader · META-INF/services · TCCL · 子优先 · Metaspace

类加载与双亲委派:先问上级,核心类就只有一份;SPI 要反着找,就得借线程手里的加载器

JVM 里一个类的身份是类名 + 定义它的加载器。双亲委派规定每个加载器先问上级、上级找不到才自己来,所以 java.lang.String 这类核心类在整个 JVM 里只有一份,应用换不掉。代价是上层的代码看不见下层的类。可 JDBC 这类 SPI 正好要反过来:接口和 DriverManager 在 JDK 里,实现在你的 jar 里。于是 ServiceLoader 改用线程上下文类加载器(TCCL)去下层找。在交易靶场里实测:同一个 Order.class 被两个加载器各加载一次,就报 Order cannot be cast to Order;框架用自己的加载器找支付渠道的实现,找到 0 个,换成 TCCL 找到 1 个。

全景:一个对象从 new 到被回收 · 第 1 站地图:J03 · 类加载与双亲委派,SPI 为什么要打破它

①为什么需要:第一次 new Order,JVM 手里还没有 Order

在全景第 1 站,下单线程执行到 new Order(9527, 2)。JVM 要先有 Order 这个类,才能知道一个订单占几个字节、构造方法在哪。可这时它手里只有一个名字 labs.trade.Order。从名字到能用的类,要回答三件事:去哪找字节(classpath 上几十个 jar),找到后怎么变成类(校验、放进元空间),找到两份时用哪份。负责这件事的就是类加载器(ClassLoader)。

app

Order 由应用类加载器定义,在第一次 new 时才加载 实测

null

String.class.getClassLoader():启动类加载器是 C++ 写的,Java 里拿不到它 实测

platform

JDK 17 上 DriverManager 由平台类加载器定义,不是启动类加载器 实测

0 → 1

框架用自己的加载器找支付渠道实现:0 个;换成 TCCL:1 个 实测

场景交易靶场的下单服务,java -cp target/classes:lib/* … 启动。用户 1024 下单,线程第一次执行 new Order;同一个进程里,订单要写库,DriverManager.getConnection 得找到 -cp 上 MySQL 驱动 jar 里的 com.mysql.cj.jdbc.Driver。这两件事都靠类加载器,后一件恰好要“打破”前一件的规矩。
下单线程new Order(9527, 2) JVM 查一下Order 还没加载 应用类加载器先问上级(本页的主角)没人有,才自己去 classpath 找 target/classes/…/Order.class一串字节:CA FE BA BE … 元空间(堆外)类的元数据:字段、方法、常量池 堆里一个 Class 对象Order.class 拿到的就是它 然后才到第 3 站:Eden 里分 48 字节 这一步只在第一次发生;之后每个 Order 对象头里的类指针都指向那份元数据。

②没有它会怎样:每个加载器各找各的

设想没有“先问上级”的规矩:每个加载器拿到名字,就去自己能看到的地方找,找到就定义。听起来更简单,问题出在“同一个名字可能被定义好几次”。

各找各的

  • classpath 上放一个自己写的 java.lang.String,应用加载器就会用它,JDK 的那份被顶掉
  • 两个加载器都能看到 Order.class,就各定义一个 Order
  • 每个加载器把 Object、String 这些基础类也各加载一遍

后果

  • 安全和稳定都没了底:核心类可以被任何一个 jar 换掉,String 的不可变也就没了保证
  • 两个 Order 名字一样,JVM 却当成两个类。⑤ 里实测:(Order) fromA 抛 class labs…Order cannot be cast to class labs…Order
  • 同样的元数据在元空间里存好几份;一个类的对象传给另一个加载器的代码,类型就对不上

缺的是一条规矩,让同一个名字的类在需要共享的范围内只被定义一次,并且核心类永远由最上层定义。双亲委派就是这条规矩。不懂这一点的典型误用:以为“类名一样就是同一个类”。开了 Spring Boot devtools 热重启,或者把同一个 jar 同时放进 Tomcat 的 lib/ 和应用的 WEB-INF/lib,看到 X cannot be cast to X 就一头雾水。

③相邻方案:请求往哪个方向走

“谁来定义这个类”有几种做法。JDK 默认是父优先,其他几种都是在它上面为某个需要改的。

做法请求怎么走谁在用为了什么
父优先(双亲委派)先问上级,上级都没有才自己找JDK 的 ClassLoader.loadClass 默认实现;自己继承 ClassLoader 只重写 findClass 的也是核心类唯一、不可替换;同一个类不重复加载
按模块直达先看包属于哪个模块,直接交给定义这个模块的加载器;不在模块里才按父优先JDK 9+ 的三个内置加载器(BuiltinClassLoader)模块化以后,包和加载器的对应关系启动时就定了,不用一层层问
子优先JDK 的类仍先交给上层;其余的先找自己,找不到才问上级Tomcat 的 webapp 加载器(默认 delegate=false)每个应用用自己 WEB-INF/lib 里的版本,互不干扰
反向(借 TCCL)上层的代码拿着下层的加载器去加载ServiceLoader、JDBC、JNDI、Spring 的 SpringFactoriesLoader上层定义接口,下层提供实现(SPI)
网状按声明的导入导出,直接找提供这个包的那个模块OSGi同一进程里一个库的多个版本并存

选择规则:普通应用不用选,用默认的父优先;需要“同一个 JVM 里多个应用各用各的版本”时用子优先或网状;需要“框架定义接口、业务提供实现”时用 SPI,它离不开 TCCL。本页学父优先,以及 SPI 和 Tomcat 这两种“打破”。

④原理:一个类的一生、三层加载器、身份,以及 SPI 为什么要反着走

先知道这些
  • .class 文件就是一串字节,开头是 CA FE BA BE。它可以来自目录、jar、网络,甚至运行时现生成(动态代理)。
  • 类的元数据放在元空间,在堆外;堆里另有一个 Class 对象代表它。见 JVM 内存结构。
  • 每个对象头里有一个类指针,指向它的类的元数据,见 new 一个对象的全过程。instanceof 和强转比的就是这个指针。

④-1 一个类的一生:加载器只管第一步

加载 类加载器按名字找字节调 defineClass 定义类 唯一能由你插手的一步 链接 验证字节码合法吗会不会越界 准备静态字段分配先填 0 / null 解析符号引用换成真地址,可推迟 初始化 执行 static 块和静态字段赋值 只一次,JVM 加锁 使用 卸载加载器整个不可达才行 什么时候初始化(JLS 12.4.1,下面这些“第一次”) new · 调静态方法 · 读写非常量的静态字段 · Class.forName(name) · 子类初始化之前先初始化父类 · main 所在的类 只加载、不初始化 loader.loadClass(name) · Class.forName(name, false, loader) · 读 static final 的编译期常量(编译时就抄进调用方了)

⑤ 的 WhoLoads 里,Order 的 static 块在“准备 new Order”之后才打印,-Xlog:class+load 里那一行也排在它前面一点:加载和初始化都推迟到第一次真正用到。初始化失败时(static 块抛异常),这个类会被标成错误状态,之后每次用都报 NoClassDefFoundError: Could not initialize class,见 ⑥ 坑 4。

④-2 三层加载器,和 loadClass 的十几行

启动类加载器(Bootstrap)java.base 等 · lib/modules · Java 里是 null 平台类加载器(Platform)java.sql、java.xml.crypto 等 · JDK 8 叫 Ext 应用类加载器(App / System)-cp 上的目录和 jar · Order 在这里 自定义加载器Tomcat webapp、URLClassLoader、热部署 ① 先问上级 ② 上级没有才往下回 parent 字段
源码java.lang.ClassLoader#loadClassJDK 17,删了计时
protected Class<?> loadClass(String name, boolean resolve) {
  synchronized (getClassLoadingLock(name)) {   // 同名并发加载只放一个
    Class<?> c = findLoadedClass(name);        // 1. 我加载过吗
    if (c == null) {
      try {
        if (parent != null)
          c = parent.loadClass(name, false);  // 2. 先问上级
        else
          c = findBootstrapClassOrNull(name);   // 顶上是启动类加载器
      } catch (ClassNotFoundException e) { }
      if (c == null)
        c = findClass(name);                 // 3. 都没有,才自己找
    }
    if (resolve) resolveClass(c);
    return c;
  }
}

自定义加载器只需要重写 findClass:去哪找字节、然后 defineClass。要“打破”委派,就得重写 loadClass 本身,Tomcat 就是这么做的。JDK 9 以后三个内置加载器另有一套 BuiltinClassLoader.loadClassOrNull:先查这个包属于哪个模块,属于模块就直接交给那个模块的加载器,不在模块里(classpath 上的类)才走上面的父优先。效果一样,少问几层。

④-3 委派走一遍先猜由谁定义,再一步一步看

先选一个场景。

启动类加载器null
java.base 等核心模块:java.lang.*、java.util.*
平台类加载器platform
java.sql 等模块:DriverManager、Driver 接口
应用类加载器app
-cp 上的 target/classes 和依赖 jar
webapp 加载器Tomcat,子优先
WEB-INF/classes、WEB-INF/lib
    选一个场景,猜一下,再点“下一步”。

    步骤照 JDK 17 的 ClassLoader.loadClass、BuiltinClassLoader.loadClassOrNull 和 Tomcat 文档里 webapp 加载器的查找顺序编排,模拟逻辑在 node 里跑过;每个加载器“地盘里有什么”是这个场景的设定。

    ④-4 类的身份:名字 + 定义它的加载器

    Order.class同一个文件,同样的字节 Order @ app代码里写的 Order、Order.class 都是它 Order @ URLClassLoader@251a69d7名字一样,元空间里另一份元数据 ≠ fromA(一个订单对象)对象头的类指针 → 下面那份 (Order) fromA:指针对不上→ ClassCastException

    这条规则是本页一半坑的根源。双亲委派保证的是:只要请求都先往上走,同一个名字在这条链上只会被一个加载器定义。一旦有两个加载器都绕开上级、自己定义了同名类,就是两个类。

    ④-5 为什么非得先问上级

    父优先(选中)

    • java.lang.String 一定落到启动类加载器,全 JVM 只有一份;应用里同名的类永远轮不到
    • 一个类被谁加载,只取决于“链上最高的、能看到它的那一层”,结果是确定的
    • 上层定义过的不再重复定义,元空间里只一份
    • 类型检查依然便宜:比一下类指针就行

    被放弃的:各找各的,或处处子优先

    • 任何 jar 都能顶替核心类,JDK 的安全检查(比如 String 不可变、SecurityManager 时代的权限检查)就形同虚设
    • 结果取决于“谁先被问到”,同一个名字在不同代码里可能是不同的类,处处有 ⑤ 那种 ClassCastException
    • 要靠更复杂的规则补救:Tomcat 子优先时也得先把 JDK 的类交给上层,Servlet API 也不许应用自己带

    决定胜负的约束:核心类必须全 JVM 唯一、不可替换,而 JVM 判断“是不是同一个类”只看名字和定义它的加载器。在这两条下面,让请求一律先往上走,是最简单的能保证唯一的办法。它的代价也由此而来:请求只往上走,上层的代码就看不见下层的类。

    ④-6 SPI 为什么要打破:接口在上面,实现在下面

    平台类加载器定义的 DriverManagergetConnection 要找驱动 java.sql.Driver接口(SPI) 应用类加载器看得到的(-cp) mysql-connector-j.jarMETA-INF/services/java.sql.Driver → com.mysql.cj.jdbc.Driver 用自己的加载器往下找:看不见 实现类引用的接口照常往上委派 → 同一份 解法:借线程手里的加载器 ServiceLoader.load(Driver.class) → Thread.currentThread() .getContextClassLoader() 每个线程带一个加载器引用(TCCL), 默认继承自创建它的线程;main 线程的是 app 所以 DriverManager 在上层,却能让 app 读服务文件、加载 com.mysql.cj.jdbc.Driver JDK 17 源码:ServiceLoader.load(Class) 第一行就取 TCCL

    破的是方向

    双亲委派只允许“下层问上层”。SPI 让上层的代码主动用下层的加载器去加载,这才叫打破。⑤ 的 SpiDemo:框架用自己的加载器找到 0 个实现,TCCL 换成 app 找到 1 个。

    没破的是 loadClass

    TCCL 指向的 app 加载器自己仍然父优先:先问平台、启动,没有才在 -cp 上找。所以实现类里引用的 java.sql.Driver 拿到的还是上层那一份,类型对得上。

    新的约束

    TCCL 指向的加载器必须能把接口委派回同一份。⑤ 里把 TCCL 换成一个不委派的加载器,它自己又定义了一份接口,结果是 MockPay not a subtype。DriverManager 还会用调用方的加载器再核对一遍驱动(isDriverAllowed),就是在防这个。

    Tomcat 是另一种打破:webapp 加载器重写了 loadClass,JDK 的类先交给上层,其余的先找 WEB-INF,找不到才问上级。目的是让每个应用用自己的依赖版本,热部署时整个加载器扔掉重建。这两种打破在实验台里都能走一遍。

    API最少要会的几行参数和版本差异放到 ⑥
    Order.class.getClassLoader();                         // 谁定义了它;启动类加载器返回 null
    Class.forName("labs.trade.Order");                    // 用调用方的加载器,加载并初始化
    Class.forName("labs.trade.Order", false, loader);     // 指定加载器,不初始化
    loader.loadClass("labs.trade.Order");                 // 走委派,只加载不初始化
    Thread.currentThread().getContextClassLoader();       // TCCL,框架加载“用户的类”时用它
    ServiceLoader.load(PaymentChannel.class);             // 读 META-INF/services/接口全名,按 TCCL 加载实现
    
    # src/main/resources/META-INF/services/labs.jvm.jvm_classloading.spi.PaymentChannel
    labs.jvm.jvm_classloading.channel.MockPay

    ⑤亲眼看到:交易靶场里的三个小实验

    代码在 study-labs 的 jvm/jvm-classloading,--release 8 编译,JDK 17 运行,不需要任何依赖。三个实验分别看:谁加载了谁;两个 Order;支付渠道的 SPI。

    0编译、运行2 核 ARM · JDK 17.0.20.1
    cd study-labs/java && JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64 mvn -q -pl jvm/jvm-classloading compile
    cd jvm/jvm-classloading            # 实验按相对路径 target/classes 找类,要在这里运行
    J=/usr/lib/jvm/java-17-openjdk-arm64/bin/java; M=labs.jvm.jvm_classloading
    $J -Xlog:class+load -cp target/classes $M.WhoLoads | grep -E "Order|DriverManager|─"
    $J -cp target/classes $M.TwoOrders
    $J -cp target/classes $M.SpiDemo

    1. 谁加载了谁

    加载器链(从下往上):
      jdk.internal.loader.ClassLoaders$AppClassLoader@30946e09
      jdk.internal.loader.ClassLoaders$PlatformClassLoader@5f712fd4
      null = 启动类加载器(C++ 实现,Java 里拿不到对象)
      String                     → null
      DriverManager              → …PlatformClassLoader@5f712fd4
    [class,load] java.sql.DriverManager source: jrt:/java.sql
    准备 new Order ……
    [class,load] labs.jvm.jvm_classloading.Order source: file:/…/target/classes/   # 第一次 new 时才加载
      [Order 的 static 块执行了:类初始化,只会有一次] loader=…AppClassLoader@30946e09
      Order                      → …AppClassLoader@30946e09
    当前线程的上下文类加载器(TCCL):…AppClassLoader@30946e09

    2. 两个 Order

    2TwoOrders.java(节选)完整代码 →
    URL classes = new File("target/classes").toURI().toURL();
    // parent = null:只问启动类加载器,不问 app,相当于“各找各的”
    URLClassLoader a = new URLClassLoader(new URL[]{classes}, null);
    URLClassLoader b = new URLClassLoader(new URL[]{classes}, null);
    Class<?> ca = a.loadClass("labs.jvm.jvm_classloading.Order");
    Class<?> cb = b.loadClass("labs.jvm.jvm_classloading.Order");
    Object fromA = ca.getConstructor(long.class, int.class).newInstance(1024L, 2);
    Order o = (Order) fromA;     // 这里的 Order 是 app 定义的那个
    ca.getName().equals(cb.getName()) = true
    ca == cb                           = false
    ca == Order.class                  = false
      [Order 的 static 块执行了:类初始化,只会有一次] loader=java.net.URLClassLoader@251a69d7   # “只一次”是对每个类而言:这是另一个类
    fromA instanceof Order             = false
    ClassCastException: class labs.jvm.jvm_classloading.Order cannot be cast to class labs.jvm.jvm_classloading.Order
     (labs.jvm.jvm_classloading.Order is in unnamed module of loader java.net.URLClassLoader @251a69d7;
      labs.jvm.jvm_classloading.Order is in unnamed module of loader 'app')

    JDK 17 上这条异常信息会把两边的加载器都写出来。看到 X cannot be cast to X,先看括号里的两个 loader。

    3. 支付渠道 SPI:框架在上,实现在下

    3SpiDemo / ChannelRegistry(节选)完整代码 →
    // 两层:framework 只定义 spi 包(接口 PaymentChannel、框架代码 ChannelRegistry),看不见服务文件
    //      app = URLClassLoader(target/classes),parent = framework,能看到实现 channel.MockPay
    FrameworkLoader framework = new FrameworkLoader();
    Named app      = new Named("app", framework);
    Named isolated = new Named("isolated", null);        // 不委派:自己再定义一份接口
    
    // ChannelRegistry 里:
    ServiceLoader.load(PaymentChannel.class, ChannelRegistry.class.getClassLoader());  // ① 用自己的
    ServiceLoader.load(PaymentChannel.class);                                           // ②③④ 用 TCCL
    
    // 换 TCCL 的写法:用完一定还回去
    ClassLoader old = t.getContextClassLoader();
    t.setContextClassLoader(cl);
    try { … } finally { t.setContextClassLoader(old); }
    ChannelRegistry 由谁定义:framework
    MockPay        由谁定义:app
    ① 框架用自己的加载器找实现:0 个 []
    ② TCCL = framework → 0 个 []
    ③ TCCL = app        → 1 个 [MockPay(loader=app)]
    ④ TCCL = isolated   → ServiceConfigurationError: labs.jvm.jvm_classloading.spi.PaymentChannel: labs.jvm.jvm_classloading.channel.MockPay not a subtype

    到这里已经能交差了:能说出一个类由谁加载、为什么会 X cannot be cast to X、SPI 找不到实现时先查 TCCL。已知还没处理的细节:加载器泄漏把元空间撑爆(坑 2),线程池里 TCCL 不对(坑 3),初始化失败后的 NoClassDefFoundError(坑 4),依赖冲突(坑 5)。

    ⑥细节:坑、规范、更深一层

    第一圈

    注意事项

    坑 1X cannot be cast to X← ④-4 类的身份⑤ 实测
    现象
    类名完全一样,强转却失败;或者 instanceof 莫名其妙地是 false。常见于 Spring Boot devtools 热重启后,以及同一个 jar 同时在 Tomcat lib/ 和 WEB-INF/lib 里。
    根源
    有两个加载器各自定义了这个类。devtools 把你的代码放进一个会重建的 RestartClassLoader,依赖 jar 留在基础加载器里;如果某个库把对象缓存在 static 字段里,重启后拿出来的是旧加载器的类。
    做法
    看异常括号里的两个 loader,确定是哪两个;把这个类只留在一处(删掉重复的 jar),或者让缓存跟着重启一起清掉。
    坑 2反复热部署后 OutOfMemoryError: Metaspace← ④-1 卸载要加载器整个不可达实测
    现象
    每重新部署一次,元空间涨一截,几次之后 OutOfMemoryError: Metaspace。
    根源
    一个类只有在定义它的加载器、这个加载器定义的所有类、以及这些类的所有实例都没人引用时才能卸载。任何一个对象被长寿的地方攥着(容器线程的 ThreadLocal、JDBC 驱动注册表、static 缓存、没停的线程),整个加载器连同它的全部类都卸不掉。见 ThreadLocal 与内存泄漏。
    做法
    停应用时注销驱动、停线程、清 ThreadLocal;用 jcmd <pid> VM.classloader_stats 看加载器有多少个、各占多少元空间,用堆转储找谁引用着旧加载器。下面这张图是 LoaderLeak 的实测。
    MaxMetaspaceSize = 32 MB 0102030 元空间已用(MB) 50010001500200025003000 轮 攥着:2000 轮之前 OOM 用完就丢:GC 卸载 6192 个类,掉回 8.5 MB 前 1500 轮两条线重合(绿线下移了 3 px 以便看见):类卸载要等 GC,元空间涨到高水位才触发那次 GC。
    坑 3SPI 或按名字加载,换个线程就找不到类← ④-6 TCCL源码核对
    现象
    同一段代码在 main 线程里好好的,放进 parallelStream()、CompletableFuture.supplyAsync(默认用公共池)里,就 ClassNotFoundException 或找不到实现。Spring Boot 可执行 jar 里最常见。
    根源
    TCCL 是跟着线程走的。JDK 17 的 ForkJoinPool 公共池,工作线程的 TCCL 被设成系统类加载器(ForkJoinWorkerThread 源码、ForkJoinPool 文档)。可执行 jar 里你的类和依赖由 Spring Boot 自己的加载器加载,系统类加载器看不到。
    做法
    在用到的地方显式传加载器:ServiceLoader.load(X.class, X.class.getClassLoader());或者用自己的线程池,线程工厂里设好 TCCL;临时换 TCCL 一定写在 try/finally 里还回去。
    坑 4NoClassDefFoundError: Could not initialize class X← ④-1 初始化只一次按 JLS
    现象
    第一次报 ExceptionInInitializerError,之后每次都报 NoClassDefFoundError: Could not initialize class X,类明明就在 jar 里。
    根源
    static 块或静态字段赋值抛了异常,这个类被标成初始化失败(JLS 12.4.2),JVM 不会再试第二次。和 ClassNotFoundException 不同:后者是按名字(Class.forName、loadClass)找不到字节。
    做法
    往日志前面翻,找第一次的 ExceptionInInitializerError 和它的 cause;static 块里不要做读配置、连网络这种会失败的事,改成懒加载。
    坑 5依赖冲突:NoSuchMethodError、NoClassDefFoundError← ④-2 findLoadedClass + 按顺序找按文档
    现象
    编译通过,运行时 NoSuchMethodError;换一台机器或换个启动方式,问题时有时无。
    根源
    classpath 上有同一个类的两个版本(比如 guava 两个版本)。同一个加载器只定义一次,按 classpath 的顺序拿第一份,后面那份永远不会被用到。顺序变了,用到的版本就变了。
    做法
    mvn dependency:tree -Dincludes=… 找出是谁引进来的,排除掉一个;运行时用 -Xlog:class+load(JDK 8 是 -verbose:class)看这个类实际来自哪个 jar。
    坑 6想覆盖一个 JDK 类,结果不生效或直接报错← ④-5 核心类不可替换源码核对
    现象
    在项目里放一个同名的 java.util.XXX 想打补丁,运行时还是 JDK 的;用自定义加载器 defineClass 它,报 SecurityException: Prohibited package name: java.util。
    根源
    请求一定先到上层;就算绕开委派自己定义,ClassLoader.preDefineClass 也拒绝非启动、非平台加载器定义 java.* 包里的类。
    做法
    别改 JDK 类。真要换框架里的类,用框架提供的扩展点;要改字节码用 Java agent(Instrumentation)。
    第二圈

    使用规范

    框架加载“用户的类”:先用传进来的加载器,没有再用 TCCL

    别用 Class.forName(name) 一个参数的版本,它用的是框架自己的加载器。Spring 的 ClassUtils.getDefaultClassLoader() 就是 TCCL → 自己的加载器 → 系统加载器这个顺序。

    坑 3,以及框架放在上层时找不到业务类。

    换 TCCL 一定 try/finally 还回去

    ⑤ 的 SpiDemo.run 就是这个写法。线程池里的线程会被复用,忘了还,下一个任务拿到的是别人的加载器。

    莫名其妙的 ClassCastException,以及旧加载器被线程攥住(坑 2)。

    自定义加载器只重写 findClass

    除非像 Tomcat 那样确实要改顺序,否则不要重写 loadClass;要支持并发加载,在 static 块里调 registerAsParallelCapable()。

    核心类被重复定义;多线程同时加载时全局一把锁。

    长寿的地方别攥着短命加载器的东西

    static 缓存、注册表、线程、ThreadLocal 里放的对象,要是来自会被丢掉的加载器(插件、热部署、脚本引擎),要有对应的清理或用弱引用。

    坑 2 的元空间泄漏。

    static 块只做不会失败的事

    读配置、连网络、加载本地库放到第一次使用时,失败可以重试。

    坑 4:一次失败,整个进程里这个类再也用不了。

    元空间设上限,并且监控它

    -XX:MaxMetaspaceSize 默认不限,泄漏时会一直吃到容器被杀;设个上限,配合 jvm.memory.used{area=nonheap,id=Metaspace} 告警。

    泄漏变成没有日志的容器 OOMKilled(全景第 10 站)。

    第三圈

    更深一层

    JDK 9 以后,委派按模块走,平台加载器甚至会往下交

    三个内置加载器都是 BuiltinClassLoader。启动时建好“包 → 模块 → 加载器”的表,loadClassOrNull 先查表,查到就直接交给那个加载器(④-3 实验台里 String 那一步)。JDK 17 的 ClassLoader 文档写明:为了支持升级平台模块,平台加载器在某些情况下“may have to delegate to other class loaders, the application class loader for example”。classpath 上的类不在命名模块里,仍按父优先。

    全景里那行 “source: shared objects file”

    JDK 自带的类大多从 CDS 归档里直接映射进来,跳过读文件和解析;JDK 12 起默认带一份归档。⑤ 里 DriverManager 显示 source: jrt:/java.sql,说明它不在默认归档里,是现读的。应用自己的类也可以做进归档(AppCDS),启动更快。

    类为什么这么难卸载

    启动、平台、应用这三个加载器活到进程结束,它们定义的类永远不卸载(JLS 12.7)。只有自定义加载器整个变成垃圾,它的类才会在某次 GC 里一起卸载。⑤ 的 LoaderLeak 里,“用完就丢”的版本前 1500 轮也一直在涨,直到元空间接近上限触发 GC,才一次卸了 6192 个类。加 -Xlog:class+unload 能看到每一个。

    Spring 的“SPI”:spring.factories 和 AutoConfiguration.imports

    Spring Boot 的自动配置也是“框架定义扩展点、jar 里写清单”,SpringFactoriesLoader 和 ImportCandidates 都用传入的加载器(通常是 TCCL)去读 META-INF 下的文件,原理和 ServiceLoader 相同。见 Spring Boot 启动流程。

    版本差异

    JDK变化对你意味着什么
    8三层是启动 / 扩展(Ext,管 jre/lib/ext)/ 应用;核心类在 rt.jar;DriverManager 由启动类加载器加载,在静态初始化时就用 ServiceLoader 扫一遍驱动老资料里的“扩展类加载器”指的是这一代
    9模块化(JEP 261):去掉 rt.jar 和扩展机制(JEP 220),Ext 换成平台类加载器;java.sql 等模块改由平台加载器定义;内置加载器按模块直达别再把 jar 放进 lib/ext;DriverManager.class.getClassLoader() 不再是 null(⑤ 实测是 platform)
    17DriverManager 在第一次 getConnection / getDrivers 时才扫描驱动(ensureDriversInitialized,本机源码;具体从哪一版改的没有核对);ClassCastException 信息带上两边的加载器;公共池线程的 TCCL 是系统加载器驱动加载时机比 8 晚;排查坑 1 直接看异常信息;坑 3
    21类加载这一块和 17 一致(DriverManager 源码同样是 ensureDriversInitialized);虚拟线程同样有 TCCL,默认继承创建它的线程升级 17 → 21 这里不用改

    下一圈

    ↺回到⑤:改造你的案例

    1. 让两个 Order 变成一个

    按 ④-4,把 TwoOrders 里两个加载器的 parent 从 null 改成 TwoOrders.class.getClassLoader()。先猜 ca == cb 和强转的结果,再跑;说出是哪一步委派让它们变成了同一个类。

    2. 在线程池里找支付渠道

    按坑 3,在 SpiDemo 里先把 main 线程的 TCCL 设成 framework,再建一个线程池去调 viaTccl():找得到几个?然后用线程工厂修好它。

    3. 只放掉一半

    按坑 2,LoaderLeak -Dkeep=true 时只删掉 HOLD.add(o),代理对象照留。先猜还会不会 OOM,再跑,用 ④-1 的卸载条件解释。

    自测

    1. 用一句话说出这一页的精华。

    类的身份是“名字 + 定义它的加载器”;双亲委派让请求先往上走,保证核心类唯一、同名类不重复定义,代价是上层看不见下层;SPI 的接口在上层、实现在下层,所以要借线程上下文类加载器反过来找。

    2. 没有双亲委派、各找各的,疼在哪?

    核心类能被任何 jar 顶替;同一个名字被几个加载器各定义一次,就是几个不同的类,互相传对象就 ClassCastException(⑤ 实测 Order cannot be cast to Order);同样的元数据存好几份。

    3. 热部署几次就 Metaspace OOM,根源是哪条原理?

    ④-1 的卸载条件:类只有在定义它的加载器整个不可达时才能卸载。旧应用里只要有一个对象被容器线程、static 字段或注册表攥着,它的类 → 加载器 → 加载器定义的所有类都留在元空间里。

    4. 预测:在 JDK 17 上,把 Class.forName("com.mysql.cj.jdbc.Driver") 写在一个由平台类加载器定义的类里执行(假设能做到),会怎样?

    抛 ClassNotFoundException。一个参数的 forName 用调用方自己的加载器,平台加载器只会往上问启动类加载器,看不见 -cp 上的 jar(④-2、④-6)。这正是 DriverManager 要借 TCCL 的原因。

    5. 预测:Tomcat 里应用 A 和应用 B 都在 WEB-INF/lib 带了同一版本的 jackson,内存里有几份 ObjectMapper 类?如果把它挪到 Tomcat 的 lib/ 并从两个应用里删掉呢?

    前者两份:webapp 加载器子优先,各自定义一份,两个应用之间不能直接传 jackson 对象。后者一份:两个 webapp 加载器在自己那里找不到,交给上级 common 加载器,它只定义一次(④-2 的 findLoadedClass)。代价是两个应用必须用同一个版本。

    6. 放回全景:第 1 站的前后各发生了什么?类加载坏了,整条链路会怎样?

    前面是下单线程执行到 new Order;后面是第 2 站 JIT 把热点代码编译进代码缓存、第 3 站在 Eden 的 TLAB 里分配 48 字节,对象头里的类指针就指向第 1 站放进元空间的元数据。类加载坏了有三种样子:加载器泄漏让元空间一直涨,最后 OutOfMemoryError: Metaspace,或者没设上限时进程超出容器内存被杀(第 10 站),下单接口整体不可用;同一个类被两个加载器加载,订单对象一过框架边界就 ClassCastException,请求在第 1 站之后就失败;SPI 找不到 MySQL 驱动,订单写不了库,堆和 GC 都正常,问题却在类加载。