Spring Boot 异步任务 ClassLoader 踩坑实录:ClassNotFoundException 的诡异真相
前言
最近在项目里遇到一个诡异的问题:代码在本地 IDEA 跑得欢天喜地,一打包成 jar 扔到服务器上就报 ClassNotFoundException。更邪门的是,报错的类明明在依赖里,主线程调用完全正常,一到异步线程就炸了。
折腾了半天终于搞明白,今天把这个坑分享给大家,希望能帮你省下几根头发。
问题现象
项目用的是 Spring Boot 2.7 + OpenFeign + Spring Cloud LoadBalancer。有个需求是异步调用第三方接口,我就用了两种方式:
java
// 方式一:ScheduledExecutorService
ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, r -> {
Thread t = new Thread(r);
t.setName("async-task-" + t.getId());
return t;
});
// 方式二:CompletableFuture.runAsync()
CompletableFuture.runAsync(() -> {
// 调用 Feign 接口
thirdPartyClient.doSomething();
});
本地 IDEA 运行,完美!打包成 jar 部署到服务器,报错:
bash
java.lang.NoClassDefFoundError: org/springframework/boot/conditionals/OnPropertyCondition
at org.springframework.boot.autoconfigure.LoadBalancerAutoConfiguration$$EnhancerBySpringCGLIB$$xxx
...
Caused by: java.lang.ClassNotFoundException: org.springframework.boot.conditionals.OnPropertyCondition
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass
排查过程
第一次怀疑:依赖缺失?
第一反应是打包的时候依赖没打进去。检查 pom.xml,spring-boot-autoconfigure 明明在啊。用 mvn dependency:tree 看了一遍,没冲突。
第二次怀疑:版本冲突?
以为是 Spring Boot 版本和 Spring Cloud 版本不匹配。对照官方文档查了一遍,版本是对的。
第三次怀疑:类加载器问题?
冷静下来想了想,为什么主线程没问题,异步线程就报错?打印一下类加载器看看:
java
// 主线程
System.out.println(Thread.currentThread().getContextClassLoader());
// 输出:LaunchedURLClassLoader
// 异步线程(ScheduledExecutorService)
executor.execute(() -> {
System.out.println(Thread.currentThread().getContextClassLoader());
// 输出:AppClassLoader
});
// 异步线程(CompletableFuture.runAsync)
CompletableFuture.runAsync(() -> {
System.out.println(Thread.currentThread().getContextClassLoader());
// 输出:ForkJoinPool.commonPool 的类加载器,也是 AppClassLoader
});
找到问题了! 异步线程的 contextClassLoader 不是 LaunchedURLClassLoader,而是系统的 AppClassLoader。
根本原因
Spring Boot 打包成可执行 jar 后,用的是自定义的 LaunchedURLClassLoader 来加载 BOOT-INF/classes 和 BOOT-INF/lib 下的类。
但是:
- ScheduledExecutorService 创建新线程时,默认继承父线程的类加载器,但如果你用自定义
ThreadFactory,新线程的contextClassLoader可能是系统类加载器 - CompletableFuture.runAsync() 默认用
ForkJoinPool.commonPool(),这个线程池的线程在项目启动时就已经创建好了,它们的contextClassLoader是系统类加载器,不是 Spring Boot 的LaunchedURLClassLoader
Spring Cloud LoadBalancer 在创建子上下文时,会用 SpringFactoriesLoader.loadFactoryNames() 加载配置类,这个过程依赖线程的 contextClassLoader。如果类加载器不对,就找不到 OnPropertyCondition 等类,直接报 ClassNotFoundException。
解决方案
方案一:手动设置 contextClassLoader(推荐)
创建线程时,显式设置 contextClassLoader 为当前类的类加载器:
java
ScheduledExecutorService executor = Executors.newScheduledThreadPool(4, r -> {
Thread t = new Thread(r);
t.setName("async-task-" + t.getId());
// 关键:设置正确的类加载器
t.setContextClassLoader(this.getClass().getClassLoader());
return t;
});
方案二:在异步任务内部设置
如果不想改 ThreadFactory,可以在每个异步任务开始时设置回来:
java
// 先捕获当前的类加载器
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
CompletableFuture.runAsync(() -> {
// 在异步线程里设置回来
Thread.currentThread().setContextClassLoader(contextClassLoader);
// 你的业务代码
thirdPartyClient.doSomething();
});
方案三:封装工具类
如果项目里异步调用很多,建议封装一下:
java
public class AsyncUtils {
public static CompletableFuture<Void> runAsyncWithContext(Runnable task) {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
return CompletableFuture.runAsync(() -> {
Thread.currentThread().setContextClassLoader(contextClassLoader);
task.run();
});
}
public static <T> CompletableFuture<T> supplyAsyncWithContext(Supplier<T> supplier) {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
return CompletableFuture.supplyAsync(() -> {
Thread.currentThread().setContextClassLoader(contextClassLoader);
return supplier.get();
});
}
}
// 使用
AsyncUtils.runAsyncWithContext(() -> {
thirdPartyClient.doSomething();
});
其他可能踩坑的场景
除了 Feign + LoadBalancer,以下场景也可能因为类加载器问题报错:
- SPI 机制 :
ServiceLoader.load()依赖contextClassLoader - JNDI 查找 :
InitialContext的类加载 - 动态代理:CGLIB、Javassist 生成代理类时
- 反射加载类 :
Class.forName()不指定类加载器时
如果你在异步线程里遇到诡异的 ClassNotFoundException 或 NoClassDefFoundError,先检查类加载器对不对!
总结
- Spring Boot 可执行 jar 用的是自定义
LaunchedURLClassLoader - 异步线程池(特别是
ForkJoinPool.commonPool())的线程可能在项目启动时就创建好了,类加载器不对 - 解决方案:创建线程时手动设置
contextClassLoader,或在异步任务内部设置回来 - 建议封装工具类,避免到处写重复代码
希望这篇文章能帮你避开这个坑。如果对你有帮助,点个赞再走吧!
作者按:这是实际项目中踩过的坑,代码都经过验证。如果你也遇到了类似问题,欢迎在评论区交流。