深入分析:Spring Boot 中 ForkJoinPool 导致 ClassPathResource 找不到资源的排查与解决

1. 问题现象

在开发一个基于 Spring Boot 的复杂业务系统时,我们通常会利用 CompletableFuture 来执行异步任务,以提升应用的吞吐量和响应速度。在一个需要异步调用 Redis Lua 脚本的场景中,我们遇到了一个只在测试和生产环境(通过 java -jar 启动)才会出现的 FileNotFoundException

业务场景:一个自动化工作流需要异步执行一个存储在类路径下的 Lua 脚本(scripts/update_device_shadow.lua)来原子性地更新 Redis 中的数据。

核心代码逻辑

1
2
3
4
5
6
7
8
9
// 使用 DefaultRedisScript 来封装 Lua 脚本
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/update_device_shadow.lua")));
redisScript.setResultType(Long.class);

// 通过 CompletableFuture.runAsync() 异步执行
CompletableFuture.runAsync(() -> {
redisTemplate.execute(redisScript, keys, args);
});

错误日志: 在通过 java -jar 命令启动应用后,上述代码会抛出以下异常。

1
2
3
4
5
6
7
8
9
10
2025-08-06 17:59:07.831 [ForkJoinPool.commonPool-worker-1] ERROR c.d.b.e.s.i.EventProcessFlowServiceImpl - 流程步骤执行失败...
org.springframework.data.redis.core.script.ScriptingException: Error reading script text; nested exception is java.io.FileNotFoundException: class path resource [scripts/update_device_shadow.lua] cannot be opened because it does not exist
at org.springframework.data.redis.core.script.DefaultRedisScript.getScriptAsString(DefaultRedisScript.java:113)
// ... 中间业务调用链
at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1804)
at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinWorkerThread.java:165)
Caused by: java.io.FileNotFoundException: class path resource [scripts/update_device_shadow.lua] cannot be opened because it does not exist
at org.springframework.core.io.ClassPathResource.getInputStream(ClassPathResource.java:203)
at org.springframework.data.redis.core.script.DefaultRedisScript.getScriptAsString(DefaultRedisScript.java:111)
... 39 common frames omitted

日志清晰地指出了三个关键点:

  1. 异常: FileNotFoundException,类路径资源 scripts/update_device_shadow.lua 不存在。
  2. 执行环境: 错误发生在名为 ForkJoinPool.commonPool-worker-1 的线程中。
  3. 触发原因: 异步任务由 CompletableFuture$AsyncRun 执行。

2. 核心原因分析

此问题的根源在于 Java 类加载器(ClassLoader)机制Spring Boot Fat JAR 运行模式之间的冲突。

2.1 Spring Boot Fat JAR 的类加载机制

当使用 spring-boot-maven-plugin 将应用打包成一个可执行的 Fat JAR 时,应用的类和资源文件被打包进 BOOT-INF/classes/,依赖库被打包进 BOOT-INF/lib/。标准的 JVM 类加载器无法直接读取这种嵌套结构。

为此,Spring Boot 提供了一个特殊的类加载器——org.springframework.boot.loader.LaunchedURLClassLoader。当应用通过 java -jar 启动时,LaunchedURLClassLoader 会被设置为主线程的线程上下文类加载器 (Thread Context ClassLoader)。它知道如何从 BOOT-INF/ 目录中正确地加载类和资源。

2.2 ForkJoinPool.commonPool 的类加载器

CompletableFuture.runAsync(Runnable runnable) 在不指定 Executor 的情况下,默认使用 ForkJoinPool.commonPool()。这是一个 JVM 级别的、全局共享的静态线程池。它的创建和管理独立于 Spring 应用上下文。

因此,commonPool 中的工作线程不会继承 Spring Boot 主线程的 LaunchedURLClassLoader。这些线程的上下文类加载器是 JVM 默认的 AppClassLoader(或称 SystemClassLoader)。

2.3 根源:类加载器的冲突

当我们的业务逻辑在 ForkJoinPool.commonPool-worker-1 线程中执行时:

  1. new ClassPathResource("...") 被调用。
  2. 它内部会通过 Thread.currentThread().getContextClassLoader() 获取当前线程的类加载器,即 AppClassLoader
  3. AppClassLoader 尝试在类路径下寻找 scripts/update_device_shadow.lua
  4. 由于 AppClassLoader 不理解 Fat JAR 的 BOOT-INF/classes/ 结构,它无法找到该文件,最终导致 FileNotFoundException

简而言之:我们试图用一个不认识 Fat JAR 结构的类加载器(AppClassLoader),去加载一个只有 LaunchedURLClassLoader 才认识的嵌套资源。

3. 解决方案

解决此问题的核心思想是:确保异步任务在执行时,能够使用正确的、了解应用上下文的类加载器。

方案一(推荐):使用 Spring 管理的自定义线程池

这是最规范、最彻底的解决方案。在 Spring 中定义一个线程池 Bean,由 Spring 容器创建的线程会自动继承主应用的上下文,包括正确的类加载器。

1. 创建线程池配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;

@Configuration
public class ThreadPoolConfig {

public static final String ASYNC_EXECUTOR_NAME = "appAsyncExecutor";

@Bean(ASYNC_EXECUTOR_NAME)
public Executor appAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 根据实际需求配置核心线程数、最大线程数、队列容量等
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("AppAsync-");
executor.setWaitForTasksToCompleteOnShutdown(true);
// 初始化,使其被 Spring 完全管理
executor.initialize();
return executor;
}
}

2. 在业务代码中指定执行器: 将自定义的 Executor Bean 注入,并作为 CompletableFuture 的第二个参数传入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;

@Service
public class MyBusinessService {

@Autowired
@Qualifier(ThreadPoolConfig.ASYNC_EXECUTOR_NAME)
private Executor appAsyncExecutor;

public void executeMyAsyncTask() {
CompletableFuture.runAsync(() -> {
// 在此线程中执行的代码,可以正确加载类路径资源
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/update_device_shadow.lua")));
// ...
}, appAsyncExecutor);
}
}

方案二(不推荐):手动传递 ClassLoader

如果无法使用自定义线程池,可以作为一种临时变通方案,手动在任务开始前设置正确的上下文类加载器。这种方式会使代码变得冗长且容易出错,不建议在生产代码中大规模使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在主线程中捕获正确的 ClassLoader
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

CompletableFuture.runAsync(() -> {
// 在子线程中手动设置
Thread.currentThread().setContextClassLoader(contextClassLoader);
try {
// ... 执行资源加载代码 ...
} finally {
// 最好在任务结束后清理,避免潜在的内存泄漏
Thread.currentThread().setContextClassLoader(null);
}
});

4. 总结与最佳实践

  1. 明确指定执行器:当使用 CompletableFuture 执行需要访问 Spring 应用上下文(如资源、AOP、事务等)的异步任务时,必须为其指定一个由 Spring 管理的 Executor严禁使用无参的 runAsync()supplyAsync() 方法。
  2. 统一线程池管理:在应用中,所有业务相关的线程池都应作为 Bean 交给 Spring 容器管理,便于统一配置、监控和维护。
  3. 环境一致性测试:在持续集成流程中,应增加使用 java -jar 命令启动并运行核心流程的自动化测试,以尽早发现此类由于运行环境差异导致的问题。