玄学现象:ClassNotFoundException
1. 现象
1
java.lang.IllegalStateException: No such extension org.apache.dubbo.rpc.cluster.LoadBalance by name random
陆续收到升级到高版本JDK(>8)的服务,在服务发布、重启场景下出现上述诡异的异常。
2. 背景
要完整的解释上述现象,我们需要先对齐一些旁路的背景。
2.1 SpringBoot类加载器拓扑
SpringBoot使用可执行FatJar启动时,应用类和依赖Jar放在包内,由启动器创建的自定义类加载器负责加载。SpringBoot 2.x中,这个加载器就是LaunchedURLClassLoader。
进程类加载器结构图示如下:
上图以JDK8为背景。JDK9起,扩展类加载器换成了平台类加载器。以下以 SpringClassLoader代指 LaunchedURLClassLoader。
spring-boot-loader模块会在启动时,将上述SpringClassLoader设置为main线程的Thread.contextClassLoader。
org.springframework.boot.loader.Launcher#launch(java.lang.String[], java.lang.String, java.lang.ClassLoader)
java.lang.Thread#setContextClassLoader
详细参考:
2.2 ContextClassLoader
ContextClassLoader,也就是线程上下文类加载器(TCCL),让基础库可以借助调用方提供的类加载器,访问自身加载器看不到的类和资源。
典型的使用场景是SPI机制,如JDK SPI。
如果没有通过setContextClassLoader(ClassLoader cl)方法进行设置的话,普通Thread默认继承创建它的线程的ContextClassLoader( 注意,是父线程不是父类)
在新线程的构造方法中,获取
Thread.currentThread.contextClassLoader作为自身的contextClassLoader
java.lang.Thread#init(java.lang.ThreadGroup, java.lang.Runnable, java.lang.String, long, java.security.AccessControlContext, boolean)
具体到SpringBoot进程,main线程的ContextClassLoader已经被启动器设置为SpringClassLoader。如果后续普通线程沿用默认继承行为,这个加载器就可以继续向下传播。
2.3 SPI (Service Provider Interface)机制
SPI机制是Java生态广泛使用的一种IOC或者说控制反转模式。用于将能力的声明和能力的实现解耦,提高代码的拓展性。
该模式在不同的框架下有不同的具体实现,广泛使用的实现有:
2.3.1 JDK SPI:ServiceLoader
如上述,ServiceLoader.load(Class)默认使用当前线程的TCCL,也可以通过重载方法显式传入类加载器。
2.3.2 Spring SPI:SpringFactoriesLoader
org.springframework.core.io.support.SpringFactoriesLoader#loadFactoryNames
这个方法使用传入的类加载器。以Spring 5.3.18为例,传入null时回退到SpringFactoriesLoader自身的类加载器。详见API说明。
2.3.3 Dubbo SPI:ExtensionLoader
org.apache.dubbo.common.extension.ExtensionLoader#loadExtensionClasses
以Dubbo 2.7.3为例,ExtensionLoader通过ClassUtils.getClassLoader()优先获取TCCL,获取不到时再回退。详见该版本源码。
SPI需要通过类加载器扫描、加载实现类。对于使用TCCL的调用路径,如果这个加载器看不到目标内容,就可能出现扫描、加载异常。
2.4 ForkJoinPool.common线程池
J.U.C包中,Doug Lea大师一手打造了实现map-reduce(分治-聚合)语义的ForkJoinPool线程池。
java.util.concurrent.ForkJoinPool#commonPool
其中存在一个 全局共用的线程池,通过ForkJoinPool.commonPool()获取,广泛地作为JDK标准库API的默认线程池使用。
目前已知的隐式使用ForkJoinPool.commonPool()的API有:
collection.parallelStream(),并行流CompletableFuture.runAsync(Runnable),未指定线程池时通常使用commonPool。
在相关的旧实现中,commonPool工作线程可能继承创建者的ContextClassLoader。如果工作线程长期存活,就可能持续引用业务类加载器,阻碍该加载器及其加载的类回收,存在潜在内存泄漏风险。
典型的场景有使用自定义类加载器动态进行脚本(如groovy)编译执行等。
到了JDK17,commonPool的默认线程工厂使用ClassLoader.getSystemClassLoader()作为工作线程的ContextClassLoader,原来的继承关系也就变了。
java.util.concurrent.ForkJoinWorkerThread#ForkJoinWorkerThread(java.lang.ThreadGroup, java.util.concurrent.ForkJoinPool, boolean, boolean)
JDK8和JDK17代码对照如下:
参考:震惊!JDK7和JDK8中关于ForkJoinPool的内存泄漏BUG - 掘金
2.5 ForkJoinPool.ContextClassLoader传播
如上述,我们知道了JDK17默认commonPool工作线程使用ClassLoader.getSystemClassLoader()作为ContextClassLoader。
这个逻辑在一般场景下不会产生影响,但是如果commonPool工作线程执行Task时,在Task中创建了普通子线程,那么这个ContextClassLoader就会继续向下传播,使这些新线程继承SystemClassLoader,而不是SpringBoot在main线程中设置的SpringClassLoader。
下面创建一个自定义URLClassLoader,通过打印各线程的TCCL,观察它的传播过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class App2Bootstrap {
private static Executor testExecutor = Executors.newFixedThreadPool(3);
public static void main(String[] args) {
//main线程实例
Thread mainThread = Thread.currentThread();
ClassLoader contextClassLoader = mainThread.getContextClassLoader();
//IDE本地启动时,模拟SpringBoot设置ContextClassLoader
URLClassLoader springClassLoader = new URLClassLoader(new URL[]{}, contextClassLoader);
mainThread.setContextClassLoader(springClassLoader);
//main线程状态
currentThreadAndContextClassLoader("111");
Lists.newArrayList("a","b","c")
.parallelStream()//隐式使用ForkJoinPool
.forEach(it -> {
//main线程或commonPool工作线程状态
currentThreadAndContextClassLoader("222");
testExecutor.execute(() -> { //投递任务时触发testExecutor创建工作线程
//后台工作线程状态
currentThreadAndContextClassLoader("333");
System.out.println(it);
});
});
}
private static void currentThreadAndContextClassLoader(String tag) {
Thread thread = Thread.currentThread();
ClassLoader contextClassLoader = thread.getContextClassLoader();
System.out.println(tag + ",线程名:" + thread.getName() + ",classLoader:" + contextClassLoader);
}
}
2.5.1 JDK8运行结果
如上图,在这次JDK8运行中,commonPool工作线程继承了main线程设置的自定义ClassLoader (SpringClassLoader),并继续向testExecutor创建的工作线程传播。
2.5.2 JDK17运行结果
如上图,commonPool工作线程中的SystemClassLoader继续传播到了testExecutor的部分工作线程,而main线程创建的工作线程仍继承自定义加载器。后续通过TCCL加载类或扫描资源时,就可能因任务落到不同线程而出现诡异的“找不到”的问题。
2.5.3 总结
上述commonPool默认行为的变化,打断了我们原先预期的ContextClassLoader继承链。main线程里设置好了,并不代表异步初始化过程中创建的其他线程也会拿到同一个加载器,进一步产生潜在的、破坏性的、难以理解的影响。
具体表现包括但不限于如下:
莫名其妙的
ClassNotFoundExceptionSPI加载异常,包括但不限于
JDK SPI、Dubbo SPI
2.6 启动提速,怎么影响到了后台任务?
前面的例子有点抽象,我们换一个常见的场景。
假设一个应用启动时,需要初始化缓存、通知、报表等几个组件。原来是main线程挨个初始化,后来发现启动有点慢,于是改成了并行初始化:
1
2
// initializers中的每个Runnable负责启动一个组件
initializers.parallelStream().forEach(Runnable::run);
看起来只是把串行改成并行,组件内部的代码又没动,能有什么问题?
我们看看其中的通知组件。它维护了一个后台线程池,启动时会投递一个任务,从classpath加载通知模板,为后续发送通知做准备。核心代码大概是这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class NotificationComponent {
private final ExecutorService executor = Executors.newFixedThreadPool(2);
void start() {
executor.execute(() -> {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
URL template = loader.getResource("notification-template.txt");
if (template == null) {
throw new IllegalStateException("找不到通知模板");
}
// 读取模板,完成后续初始化
});
}
}
需要注意的是,newFixedThreadPool(2)创建线程池对象时,并不会立即创建两个工作线程。上面的execute()第一次投递任务时,才会触发工作线程创建。也就是说,线程池对象在哪里创建,和工作线程在哪里创建,是两回事。
原来由main线程调用start(),后台线程继承main线程的ContextClassLoader。在本文的SpringBoot FatJar场景下,它就是可以访问应用类和资源的SpringClassLoader。
改成并行初始化后,如果通知组件的start()恰好被分配到了ForkJoinPool.commonPool()的工作线程上,事情就不一样了:
- commonPool工作线程调用通知组件的
start()。 start()向后台线程池投递第一个任务,触发工作线程创建。- 默认线程工厂创建的后台线程,继承了创建它的commonPool工作线程的
ContextClassLoader。 - 在本文讨论的默认配置下,这个加载器是
SystemClassLoader,而不是SpringClassLoader。 - 后台线程通过TCCL查找FatJar内部的通知模板,结果找不到。
flowchart TD
M[main线程:TCCL为SpringClassLoader] -->|并行初始化组件| F[commonPool工作线程:TCCL为SystemClassLoader]
F -->|首次投递任务,创建后台线程| W[通知后台线程:继承SystemClassLoader]
W -->|通过TCCL查找应用资源| R[找不到FatJar内部的通知模板]
模板明明就在包里,串行启动时也好好的,怎么换成并行就找不到了?变的是执行初始化的线程,以及它创建的后台线程所继承的类加载器。
即使初始化任务已经结束,后台线程仍可能继续带着继承来的TCCL执行后续任务。
而且,并行流的任务也可能由调用它的main线程参与执行。如果某个组件恰好由main线程初始化,它创建的后台线程又可能是正常的。这就解释了为什么同样的代码,有些组件正常、有些组件异常,看起来很“玄学”。
通知模板只是一个更直观的例子。如果后台任务做的不是读取模板,而是通过TCCL加载类、扫描SPI实现,同样可能表现为ClassNotFoundException或者找不到扩展实现。
3. 梳理
至此,我们可以按时序做一个全盘梳理了:
- 服务进程启动,
spring-boot-loader解析FatJar,创建SpringClassLoader。 - 启动器将main线程的
ContextClassLoader设置为SpringClassLoader。 - 应用开始初始化组件,使用
parallelStream()进行并行初始化。 - 一部分初始化任务由commonPool工作线程执行,其TCCL是
SystemClassLoader。 - 初始化代码向组件自己的后台线程池投递任务,触发工作线程创建。
- 后台线程继承创建它的commonPool工作线程的TCCL。
- 后台任务通过TCCL读取应用资源、加载类或发现SPI实现。
- 由于使用了不符合当前打包方式预期的类加载器,找不到FatJar内部的目标资源或实现。
4. 总结
为规避JDK9+该破坏性更新带来的影响,建议各开发者不要在启动阶段(main线程中)显式或隐式使用ForkJoinPool.commonPool()线程池。
目前已知的通常会隐式使用ForkJoinPool.commonPool()的API有:
collection.parallelStream(),并行流。CompletableFuture.runAsync(Runnable),未指定线程池使用CompletableFuture。
升级JDK版本时,请在测试环境充分测试、验证。














