资讯动态

CompletableFuture与ForkJoinPool类加载器错位排查解决

发布时间:2026/10/9 3:54:25 来源:尧图企业网站定制
1. 先说结论问题不在并发而在类加载器的错位如果你在线上看到过类似这样的一段堆栈Caused by: java.lang.NoClassDefFoundError: com/xxx/yyy/ZZZ而它偏偏出现在CompletableFuture的异步任务里线程名又恰好是ForkJoinPool.commonPool-worker-1那大概率不是业务类真的不存在而是异步任务的线程上下文类加载器TCCL和你的应用类加载器对不上。这篇文章就从CompletableFuture默认线程池ForkJoinPool这个底层设计出发把类加载失败的前因后果、复现方式、排查手段和解决方案一次讲清楚。这个问题不是那种一上来就必现的bug。很多项目跑了几个月才在某个特定环境炸一次因为触发条件非常依赖类加载器的初始化时机。等你理解了背后的机制就会发现真正的元凶很明确ForkJoinPool.commonPool是JVM级共享单例它的工作线程在创建时并不会继承业务提交方的类加载器上下文。1.1 一句话描述这个坑CompletableFuture在使用supplyAsync()、runAsync()且不传线程池参数时默认会把任务丢给JVM级共享的ForkJoinPool.commonPool()。这个池子的工作线程在类加载器上不受提交方控制当任务内部依赖线程上下文类加载器去加载业务类、SPI实现类或者资源文件时就会因为类加载器错位而失败。注意我这里说的是依赖线程上下文类加载器的场景而不是所有代码都会出问题。直接new一个业务类通常不会走TCCL真正会踩雷的是框架代码、反射调用、资源读取以及显式通过TCCL加载类的写法。很多网上讨论把边界模糊了导致排查时走了弯路。1.2 谁最容易踩到从我的实际观察看下面这几类项目是重灾区Tomcat等Web容器里的多应用部署。一个JVM里同时存在多个WebappClassLoader每个应用各有一份私有类。某个应用先触发了commonPool的初始化另一个应用再提交异步任务时任务跑在前面应用的线程上类加载器错乱几乎是必然的。插件化架构。比如IDE插件、自研插件系统、热加载模块等。插件类由各自的PluginClassLoader加载而公共线程池的工作线程TCCL跟插件加载器八竿子打不着。动态加载jar包的项目。用URLClassLoader动态加载外部依赖是最典型的场景外部jar里的类只对自定义加载器可见对ForkJoinPool默认线程完全不可见。大量使用SPI机制的框架集成。JDBC驱动、日志实现、JSON序列化器、MyBatis类型处理器等往往靠TCCL反查实现类。只要异步任务里第一次触发SPI加载就有机会抛ClassNotFoundException。如果你正好在这些场景里用了CompletableFuture.runAsync(() - ...)这种无参版本强烈建议把后面几节看完尤其是第5章的解决方案可以直接抄。2. 为什么CompletableFuture默认落在ForkJoinPool上2.1 CompletableFuture的默认执行策略CompletableFuture的源码里写得很清楚public static U CompletableFutureU supplyAsync(SupplierU supplier) { return asyncSupplyStage(ASYNC_POOL, supplier); } public static CompletableFutureVoid runAsync(Runnable runnable) { return asyncRunStage(ASYNC_POOL, runnable); }这个ASYNC_POOL就是ForkJoinPool.commonPool()。也就是说只要你在调用时没单独传Executor任务就会进入这个共享的ForkJoinPool。类似地thenApplyAsync、thenAcceptAsync、thenComposeAsync这些后缀带Async的方法不传Executor时同样走commonPool。很多人对这句话不敏感觉得反正Java给我安排了线程池能用就行。问题恰恰出在这个默认安排上——它安排的是一个JVM全局共享的池子而不是为你的应用类加载器单独准备的池子。2.2 ForkJoinPool.commonPoolJVM级共享池的代价ForkJoinPool.commonPool()是Java 8引入的JVM级共享池设计初衷是给那些不需要自己管理线程资源的并行任务提供统一的执行环境。默认并行度是Runtime.getRuntime().availableProcessors() - 1线程是懒创建的第一次被使用时才初始化。它服务的对象远不止CompletableFuture。parallelStream()底层用的也是它Arrays.parallelSort、CompletableFuture无参版本、部分异步回调机制都会走到这个池子。换句话说commonPool是JVM内部一个公共的免费劳力池所有组件都可以往里丢任务。问题就在所有组件都可以往里丢任务这句话上。当你的应用A和应用B共存于同一个JVM时它们看到的是同一个commonPool同一个线程集合。应用A先触发了线程创建这些线程的上下文环境很可能就固定成了A的某个加载器状态应用B的任务再进来就跑在了别人家的线程上。2.3 默认选择没错错在环境假设ForkJoinPool这么设计本身没问题它隐含了一个前提当前JVM里只有一个应用类加载器或者任务代码不依赖TCCL。这个前提在简单的Java进程里成立但在Web容器、插件系统、动态加载器场景下完全失效。打个比方commonPool像一个火车站里的公共候车室谁都能进来等车。但每个应用有自己专属的行李寄存柜类加载器A应用只在A的寄存柜里取东西B应用只在自己的寄存柜里取东西。公共候车室的工作人员worker线程手里拿的是通用钥匙他只知道去系统级寄存柜取当然拿不到你存在应用专属柜子里的行李。所以问题的根源不是并发工具本身而是共享线程池自定义类加载器这个组合天然冲突。理解了这一点再看后面的类加载器机制就顺了。3. 类加载失败的根因线程上下文类加载器与ForkJoinPool的交互3.1 类加载器双亲委派先建立直觉Java类加载器采用双亲委派模型子加载器收到加载请求后先让父加载器尝试加载父加载器加载不到才轮到子加载器。这个模型的优点是不会重复加载核心类但副作用是父加载器能看到的类子加载器能看到子加载器里的类父加载器反而看不到。拿Tomcat举例AppClassLoader系统类加载器能加载jdk.*、java.*以及classpath下的类。WebappClassLoader专门加载web应用/WEB-INF/classes和/WEB-INF/lib下的类。你应用里的com.example.service.UserService只存在于WebappClassLoader里。如果有一段代码运行在AppClassLoader能看到的线程上它通过系统类加载器去找UserService必然找不到因为系统类加载器根本不认识这个子加载器才有的类。双亲委派模型本身不复杂复杂的是线程在里边扮演的角色。每个线程都有一个contextClassLoader属性这个属性就是给线程运行时的类加载视角用的。3.2 TCCL解决的是什么问题先说一个经典场景JDBC驱动加载。DriverManager位于java.base模块父加载器但它要加载的MySQL驱动实现类位于应用classpath子加载器。按照双亲委派父加载器不可能加载到子加载器里的类。那DriverManager怎么工作答案是借助线程上下文类加载器。DriverManager在执行getConnection()时会调用Thread.currentThread().getContextClassLoader()拿到这个线程的TCCL用它去加载驱动实现类。TCCL允许底层代码反查上层代码的类打破了双亲委派只能从上往下的限制。说白了TCCL就是线程自带的类加载器通行证。框架代码不认识你的业务类但它可以问线程要这张通行证用你的类加载器去加载。这也是为什么Thread.currentThread().getContextClassLoader()这个调用在生产代码里无处不在。3.3 commonPool工作线程的TCCL为什么不受我们控制现在问题来了ForkJoinPool.commonPool的工作线程它们的TCCL是什么根据我的实测经验不同JDK版本和不同触发时机下表现不完全一致但有一点是确定的——它不会根据你提交任务时的业务线程动态切换。常见情况是系统类加载器也可能是null但几乎不可能变成你业务应用里的WebappClassLoader或PluginClassLoader。有人可能会想commonPool不是懒加载吗我第一次提交任务时用它业务线程的TCCL难道不会传递给新创建的worker线程吗很遗憾ForkJoinPool在创建公共池时有自己的一套初始化逻辑。commonPool的线程不是普通new Thread()那样简单继承调用方的TCCL它是在静态初始化上下文中创建的跟提交任务的业务线程没有可靠的继承关系。更麻烦的是commonPool一旦初始化这些worker线程就固定下来。即使后来某个新应用启动新的类加载器创建了任务丢到commonPool上依然是老一批线程在跑它们的TCCL还是老样子。所以你会看到这样一个诡异现象同一个应用单独启动时正常跟其他应用部署在一起时偶发类加载失败。3.4 哪些代码会真实触发类加载失败不是所有异步任务里的代码都会踩雷。普通new关键字不会使用TCCL它使用定义当前类的类加载器。比如你在lambda里写UserService userService new UserService()这个lambda合成的类通常由你的业务类加载器加载运行new指令时也会找业务类加载器不会受线程TCCL影响。真正会因为TCCL错位而失败的操作基本是下面这几种显式调用Thread.currentThread().getContextClassLoader().loadClass(...)调用Class.forName(name, true, thread.getContextClassLoader())使用TCCL作为第三个参数通过Thread.currentThread().getContextClassLoader().getResource(...)读取资源文件框架内部依赖TCCL完成类加载例如Spring的ClassUtils.forName、MyBatis的别名注册、JDBC的DriverManager、JNDI查找、ServiceLoader加载SPI实现类CGLIB/动态代理生成时通过TCCL确定代理类的加载器如果你的异步任务里只是纯粹的算法计算、简单的集合操作大概率不会炸。但一旦任务里调用了上面这些敏感的框架代码基本一碰一个准。3.5 同类现象的不同表现同样是类加载器错位表现却可能五花八门。我整理了一个速查表现象典型含义ClassNotFoundException通过某个类加载器找不到类定义日志里会带这个异常全名NoClassDefFoundError类在编译/引用期存在运行期加载不到或该类的静态初始化失败后再次被引用getResource返回null资源文件存在但当前类加载器看不到不会报异常不过后续逻辑多半跟着错ClassCastException同一个类被两个类加载器各加载一次运行时两边类型不是同一个Class对象ExceptionInInitializerError后接NoClassDefFoundError类静态初始化第一次失败后续每次引用都会以NoClassDefFoundError形式抛出来这里尤其要注意NoClassDefFoundError它不一定是类不存在也可能是某个类的static{}块抛了异常。排查时先看完整堆栈别被前面的Error带偏。4. 复现与排查用最小Demo把问题钉死4.1 最小复现思路为了确认问题我习惯写一个最小复现Demo。思路很简单准备一个只在自定义类加载器里存在的类然后通过CompletableFuture.runAsync()默认提交任务任务里用TCCL去加载这个类。因为commonPool worker的TCCL不是自定义加载器这个加载动作必然失败。为了不让读者还要打包一个jar我们可以用URLClassLoader指向一个目录假装目录里有plugin-classes/com/plugin/OnlyInPlugin.class这个文件。4.2 Demo代码与执行效果import java.io.File; import java.net.URL; import java.net.URLClassLoader; import java.util.concurrent.CompletableFuture; public class ForkJoinTccldemo { public static void main(String[] args) throws Exception { // 假设这个目录下只有 PluginClassLoader 能加载到 OnlyInPlugin URL[] urls { new File(plugin-classes).toURI().toURL() }; URLClassLoader pluginLoader new URLClassLoader( urls, ClassLoader.getSystemClassLoader()); // 打印一下业务类加载器方便对比 System.out.println(pluginLoader pluginLoader); CompletableFutureVoid future CompletableFuture.runAsync(() - { ClassLoader cl Thread.currentThread().getContextClassLoader(); System.out.println(执行线程: Thread.currentThread().getName()); System.out.println(任务线程TCCL: cl); try { Class.forName(com.plugin.OnlyInPlugin, true, cl); System.out.println(类加载成功); } catch (ClassNotFoundException e) { throw new IllegalStateException(类加载失败, e); } }); future.join(); } }执行结果大致是pluginLoader java.net.URLClassLoader... 执行线程: ForkJoinPool.commonPool-worker-1 任务线程TCCL: jdk.internal.loader.ClassLoaders$AppClassLoader... Exception in thread main java.util.concurrent.CompletionException: java.lang.IllegalStateException: 类加载失败看到ForkJoinPool.commonPool-worker-1和AppClassLoader就可以确定问题跟TCCL错位有关。如果你把代码改成在任务开头手动设置TCCL为pluginLoader再跑一次结果会变成类加载成功。4.3 排查三板斧如果线上没有这么干净的复现环境我一般用三步定位第一板斧看线程名。异常堆栈里出现ForkJoinPool.commonPool-worker-*基本可以锁定是默认池在执行任务。第二板斧打印加载器。在任务的入口处输出两行日志一行是Thread.currentThread().getContextClassLoader()一行是YourBusinessClass.class.getClassLoader()。如果两者不同类加载失败的方向就明确了。第三板斧临时验证。把任务体包一层在进去之前设置Thread.currentThread().setContextClassLoader(YourBusinessClass.class.getClassLoader())结束后再恢复。如果这样改了之后问题消失那100%是TCCL的锅。CompletableFuture.runAsync(() - { ClassLoader original Thread.currentThread().getContextClassLoader(); try { Thread.currentThread().setContextClassLoader(ForkJoinTccldemo.class.getClassLoader()); // 原来的业务代码 } finally { Thread.currentThread().setContextClassLoader(original); } });这个方法既不改线程池也不用重启大范围应用是最快的验证手段。4.4 避免误判的关键细节有几个细节容易把排查方向带偏吃过亏的人应该深有体会。第一如果任务是串行跑正常、放到commonPool里就挂那大概率是类加载器问题但如果串行也挂先别急着怀疑线程池可能是依赖缺失。第二-verbose:class是很好的辅助工具。启动时加上-verbose:classJVM会打印每个类是从哪个jar、哪个类加载器加载的。搜一下报错的类名能看到它到底该由谁加载、实际由谁加载。第三NoClassDefFoundError不一定是找不到类定义也可能是静态初始化失败后的连锁反应。如果堆栈前面有ExceptionInInitializerError优先查static块里的代码。第四commonPool是懒初始化的所以同一个应用有时候能复现有时候不能。复现不稳定不是玄学十有八九是commonPool被谁提前初始化了或者JVM里多个类加载器互相干扰。5. 解决方案与最佳实践5.1 给CompletableFuture配上自定义线程池最推荐的方案是不让CompletableFuture使用默认池所有异步方法都显式传入一个自定义Executor。这样线程归属是明确的TCCL在提交时由你控制资源隔离也更干净。ExecutorService asyncExecutor new ThreadPoolExecutor( 4, 8, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue(200), new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(async-biz- seq.incrementAndGet()); t.setDaemon(true); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() ); CompletableFuture.supplyAsync(() - { // 业务逻辑 return result; }, asyncExecutor);注意几个细节线程命名很重要。出事的时候日志里看到async-biz-3一眼就能定位到自己的业务池比ForkJoinPool.commonPool-worker-5直观得多。拒绝策略谨慎选。CallerRunsPolicy会在队列满时让提交线程自己执行任务虽然不丢任务但调用方会被阻塞要能接受这个行为。如果线程池在Spring容器里管理别忘了在容器销毁时shutdown()避免资源泄漏。5.2 任务内显式绑定TCCL的标准写法有些时候你不想为每个任务都建线程池那就需要在任务内部手动传播TCCL。我个人的做法是写一个包装工具每次提交时把提交方线程的TCCL存下来任务执行前设置进去执行后恢复。import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.function.Supplier; public class AsyncTasks { public static T CompletableFutureT supplyAsyncWithTCCL( SupplierT supplier, Executor executor) { ClassLoader contextClassLoader Thread.currentThread().getContextClassLoader(); return CompletableFuture.supplyAsync(() - { Thread thread Thread.currentThread(); ClassLoader oldCl thread.getContextClassLoader(); try { if (contextClassLoader ! null oldCl ! contextClassLoader) { thread.setContextClassLoader(contextClassLoader); } return supplier.get(); } finally { thread.setContextClassLoader(oldCl); } }, executor); } }这里有个容易犯的错只设置不恢复。如果你在一个线程池里执行任务任务A改了TCCL没恢复任务B复用同一个线程时就会继承任务A的类加载器后续的类加载行为全乱。try-finally里的恢复不是可选项是必须项。5.3 针对SPI和资源加载的更彻底解法如果问题出在SPI机制上可以考虑在加载时显式指定类加载器绕开TCCLServiceLoader.load(MySpiInterface.class, MyBusinessClass.class.getClassLoader());读资源文件同理别用ClassLoader.getSystemResourceAsStream尽量用业务类所在的加载器去读MyBusinessClass.class.getClassLoader().getResourceAsStream(config.properties);很多框架支持自定义类加载器比如MyBatis可以给Configuration设置classLoaderSpring的ClassUtils也有重载方法。能显式指定的地方就不要依赖TCCL这个习惯能帮你少踩很多坑。5.4 为什么不建议全局替换commonPool配置网上有一种方案是改JVM系统属性全局替换commonPool的线程工厂或并行度-Djava.util.concurrent.ForkJoinPool.common.parallelism8这个方案我不推荐作为正式解法。原因有三影响面太大。parallelStream、所有CompletableFuture无参调用、JDK内部并行操作都会受到影响你只想修一个类加载问题结果把全JVM的并发行为都改了。设置时机苛刻。commonPool是懒加载系统属性必须在首次使用前生效一旦某个组件提前触发了初始化再改就晚了。类加载问题没有根除。即使换了线程工厂你依然要处理TCCL的传播逻辑复杂度没有降低反而加了一层全局黑盒。我见过有项目用这个方案临时止血后来还是不得不改回自定义线程池。建议把注意力放在让线程池归属业务方这个方向上。5.5 一个可以直接抄的异步任务包装类结合线程池和TCCL传播可以封装一个使用起来最省心的工具类。下面是完整的参考实现import java.util.concurrent.*; import java.util.function.Supplier; public class AsyncUtils { private AsyncUtils() {} public static T CompletableFutureT supplyAsync(SupplierT supplier) { return CompletableFuture.supplyAsync(supplier, globalExecutor()); } public static CompletableFutureVoid runAsync(Runnable runnable) { return CompletableFuture.runAsync(runnable, globalExecutor()); } public static T CompletableFutureT thenApplyAsync( CompletableFutureT future, T applyFn) { return future.thenApplyAsync(applyFn, globalExecutor()); } private static ExecutorService globalExecutor() { return ExecutorHolder.executor; } private static final class ExecutorHolder { static final ExecutorService executor new ThreadPoolExecutor( 4, 8, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue(500), new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(async-biz- seq.incrementAndGet()); t.setDaemon(true); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() ); } }使用时直接用AsyncUtils.runAsync(() - ...)所有任务都走业务池并且线程名清晰可查。6. 实测中的坑与经验汇总6.1 偶发性复现commonPool的初始化时机我遇到过一个真实案例应用A和应用B部署在同一个Tomcat里A先启动B后启动。A的某个业务在启动阶段就用到了CompletableFuture提前把commonPool的worker线程都创建好了。B启动后自己的业务代码也往commonPool里丢异步任务结果任务在A创建的线程上执行B的私有关一个都加载不到。这种问题最坑的地方在于单独部署B的时候完全正常跟A一起部署就偶发报错。如果你在排查这类问题可以先去看看这个JVM里到底谁先触发了commonPool的初始化可以用反射查看ForkJoinPool.commonPool()的线程名和TCCL通常能找出真相。6.2 thenApply系列并没有真正继承自定义线程池还有个容易踩的坑很多人以为给supplyAsync传了自定义线程池后面的thenApply就会自动跑在同一个池子里。实际不是。CompletableFuture的执行规则是非async方法thenApply、thenAccept等会在依赖stage完成时执行执行线程可能是完成前一个stage的线程也可能是调用线程取决于当时的状态。如果你的后续操作里还有thenApplyAsync并且没传Executor它又会回到commonPool去。所以正确做法是凡是Async后缀的方法都显式传同一个Executor如果不是Async后缀也别依赖它一定在哪个线程上要在线程池上下文里保证安全的代码最好所有阶段都走Async方法。6.3 线程池不可控是比类加载器更深的坑类加载失败是最显眼的问题但透过这个问题你还会看到第二个隐患commonPool是全局共享的它的并行度是CPU核数减一。假如某个服务用parallelStream跑了一堆CPU密集任务把commonPool的线程都占了另一个服务的CompletableFuture异步任务只能排队响应时间会明显劣化。之前有个项目就是这样生产环境偶发假死。查了半天发现是一个报表功能用了parallelStream做聚合把commonPool占满了业务异步任务都堵在队列里。这个问题的解决方案跟类加载问题同源不要让业务逻辑跟JVM全局池纠缠在一起建立属于自己的线程池并做好容量规划。6.4 代码审查时要重点盯的信号踩过几次坑之后我再看别人的代码就变得很敏感。下面这些信号只要出现我都会停下来多问一句调用CompletableFuture.supplyAsync()或runAsync()时没有传Executor直接用了默认池。使用了parallelStream()尤其是在有自定义类加载器的模块里。异步任务内部有Thread.currentThread().getContextClassLoader()、Class.forName()、getResource()这类操作。自定义线程工厂没有给线程命名出问题时日志里的线程名毫无辨识度。异步任务的异常被catch (Exception e) {}吞掉或者没有通过handle/exceptionally做兜底。任务内部修改了TCCL但没有在finally里恢复。这些信号单看任何一个都可能没事但组合在一起往往就是事故现场。最后说点个人体会。这种问题通常不会在第一次运行就暴露往往是应用部署到特定环境、或者某个类第一次被懒加载时才炸出来排查成本很高。与其事后救火不如一开始就把异步任务必须运行在由我掌控的线程池中当成默认约定。至少在我经手的项目里给CompletableFuture配专属线程池、并统一做TCCL传播是性价比最高的做法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑