按类名加载类的代码到处都是,而且大多不问「谁加载了我」,只问当前线程的 TCCL(thread context class loader):ServiceLoader.load(Class) 的第一行就是取 TCCL(JDBC 驱动的自动注册走的正是它),Jackson 的 TypeFactory.findClass 注释写着 two-phase lookup: first using context ClassLoader。这类代码在 IDE 里通常不出错,打成 fat jar 就可能报 ClassNotFoundException,而且只在一部分线程上报。

这次把两件事分开测:打包形态决定真正挂着依赖的是哪个 class loader,线程来源决定 TCCL 是什么。环境是 Temurin 25.0.2、Spring Boot 3.0.5 与 3.5、macOS,输出都来自 meirongdev/kafka-tccl-issue 里的 ./run.sh threads / repro / flat / extract

委派链:谁看得见 BOOT-INF/lib#

类不是一次性装进 JVM 的:轮到它被真正用到,才去找字节码、链接、跑 <clinit>,规范把这三段分开写(JVMS 第 5 章)。本篇只关心第一步:按名字把字节码找出来,这一步归 class loader 管。

JVM 里的 class loader 是一条链,每一层有自己的搜索路径,也各管一片类:bootstrap 管 java.baseString 在这里,它的 loader 打出来是 null),PlatformClassLoaderjava.sql 这些平台模块(DriverManagerjava.sql.Driver 都在这一层),应用自己的类和它们的依赖挂在最底下。真实的 Spring Boot fat jar 跑起来是这条(./run.sh repro 启动时那几行自检日志打出来的,Boot 3.0.5):

flowchart BT L["LaunchedURLClassLoader(Boot 建的)
搜 BOOT-INF/classes/ 与 BOOT-INF/lib/*.jar
业务类和依赖都在这一层"] A["AppClassLoader(system class loader)
java -jar 时只搜那个 jar 的根
fat jar 下这里只有 Boot 的 loader 类"] P["PlatformClassLoader
平台模块 java.sql 等"] B["bootstrap(null)
java.base(String、Object)"] L ==>|"① 请求先交给 parent"| A A ==>|"①"| P P ==>|"①"| B B -.->|"② 都没有,才回落到各自的搜索路径"| L style A stroke:#c00,stroke-width:2px

加载一个类时,请求先一路向上交给 parent,谁都没有,才回落下来由各自的搜索路径去找(ClassLoader.loadClass 的顺序是 findLoadedClass → parent → 自己的 findClass)。fat jar 把依赖放进 BOOT-INF/lib/,只有链最底下那层读得到,AppClassLoader 作为它的 parent 够不着。图里标红的就是它。

图里两个名字容易混,先把叫法定下来:JDK 管 system class loader 叫 application class loader(自检日志里那个 AppClassLoader(app),末尾的 app 是它自己的 name)。下文跟着 Boot 的习惯,用 application class loader 指「真正挂着应用类和依赖的那个 loader」。平铺 classpath 下两者是同一个;fat jar 下不是:挂着依赖的是图里最底下那层 LaunchedURLClassLoader,而 getSystemClassLoader() 拿到的是它上面那个标红的 AppClassLoader

库代码为什么不用自己的 class loader#

委派只朝一个方向走,于是这条通道是单向的:child 用得上 parent 的类,parent 碰不到只有 child 才有的类。单向不是实现偷懒,JVMS §5.4.3.1 把它写进了规范:一个类里出现的类名(规范里叫符号引用),由定义这个类的那个 class loader 负责解析。也就是说,一段代码能解析到哪些类,取决于它自己被谁加载,跟调用它的人站在哪一层无关。

DriverManager 就卡在这条规则上。它住在 java.sql 模块里,也就是上面那条链的 PlatformClassLoader 那一层;可它要干的活是把 JDBC 驱动的实现类找出来。java.sql 里只有 Driver 这个接口,实现在 MySQL、PostgreSQL 那些 jar 里,挂在它下面那层。按上面那条规则,它代码里无论怎么写类名都解析不到那些实现类。这不是 fat jar 才有的问题。最普通的 java -cp 平铺 classpath 下,驱动 jar 由 AppClassLoader 加载,而 AppClassLoader 就是 PlatformClassLoader 的 child,驱动实现还是落在 DriverManager 的下一层,照样解析不到。所以这是 JDK 自己就得先解决的问题,跟 Spring Boot 怎么打包无关。

JDK 给这类代码留的口子就是 TCCL:这一段不走符号引用,改问 Thread.currentThread().getContextClassLoader(),把「该用哪个 loader」交回应用这一侧。DriverManager 就是这么办的:初始化驱动列表时调的是 ServiceLoader.load(Driver.class),开头说过,那个方法第一行取的就是 TCCL。

一次按名加载要闯两关#

把前两节叠起来,一次「按类名加载」能不能成,要连过两道关:先看这条线程的 TCCL 是谁给的,再看那个 class loader 看不看得见依赖。

flowchart TD A["库代码:Class.forName(name, true, TCCL)
ServiceLoader / Jackson 这类库都走这条"] --> B{"这条线程的 TCCL
是谁给的?"} B -->|"继承创建它的线程"| C["多半是 application class loader"] B -->|"建它时 ThreadFactory 设过"| D["可能是 system class loader"] C --> E{"这个 class loader
看得见依赖吗?"} D --> E E -->|"BOOT-INF/lib 只有 Boot 的 loader 读得到"| F["看不见 -> ClassNotFoundException"] E -->|"平铺 classpath / extract 布局"| G["看得见 -> 正常"] style F stroke:#c00,stroke-width:2px

第一关看的是这条线程的 TCCL。它是哪个 class loader,线程自己说了不算,取决于它是怎么被建出来的:建它的 ThreadFactory 调过 setContextClassLoader 的,就是设进去的那个;没人设过,就照抄创建它的那条线程;把继承那个开关关掉时另有默认值,落到 system class loader(下面 virtual thread 一节实测的就是这一条)。

main 线程能过关,是因为第一关就分岔了:真 fat jar 里 JarLauncher 在调你的 main() 之前,把自己建的 loader 设成了 main 的 TCCL(Launcher.java:94),到第二关自然看得见依赖。第一关由线程决定,第二关由打包形态决定。下面先测第二关:它只有三种取值,一张表就能判掉,判掉之后第一关往哪儿指都无所谓;然后再回头细看第一关。

打包形态决定谁是 application class loader#

三种跑法,同一份代码,./run.sh repro(fat jar)、flat(平铺 classpath)、extract(Boot 3.3+ 的 jarmode=tools extract 布局)各跑一次,应用自检日志里打的是 application class loader 和 commonPool 线程能不能看见依赖。探针是它序列化器依赖里的一个类,躺在 BOOT-INF/lib 里,所以那一列叫「看得见序列化器依赖」:

跑法 application class loader commonPool 线程看得见依赖
java -jar fat jar Boot 3.0.5 是 LaunchedURLClassLoader,3.5 是 LaunchedClassLoader
java -cp 平铺 classpath AppClassLoader
jarmode=tools extract 后的布局 AppClassLoader

fat jar 那一行里的两个名字只是版本差异:spring-boot-loader 在 3.2 重写过一遍,LaunchedURLClassLoader 改叫 LaunchedClassLoader,位置和职责没变。extract 布局把依赖摊回 lib/ 由 manifest 引用,于是 application class loader 就是 system class loader 本身,这一关直接消失:

# A. fat jar(BOOT-INF/lib,只有 Boot 的 loader 看得见)
运行形态       : fat jar(应用类加载器 = LaunchedClassLoader)
commonPool 线程 : TCCL=AppClassLoader(app) 看得见序列化器依赖=否 ← 就是这里出问题

# B. jarmode=tools extract 布局(依赖放回 lib/,由 manifest 引用)
运行形态       : 平铺 classpath(应用类加载器 = AppClassLoader)
commonPool 线程 : TCCL=AppClassLoader(app) 看得见序列化器依赖=是

IDE、mvn spring-boot:run 和单元测试走的都是平铺 classpath,所以这一关在本地永远是过的。这是我现在遇到「本地好好的」类加载问题时第一个怀疑的地方。

从 main 出发:十一种线程的 TCCL#

./run.sh threads 用一个自建的 URLClassLoader 扮演 Boot 的 class loader(应用类只挂在它上面,等价于 BOOT-INF/lib),把 main 的 TCCL 设成它,然后逐个探针看每种线程拿到什么。探针是只挂在那个自建 loader 上的 app.Nested,所以下面那一列叫「看得见应用类」。它和上一节的「看得见序列化器依赖」问的是同一件事,只是探针类换了。

下面 TCCL 那一列只有两个值,名字有点像,别看混:boot-like 是那个自建的 loader,扮演 fat jar 里的 LaunchedClassLoader,看得见应用类;AppClassLoader 是 JDK 的 system class loader,也就是它的 parent,在 fat jar 下看不见 BOOT-INF/lib

线程来源 TCCL 看得见应用类
main 自己 boot-like
new Thread(...) boot-like
Executors.newFixedThreadPool(1) boot-like
Executors.newCachedThreadPool() boot-like
Executors.newScheduledThreadPool(1) boot-like
new ForkJoinPool(1)(自建,非 common) AppClassLoader
Thread.ofVirtual().start(...) boot-like
Thread.ofVirtual().inheritInheritableThreadLocals(false) AppClassLoader
Executors.newVirtualThreadPerTaskExecutor() boot-like
CompletableFuture.runAsync(...)(不传 executor) AppClassLoader
IntStream.range(...).parallel() AppClassLoader(main 那部分除外)

四行「否」的原因只有两个:new ForkJoinPool(1)、不传 executor 的 CompletableFuture.runAsync(...)parallel() 都落在 ForkJoinPool 上(后两个用的是 commonPool),另一行是那条关掉了继承的 virtual thread。

ForkJoinPool 不只是 commonPool 有问题#

ForkJoinPool 的 javadoc 只承诺 common pool 用 system class loader 当 TCCL,但表里自建的 new ForkJoinPool(1) 同样看不见应用类。JDK 25 的源码把这件事说得更直白,默认工厂的注释就写着 creates a new ForkJoinWorkerThread using the system class loader as the thread context class loader,两个分支都是:

static final class DefaultForkJoinWorkerThreadFactory
    implements ForkJoinWorkerThreadFactory {
    public final ForkJoinWorkerThread newThread(ForkJoinPool pool) {
        return ((pool.workerNamePrefix == null) ? // is commonPool
                new ForkJoinWorkerThread.InnocuousForkJoinWorkerThread(pool) :
                new ForkJoinWorkerThread(null, pool, true, false));
    }
}

那个 trueuseSystemClassLoader,构造器里直接 setContextClassLoader(ClassLoader.getSystemClassLoader())。所以只要你没给 ForkJoinPool 传自己的 ForkJoinWorkerThreadFactory,它的 worker 就都不继承创建者的 TCCL。parallel() 那一行是同一件事的另一副面孔:parallel stream 跑在 common pool 上,调用线程也会参与,所以输出里既有 commonPool-worker-N 的「看不见」,也有 main 的「看得见」,同一个 stream 里两种结果并存。

virtual thread 默认继承,关掉继承就换成 system class loader#

Thread.ofVirtual().start(...)newVirtualThreadPerTaskExecutor() 都继承了创建者的 TCCL,这一点和 platform thread 一致。但 inheritInheritableThreadLocals(false) 那一行掉进了「否」:TCCL 和 inheritable thread-local 共用同一个开关。Thread 里初始化 virtual thread 的那个构造器,两条分支是挨着写的:

// thread locals
if ((characteristics & NO_INHERIT_THREAD_LOCALS) == 0) {
    Thread parent = currentThread();
    ThreadLocal.ThreadLocalMap parentMap = parent.inheritableThreadLocals;
    if (parentMap != null && parentMap.size() > 0) {
        this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parentMap);
    }
    this.contextClassLoader = parent.getContextClassLoader();
} else {
    // default CCL to the system class loader when not inheriting
    this.contextClassLoader = ClassLoader.getSystemClassLoader();
}

为了少继承几个 ThreadLocal 而关掉它,会连 TCCL 一起换掉。platform thread 的构造器里是同一段代码,commonPool 的 worker 走的就是那条 else:它是 InnocuousForkJoinWorkerThread,构造时 clearThreadLocals = true

上面两节引的都是 JDK 25 的源码。这两处(ForkJoinWorkerThread 里那个 useSystemClassLoader 分支、Thread 里 virtual thread 的这条 else)在 Temurin 21.0.11 自带的 src.zip 里写法相同,所以这两节的结论在 JDK 21 上也成立;实测我只在 25 上跑过。

坏 TCCL 传给谁:platform pool 的 worker 建一次,virtual pool 每个任务重来#

上面那张表都是从 main 出发。把出发点换成一条 TCCL 已经坏掉的 commonPool worker,./run.sh threads 的 B 段是这样:

B. 从 commonPool worker 出发 —— 坏 TCCL 会传给谁
  (预热)main 提交,建出 worker        [平台] pool-4-thread-1  TCCL=boot-like     看得见应用类
  commonPool worker 自己             [平台] commonPool-worker-1  TCCL=AppClassLoader  看不见应用类
  它 new 的平台线程                  [平台] Thread-1        TCCL=AppClassLoader  看不见应用类
  它建的 newFixedThreadPool(1)       [平台] pool-5-thread-1  TCCL=AppClassLoader  看不见应用类
  它起的虚拟线程                     [虚拟]                 TCCL=AppClassLoader  看不见应用类
  main 建好的平台池,worker 早就在了   [平台] pool-4-thread-1  TCCL=boot-like     看得见应用类
  main 建的虚拟线程池,每任务新建线程   [虚拟]                 TCCL=AppClassLoader  看不见应用类

(为了在页面里排得下,我把 TCCL 那一列的 java.net.URLClassLoader@18b4aac2 缩写成了 boot-like,线程名去掉了 ForkJoinPool. 前缀,其余照抄。)

中间三行是传染:这条 worker 自己 new 的平台线程、它建的线程池 worker、它起的虚拟线程,全都带着它那份 system class loader。

最后两行是这次实测里最值得记的一条。两个池都由 main 建,任务都由那条坏 TCCL 的 worker 提交,结果相反:

  • platform thread pool 的 worker 是建一次的。它在 main 第一次提交时就创建好,TCCL 当场定型,之后谁来提交都不影响;
  • virtual thread pool 是每个任务新建一条线程。新线程继承的是提交任务的那条线程,所以坏 TCCL 顺着提交路径进来了。

这条差异不影响「启动时先在 main 上把类初始化一遍」这类预热修复:类初始化只发生一次,在正确的线程上预热成功之后,之后哪条线程来用都无所谓。它影响的是另一半:那些每次调用都按名字解析类的地方,比如运行时才调的 ServiceLoader.load()、按 payload 里的类名找类的 Jackson。这类代码在 platform thread pool 上还有「worker 建一次」兜着,换成 virtual thread pool 就每个任务重赌一次提交者的 TCCL。要往 virtual thread 迁移,钉住 ThreadFactory(下一节)就不再是纵深防御,而是唯一稳的那层。

临时改 TCCL 留不住,钉在 ThreadFactory 上才稳#

坏 TCCL 传得下去,那能不能在任务开头临时改回来?改得动,但留不住:

D. 在 commonPool 任务里临时改 TCCL —— 留不留得住
  任务里改完,同一个任务内再看        [平台] commonPool-worker-1  TCCL=boot-like     看得见应用类
  空闲之后再跑 32 个任务             [平台] commonPool-worker-1  TCCL=AppClassLoader  看不见应用类

原因在 JDK 源码里:common pool 的 worker 是 InnocuousForkJoinWorkerThread,它重写了 setContextClassLoader 把「你改过」记下来,等 ForkJoinPool.awaitWork() 里 worker 转入空闲时调 resetThreadLocals(),TCCL 就被还原成 system class loader。所以「在任务开头设一下 TCCL」只对当前这个任务成立,不能当成给整个池的修复。

对比之下,自己在 ThreadFactory 里钉住 TCCL 是稳的(./run.sh threads 的 C 段测的就是这个):同一个工厂建出来的 worker,无论由 main 还是由 commonPool worker 触发创建,TCCL 都是 application class loader。

ExecutorService pool = Executors.newFixedThreadPool(size, r -> {
    Thread t = new Thread(r, "outbound-" + SEQ.incrementAndGet());
    t.setContextClassLoader(MyApp.class.getClassLoader());   // 钉死,不看谁来提交
    return t;
});

真实 Spring Boot 应用:Tomcat、Spring 线程池、commonPool#

上面都是裸 JDK。把同样的探针放进一个真的 Spring Boot 应用(fat jar,Boot 3.0.5),启动时那几行自检日志打出来是这样:

# 去掉了时间戳、日志前缀和每行的 thread=… 那一段,其余照抄
运行形态                       : fat jar(应用类加载器 = LaunchedURLClassLoader)
main 线程                      : TCCL=LaunchedURLClassLoader 看得见序列化器依赖=是
commonPool 线程                : TCCL=AppClassLoader(app)    看得见序列化器依赖=否 ← 就是这里出问题
JDK 固定池(worker 由 main 创建) : TCCL=LaunchedURLClassLoader 看得见序列化器依赖=是
JDK 固定池(worker 由 pool 创建) : TCCL=AppClassLoader(app)    看得见序列化器依赖=否 ← 就是这里出问题
Spring TPTE(worker 由 pool 创建): TCCL=AppClassLoader(app)    看得见序列化器依赖=否 ← 就是这里出问题

两点和裸 JDK 不一样。一是 Spring 的 ThreadPoolTaskExecutor 并不会替你钉 TCCL,它的 worker 照样继承创建者,换个池不解决问题;二是 HTTP 请求线程另有出处,嵌入式 Tomcat 给 http-nio-* 装的是自己的 TomcatEmbeddedWebappClassLoader,看得见依赖:

# 请求打进来之后业务代码记的那一行,去掉了时间戳、日志前缀和这一轮发送的业务描述
[http-nio-18080-exec-1] >>> [HTTP-REQUEST] ...
  | thread=http-nio-18080-exec-1 TCCL=TomcatEmbeddedWebappClassLoader 看得见序列化器依赖=是

所以同一个 JVM 里,HTTP 入口正常、异步补偿任务失败,是完全可能的:手动点一次接口试不出来,得让那条异步线程自己跑一次。

自己的服务怎么查#

不用等线上炸。打成 fat jar 用 java -jar 跑一次(平铺 classpath 查不出东西),在可疑的线程上看一眼 TCCL 能不能加载你的依赖:

String verdict = CompletableFuture.supplyAsync(() -> {
    ClassLoader tccl = Thread.currentThread().getContextClassLoader();
    try {
        // initialize=false:只看可见性,不触发 clinit
        Class.forName("com.example.SomeSpiImpl", false, tccl);
        return "看得见";
    } catch (ClassNotFoundException | NoClassDefFoundError e) {
        return "看不见 ← 这条线程上按类名加载会失败";
    }
}).join();

把类名换成你自己那个按名加载的实现类就行。initialize=false 不能省:用 true 去探测,等于顺手替好线程把类初始化了,然后得到「查不出问题」的结论。想看完整矩阵就把仓库拉下来跑 ./run.sh threads,只要一个 JDK,不需要 Maven。

小结#

  • 打包形态决定 application class loader:fat jar 是 Boot 的 LaunchedClassLoader(3.2 之前叫 LaunchedURLClassLoader),平铺 classpath 和 jarmode=tools extract 布局都是 system class loader,后两种下 TCCL 指哪儿都无所谓;
  • 线程决定 TCCL:platform thread 和 virtual thread 默认都继承创建者,ForkJoinPool 的 worker 是例外,用默认工厂的池全都拿 system class loader,不只是 commonPool;inheritInheritableThreadLocals(false) 会连带把 TCCL 换成 system class loader;
  • platform thread pool 的 TCCL 在 worker 建出来那一刻定型,virtual thread pool 每个任务重新继承一次。只跑一次的 <clinit> 有启动预热兜着,但每次调用都按名字解析类的地方(运行时的 ServiceLoader.load()、按类名找类的 Jackson),迁到 virtual thread 之后只剩钉住 ThreadFactory 这一条稳的路;
  • 在 commonPool 任务里临时改 TCCL 只在本次任务内有效,worker 空闲时会被还原;
  • 放进真的 Spring Boot 应用之后多两条:Spring 的 ThreadPoolTaskExecutor 也不替你钉 TCCL,换个池不解决问题;嵌入式 Tomcat 给 http-nio-* 装的是自己的 loader,所以同一个 JVM 里 HTTP 入口正常、异步任务失败是完全可能的。

相关文章#

参考资料#