Spring Boot fat jar 下 Class.forName 只在部分线程上找不到类:打包形态和线程来源各管一关
目录
按类名加载类的代码到处都是,而且大多不问「谁加载了我」,只问当前线程的 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.base(String 在这里,它的 loader 打出来是 null),PlatformClassLoader 管 java.sql 这些平台模块(DriverManager、java.sql.Driver 都在这一层),应用自己的类和它们的依赖挂在最底下。真实的 Spring Boot fat jar 跑起来是这条(./run.sh repro 启动时那几行自检日志打出来的,Boot 3.0.5):
搜 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 看不看得见依赖。
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));
}
}
那个 true 是 useSystemClassLoader,构造器里直接 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 入口正常、异步任务失败是完全可能的。
相关文章#
参考资料#
- 全部实测场景:meirongdev/kafka-tccl-issue(
./run.sh threads/repro/flat/extract) - ForkJoinPool(Java 25 javadoc,common pool 的
ThreadFactory) - Thread(Java 25 javadoc,TCCL 的设置与继承) / ClassLoader(loadClass 的 parent delegation 顺序、built-in class loaders 与 application class loader 的叫法)
- JVM 规范 第 5 章 加载、链接与初始化:§5.3 加载、§5.4.3.1 解析符号引用用的是定义方的 class loader、§5.5 初始化
- JDK 源码引自 Temurin 25.0.2 自带的
lib/src.zip,其中ForkJoinWorkerThread和Thread两处又在 Temurin 21.0.11 的src.zip里对过:java.base/java/util/concurrent/ForkJoinPool.java、java.base/java/util/concurrent/ForkJoinWorkerThread.java、java.base/java/lang/Thread.java、java.base/java/util/ServiceLoader.java、java.sql/java/sql/DriverManager.java - Jackson
TypeFactory.findClass(jackson-databind 2.17) - Spring Boot 可执行 jar 规范 — Nested JARs / Efficient deployments(jarmode=tools extract)