Spring Boot 的打包形态和线程 TCCL 决定 Class.forName 能不能找到类
目录
按类名加载类的代码到处都是,而且大多不问「谁加载了我」,只问当前线程的 TCCL(thread context class loader):ServiceLoader.load(Class) 的第一行就是取 TCCL(JDBC 驱动的自动注册走的正是它),Jackson 的 TypeFactory.findClass 注释写着 two-phase lookup: first using context ClassLoader,Kafka 的 ConfigDef 校验 Type.CLASS 默认值时也一样。这类代码在 IDE 里通常不出错,打成 fat jar 就可能报找不到类——我上次撞上的是 Kafka producer 构造失败(Kafka producer 构造失败:fat jar 下 commonPool 抢走了第一次类初始化),当时只查了自己踩到的那一条路径。
这次把这两件事分开测:打包形态决定 application 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。没测的三种形态在结尾列了。
class loader 的委派链和 TCCL#
JVM 里的 class loader 是一条链,每一层有自己的搜索路径。真实的 Spring Boot fat jar 跑起来是这条(./run.sh repro 的启动横幅打出来的,Boot 3.0.5):
委派链 : LaunchedURLClassLoader -> AppClassLoader -> PlatformClassLoader -> bootstrap(null)
搜 BOOT-INF/classes/ 与 BOOT-INF/lib/*.jar
业务类和依赖都在这一层"] A["AppClassLoader(system class loader)
java -jar 时只搜那个 jar 的根
fat jar 下这里只有 Boot 的 loader 类"] P["PlatformClassLoader
JDK 里的非核心模块"] B["bootstrap(null)
java.base"] L ==>|"① 请求先交给 parent"| A A ==>|"①"| P P ==>|"①"| B B -.->|"② 都没有,才回落到各自的搜索路径"| L style A stroke:#c00,stroke-width:2px
加载一个类时,请求先一路向上交给 parent,谁都没有,才回落下来由各自的搜索路径去找(ClassLoader.loadClass 的顺序是 findLoadedClass → parent → 自己的 findClass)。这条通道是单向的:child 用得上 parent 的类,parent 看不见只有 child 才有的类。fat jar 把依赖放进 BOOT-INF/lib/,只有链最底下那层读得到,AppClassLoader 作为它的 parent 够不着——图里标红的就是它。
TCCL 就是给这条单向通道留的口子:库代码改问 Thread.currentThread().getContextClassLoader()。而一条线程的 TCCL 是哪个 class loader,这条线程自己说了不算——建它的时候 ThreadFactory 设过,就是设的那个;没设过,就照抄创建它的那条线程。
于是一次「按类名加载」要闯两关:
ServiceLoader / ConfigDef / Jackson 都走这条"] --> B{"这条线程的 TCCL
是谁给的?"} B -->|"继承创建它的线程"| C["多半是 application class loader"] B -->|"ThreadFactory 硬设"| D["可能是 system class loader"] C --> E{"这个 class loader
看得见依赖吗?"} D --> E E -->|"fat jar 里的 BOOT-INF/lib
只有 Boot 的 class loader 读得到"| F["看不见 -> ClassNotFoundException"] E -->|"平铺 classpath / extract 布局"| G["看得见 -> 正常"] style F stroke:#c00,stroke-width:2px
下面两节分别把这两个菱形填上实测值。
打包形态决定谁是 application class loader#
三种跑法,同一份代码,./run.sh repro(fat jar)、flat(平铺 classpath)、extract(Boot 3.3+ 的 jarmode=tools extract 布局)各跑一次,应用启动横幅里打的是 application class loader 和 commonPool 线程能不能看见依赖:
| 跑法 | 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 设成它,然后逐个探针看每种线程拿到什么。
下面两列值只有两种,名字有点像,别看混: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 那部分除外) |
否 |
三行「否」值得单独说,它们各自的原因并不相同。
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。
platform thread pool 和 virtual thread 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. 前缀,其余照抄。)
最后两行是这次实测里最值得记的一条。两个池都由 main 建,任务都由那条坏 TCCL 的 worker 提交,结果相反:
- platform thread pool 的 worker 是建一次的。它在 main 第一次提交时就创建好,TCCL 当场定型,之后谁来提交都不影响;
- virtual thread pool 是每个任务新建一条线程。新线程继承的是提交任务的那条线程,所以坏 TCCL 顺着提交路径进来了。
这条差异不影响上一篇那个预热修复:类初始化只发生一次,在正确的线程上预热成功之后,之后哪条线程来用都无所谓。它影响的是另一半——那些每次调用都按名字解析类的地方,比如运行时才调的 ServiceLoader.load()、按 payload 里的类名找类的 Jackson。这类代码在 platform thread pool 上还有「worker 建一次」兜着,换成 virtual thread pool 就每个任务重赌一次提交者的 TCCL。要往 virtual thread 迁移,钉住 ThreadFactory(下面 C 段)就不再是纵深防御,而是唯一稳的那层。
坏 TCCL 传得下去,但留不住#
传染路径上面已经看到了:commonPool worker 建的任何线程——platform thread、virtual thread、线程池里的 worker——都带着它那份 system class loader。反过来,想在 commonPool 的任务里临时改回来,改得动,但留不住:
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 是稳的(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,看得见依赖:
# 请求打进来之后 SendService 记的那一行,同样去掉了时间戳和日志前缀
[http-nio-18080-exec-1] >>> [HTTP-REQUEST] 第 3 轮发送(3 个 Confluent topic + 1 个 StringSerializer 对照)
| 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,它不需要 Kafka,也不需要 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 空闲时会被还原;
- 这次没测的三种形态:war 部署到外置 Tomcat(
WebappClassLoader是 child-first,结论大概率不同)、devtools 的 restart class loader、GraalVM native image。等我手上有真实场景再补。
相关文章#
参考资料#
- 全部实测场景: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 顺序)
- JDK 源码引自 Temurin 25.0.2 自带的
lib/src.zip:java.base/java/util/concurrent/ForkJoinPool.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)