文章

玄学现象:ClassNotFoundException

玄学现象:ClassNotFoundException

1. 现象

Dubbo 找不到 LoadBalance 扩展的异常堆栈

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。

进程类加载器结构图示如下:

本地启动与 SpringBoot FatJar 启动的类加载器层级对比

上图以JDK8为背景。JDK9起,扩展类加载器换成了平台类加载器。以下以 SpringClassLoader代指 LaunchedURLClassLoader。

spring-boot-loader模块会在启动时,将上述SpringClassLoader设置为main线程的Thread.contextClassLoader。

Launcher#launch 中为 main 线程设置 TCCL

org.springframework.boot.loader.Launcher#launch(java.lang.String[], java.lang.String, java.lang.ClassLoader)

Thread#setContextClassLoader 的源码

java.lang.Thread#setContextClassLoader

详细参考:

  1. 我真不信,这年头还有人能懂SpringBoot的ClassLoader加载机制

  2. Spring Boot FatJar类加载机制简要分析_Spring Boot_luojiahu_InfoQ写作社区

2.2 ContextClassLoader

ContextClassLoader,也就是线程上下文类加载器(TCCL),让基础库可以借助调用方提供的类加载器,访问自身加载器看不到的类和资源。

典型的使用场景是SPI机制,如JDK SPI。

ServiceLoader 借助 TCCL 加载 SPI 实现的示例

如果没有通过setContextClassLoader(ClassLoader cl)方法进行设置的话,普通Thread默认继承创建它的线程的ContextClassLoader( 注意,是父线程不是父类)

在新线程的构造方法中,获取Thread.currentThread.contextClassLoader作为自身的contextClassLoader

Thread#init 中从父线程继承 TCCL 的源码

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

SpringFactoriesLoader#loadFactoryNames 的源码

org.springframework.core.io.support.SpringFactoriesLoader#loadFactoryNames

这个方法使用传入的类加载器。以Spring 5.3.18为例,传入null时回退到SpringFactoriesLoader自身的类加载器。详见API说明。

2.3.3 Dubbo SPI:ExtensionLoader

ExtensionLoader#loadExtensionClasses 的源码

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线程池。

ForkJoinPool#commonPool 的源码

java.util.concurrent.ForkJoinPool#commonPool

其中存在一个 全局共用的线程池,通过ForkJoinPool.commonPool()获取,广泛地作为JDK标准库API的默认线程池使用。

目前已知的隐式使用ForkJoinPool.commonPool()的API有:

  1. collection.parallelStream(),并行流

  2. CompletableFuture.runAsync(Runnable),未指定线程池时通常使用commonPool。

在相关的旧实现中,commonPool工作线程可能继承创建者的ContextClassLoader。如果工作线程长期存活,就可能持续引用业务类加载器,阻碍该加载器及其加载的类回收,存在潜在内存泄漏风险。

典型的场景有使用自定义类加载器动态进行脚本(如groovy)编译执行等。

到了JDK17,commonPool的默认线程工厂使用ClassLoader.getSystemClassLoader()作为工作线程的ContextClassLoader,原来的继承关系也就变了。

ForkJoinWorkerThread 构造器中的 setContextClassLoader

java.util.concurrent.ForkJoinWorkerThread#ForkJoinWorkerThread(java.lang.ThreadGroup, java.util.concurrent.ForkJoinPool, boolean, boolean)

JDK8和JDK17代码对照如下:

JDK8 的三个 ForkJoinWorkerThread 构造器

JDK17 构造器改用 SystemClassLoader 作为 TCCL

参考:震惊!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);
    }
}

App2Bootstrap 中打印各线程 TCCL 的代码

2.5.1 JDK8运行结果

JDK8 运行输出:所有线程都是自定义 URLClassLoader

如上图,在这次JDK8运行中,commonPool工作线程继承了main线程设置的自定义ClassLoader (SpringClassLoader),并继续向testExecutor创建的工作线程传播。

2.5.2 JDK17运行结果

JDK17 运行输出:部分线程变成 SystemClassLoader

如上图,commonPool工作线程中的SystemClassLoader继续传播到了testExecutor的部分工作线程,而main线程创建的工作线程仍继承自定义加载器。后续通过TCCL加载类或扫描资源时,就可能因任务落到不同线程而出现诡异的“找不到”的问题。

2.5.3 总结

上述commonPool默认行为的变化,打断了我们原先预期的ContextClassLoader继承链。main线程里设置好了,并不代表异步初始化过程中创建的其他线程也会拿到同一个加载器,进一步产生潜在的、破坏性的、难以理解的影响。

具体表现包括但不限于如下:

  1. 莫名其妙的ClassNotFoundException

  2. SPI加载异常,包括但不限于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()的工作线程上,事情就不一样了:

  1. commonPool工作线程调用通知组件的start()。
  2. start()向后台线程池投递第一个任务,触发工作线程创建。
  3. 默认线程工厂创建的后台线程,继承了创建它的commonPool工作线程的ContextClassLoader。
  4. 在本文讨论的默认配置下,这个加载器是SystemClassLoader,而不是SpringClassLoader。
  5. 后台线程通过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. 梳理

至此,我们可以按时序做一个全盘梳理了:

  1. 服务进程启动,spring-boot-loader解析FatJar,创建SpringClassLoader。
  2. 启动器将main线程的ContextClassLoader设置为SpringClassLoader。
  3. 应用开始初始化组件,使用parallelStream()进行并行初始化。
  4. 一部分初始化任务由commonPool工作线程执行,其TCCL是SystemClassLoader。
  5. 初始化代码向组件自己的后台线程池投递任务,触发工作线程创建。
  6. 后台线程继承创建它的commonPool工作线程的TCCL。
  7. 后台任务通过TCCL读取应用资源、加载类或发现SPI实现。
  8. 由于使用了不符合当前打包方式预期的类加载器,找不到FatJar内部的目标资源或实现。

4. 总结

为规避JDK9+该破坏性更新带来的影响,建议各开发者不要在启动阶段(main线程中)显式或隐式使用ForkJoinPool.commonPool()线程池。

目前已知的通常会隐式使用ForkJoinPool.commonPool()的API有:

  1. collection.parallelStream(),并行流。
  2. CompletableFuture.runAsync(Runnable),未指定线程池使用CompletableFuture。

升级JDK版本时,请在测试环境充分测试、验证。

5. 参考

  1. 我真不信,这年头还有人能懂SpringBoot的ClassLoader加载机制

  2. Spring Boot FatJar类加载机制简要分析_Spring Boot_luojiahu_InfoQ写作社区

  3. 惊!JDK7和JDK8中关于ForkJoinPool的内存泄漏BUG - 掘金

  4. No such extension org.apache.dubbo.rpc.cluster.LoadBalance by name random #5829

  5. ForkJoinPool/CompletableFuture/Stream.parallel中使用OpenFeign:解决Clss Not Found

  6. CompletableFuture.supplyAsync在jdk11下的坑-CSDN博客

  7. jdk11下ClassLoader的踩坑经历_jdk11 继承 forkjoinpool 编译不通过-CSDN博客

  8. 记使用CompletableFuture时,关于ClassLoader引起的问题 - 掘金

  9. Different classloaders in jdk8 and jdk11

  10. JDK17 ForkJoinPool:commonPool默认线程工厂

  11. JDK17 Thread:ContextClassLoader继承规则

本文由作者按照 CC BY 4.0 进行授权