Spring Boot 异步任务 ClassLoader 踩坑实录:ClassNotFoundException 的诡异真相

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 下的类。

但是:

  1. ScheduledExecutorService 创建新线程时,默认继承父线程的类加载器,但如果你用自定义 ThreadFactory,新线程的 contextClassLoader 可能是系统类加载器
  2. 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,以下场景也可能因为类加载器问题报错:

  1. SPI 机制 :ServiceLoader.load() 依赖 contextClassLoader
  2. JNDI 查找 :InitialContext 的类加载
  3. 动态代理:CGLIB、Javassist 生成代理类时
  4. 反射加载类 :Class.forName() 不指定类加载器时

如果你在异步线程里遇到诡异的 ClassNotFoundException 或 NoClassDefFoundError,先检查类加载器对不对!

总结

  1. Spring Boot 可执行 jar 用的是自定义 LaunchedURLClassLoader
  2. 异步线程池(特别是 ForkJoinPool.commonPool())的线程可能在项目启动时就创建好了,类加载器不对
  3. 解决方案:创建线程时手动设置 contextClassLoader,或在异步任务内部设置回来
  4. 建议封装工具类,避免到处写重复代码

希望这篇文章能帮你避开这个坑。如果对你有帮助,点个赞再走吧!


作者按:这是实际项目中踩过的坑,代码都经过验证。如果你也遇到了类似问题,欢迎在评论区交流。

相关推荐
aiks_yuan16 分钟前
Java 正则表达式很慢怎么办:Pattern 预编译只快 2~5 倍,真正卡死线程的是回溯(JDK 8、11、17 实测)
java
用户37215742613518 分钟前
C# 填写 PDF 交互式表单:文本框、复选框、单选按钮和下拉列表
java
IT枫斗者枫哥21 分钟前
Java 退款请求超时,先查原单还是重试?
java·后端
特立独行的猫A24 分钟前
我用仓颉写了个多平台发文工具:一份 Markdown,一条命令发六个平台
java·后端·python
ikgy38 分钟前
JAVA EE:1.计算机是如何工作的
java·java-ee
.痞老板.1 小时前
java ee--计算机是如何工作的
java·开发语言·java-ee
青梅味猪大肠2 小时前
【操作系统-60】临近适应算法
java·linux·开发语言·嵌入式硬件·算法·操作系统
CoderYanger2 小时前
Java EE 进阶:8.MyBatis 操作数据库(入门)(第一弹)
java·数据库·spring·面试·职场和发展·java-ee·mybatis
程序员沉梦听雨2 小时前
【搜索引擎】Kibana使用指南
java·后端·学习笔记