按类名加载类的代码到处都是,而且大多不问「谁加载了我」,只问当前线程的 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)
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
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 设过,就是设的那个;没设过,就照抄创建它的那条线程。

于是一次「按类名加载」要闯两关:

flowchart TD A["库代码:Class.forName(name, true, TCCL)
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));
    }
}

那个 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

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。等我手上有真实场景再补。

相关文章#

参考资料#