0. 前言
在 Java 中,多线程的应用场景比较多,例如以下几种情况:
- ElasticSearch 批量导入数据(具体可以参考我的另一篇博文:在SpringBoot项目中使用多线程(配合线程池)加快从MySQL导入数据到ElasticSearch的速度-CSDN博客)
- 数据汇总:例如,在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息,这三块信息都在不同的微服务中进行实现的,我们可以利用多线程并行地查询数据,结合 Future 接口实现数据汇总,从而加快查询数据的接口
- 异步调用:在很多软件中都会有搜索功能,而且这些软件会保存你的搜索记录,我们在实现搜索功能的时候,可以将保存搜索记录操作从搜索功能中利用多线程分离出来,提高搜索接口的响应速度
但是,在使用多线程时可能会引发一些意想不到的问题,比如本文将要介绍的子线程获取不到父线程上下文对象的问题
1. 准备工作
在项目开发中,我们一般会在拦截器中解析出用户信息,接着将用户信息存放在 ThreadLocal 中
记得在SpringBoot启动类上添加@Async注解
1.1 User.java(实体类)
public class User {
private Integer userId;
private String username;
private String password;
public User() {
}
public User(Integer userId, String username, String password) {
this.userId = userId;
this.username = username;
this.password = password;
}
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public String toString() {
return "User{" +
"userId='" + userId + '\'' +
", username='" + username + '\'' +
", password='" + password + '\'' +
'}';
}
}
1.2 ThreadPoolConfig.java(线程池配置)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class ThreadPoolConfig {
/**
* 核心线程池大小
*/
private static final int CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors() + 1;
/**
* 最大可创建的线程数
*/
private static final int MAX_POOL_SIZE = Runtime.getRuntime().availableProcessors() * 2 + 1;
/**
* 阻塞队列队列最大长度
*/
private static final int QUEUE_CAPACITY = 1000;
/**
* 线程池中救急线程的存活时间
*/
private static final int KEEP_ALIVE_SECONDS = 500;
/**
* 通过@Bean注解,将任务执行器(TaskExecutor)实例注入到Spring上下文中
*
* @return Executor
*/
@Bean("taskExecutor")
public Executor taskExecutor() {
// 创建线程池任务执行器实例
ThreadPoolTaskExecutor threadPoolExecutor = new ThreadPoolTaskExecutor();
// 设置核心线程池大小
threadPoolExecutor.setCorePoolSize(CORE_POOL_SIZE);
// 设置最大线程池大小
threadPoolExecutor.setMaxPoolSize(MAX_POOL_SIZE);
// 设置队列容量
threadPoolExecutor.setQueueCapacity(QUEUE_CAPACITY);
// 设置线程空闲时间
threadPoolExecutor.setKeepAliveSeconds(KEEP_ALIVE_SECONDS);
// 设置拒绝策略为AbortPolicy,当线程池无法接受新任务时抛出异常
threadPoolExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
// 设置线程名称前缀为"task-thread-"
threadPoolExecutor.setThreadNamePrefix("task-thread-");
// 初始化线程池任务执行器
threadPoolExecutor.initialize();
// 返回线程池任务执行器实例
return threadPoolExecutor;
}
}
1.3 LoginInterceptor.java(登录拦截器)
import cn.edu.scau.common.UserContext;
import cn.edu.scau.pojo.User;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) {
if (handler instanceof HandlerMethod) {
String token = request.getHeader("token");
User currentUser = getUserInfoByToken(token);
UserContext.setUserContext(currentUser);
return true;
}
return true;
}
@Override
public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, Exception exception) {
UserContext.clearUserContext();
}
/**
* 从 Token 中解析出用户信息
*
* @param token String
* @return user
*/
private User getUserInfoByToken(String token) {
// 省略从 Token 中解析出用户信息的步骤
return new User(1, "聂可以", token);
}
}
1.4 MvcConfig.java(注册拦截器)
import cn.edu.scau.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor());
}
}
1.5 UserContext.java(获取上下文信息的工具类)
import cn.edu.scau.pojo.User;
public class UserContext {
private static final ThreadLocal<User> userContext = new ThreadLocal<>();
private UserContext() {
// Private constructor to prevent instantiation
}
public static void setUserContext(User user) {
UserContext.userContext.set(user);
}
public static User getUserContext() {
return userContext.get();
}
public static void clearUserContext() {
userContext.remove();
}
}
1.6 SearchService.java(实现搜索功能)
import cn.edu.scau.common.UserContext;
import cn.edu.scau.pojo.User;
import org.springframework.stereotype.Service;
@Service
public class SearchService {
private final SaveSearchHistoryService saveSearchHistoryService;
public SearchService(SaveSearchHistoryService saveSearchHistoryService) {
this.saveSearchHistoryService = saveSearchHistoryService;
}
public void search() {
// 省略业务代码
User currentUser = UserContext.getCurrentUser();
System.err.println("currentUser in search method= " + currentUser);
// 异步保存搜索记录
saveSearchHistoryService.saveSearchHistory();
}
}
1.7 SaveSearchHistoryService.java(实现保存搜索历史功能)
import cn.edu.scau.common.UserContext;
import cn.edu.scau.pojo.User;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class SaveSearchHistoryService {
@Async("taskExecutor")
public void saveSearchHistory() {
// 省略业务代码
User currentUser = UserContext.getCurrentUser();
System.out.println("currentUser in saveSearchHistory method= " + currentUser);
}
}
2. 问题复现
在 SearchService 类中利用多线程异步保存用户的搜索记录时,发现在异步线程中获取不到原线程的上下文对象(也就是 User 对象)
3. 问题产生的原因
问题的关键在于 ThreadLocal,用户信息是存储在 ThreadLocal 中的,而 ThreadLocal 是线程内部的一个独立的变量副本
使用了多线程之后,在其它线程中是获取不到这个变量副本的,所以在异步线程中无法获取到原线程中的上下文信息(也就是 User 对象)
4. 解决方法
4.1 手动传参(不推荐)
最简单的方法就是在异步调用时手动传递 User 参数
但是这种方法可能会带来一些潜在的问题:
- 代码复杂性增加:随着代码规模的增长,手动传递参数可能会导致代码变得复杂和难以维护。每个异步调用的地方都需要显式地传递 User 参数,这增加了编程的负担
- 易出错:由于需要在多个地方传递参数,开发者可能会忘记在某些调用中传递 User 参数,导致程序在运行时出现错误或异常
- 上下文丢失:在复杂的异步调用链中,如果某个调用点忘记传递 User 参数,那么后续的调用将无法获取到正确的用户上下文,这可能导致业务逻辑错误
- 可扩展性问题:当需要传递的上下文信息增加时,比如除了 User 信息外,还需要传递其他上下文信息,手动传递的方式将变得难以扩展
- 代码可读性降低:每个异步方法都需要额外的参数来接收 User 信息,这可能会降低方法的清晰度和可读性
简单点来说,就是如果采用手动传参的方式,随着代码规模的增长,屎山代码就会越来越多
4.2 使用线程装饰器(推荐)
其实线程池在设计的时候就已经考虑到这个问题了,ThreadPoolTaskExecutor 类中有个变量叫做 TaskDecorator,这是线程池的一个装饰器,能在线程任务执行前后添加一些自定义逻辑,以下是 ThreadPoolTaskExecutor 类的部分源码
4.2.1 自定义线程装饰器
我们可以自定义一个装饰器,让装饰器自动地帮我们传递需要的上下文对象
为了避免内存泄漏,在线程执行完之后,手动调用 UserContext.clearCurrentUser() 方法
import cn.edu.scau.common.UserContext;
import cn.edu.scau.pojo.User;
import org.springframework.core.task.TaskDecorator;
import org.springframework.lang.NonNull;
public class UserContextDecorator implements TaskDecorator {
@Override
@NonNull
public Runnable decorate(@NonNull Runnable runnable) {
User currentUser = UserContext.getCurrentUser();
return () -> {
try {
UserContext.setCurrentUser(currentUser);
runnable.run();
} finally {
UserContext.clearCurrentUser();
}
};
}
}
4.2.2 设置线程装饰器
在创建线程池时设置线程装饰器
import cn.edu.scau.decorator.UserContextDecorator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class ThreadPoolConfig {
/**
* 核心线程池大小
*/
private static final int CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors() + 1;
/**
* 最大可创建的线程数
*/
private static final int MAX_POOL_SIZE = Runtime.getRuntime().availableProcessors() * 2 + 1;
/**
* 阻塞队列队列最大长度
*/
private static final int QUEUE_CAPACITY = 1000;
/**
* 线程池中救急线程的存活时间
*/
private static final int KEEP_ALIVE_SECONDS = 500;
/**
* 通过@Bean注解,将任务执行器(TaskExecutor)实例注入到Spring上下文中
*
* @return Executor
*/
@Bean("taskExecutor")
public Executor taskExecutor() {
// 创建线程池任务执行器实例
ThreadPoolTaskExecutor threadPoolExecutor = new ThreadPoolTaskExecutor();
// 设置核心线程池大小
threadPoolExecutor.setCorePoolSize(CORE_POOL_SIZE);
// 设置最大线程池大小
threadPoolExecutor.setMaxPoolSize(MAX_POOL_SIZE);
// 设置队列容量
threadPoolExecutor.setQueueCapacity(QUEUE_CAPACITY);
// 设置线程空闲时间
threadPoolExecutor.setKeepAliveSeconds(KEEP_ALIVE_SECONDS);
// 设置拒绝策略为AbortPolicy,当线程池无法接受新任务时抛出异常
threadPoolExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
// 设置线程名称前缀为"task-thread-"
threadPoolExecutor.setThreadNamePrefix("task-thread-");
// 设置任务装饰器为UserContextDecorator,用于在执行任务前进行一些预处理
threadPoolExecutor.setTaskDecorator(new UserContextDecorator());
// 初始化线程池任务执行器
threadPoolExecutor.initialize();
// 返回线程池任务执行器实例
return threadPoolExecutor;
}
}
5. ThreadPoolTaskExecutor 与 ThreadPoolExecutor
需要注意的是,ThreadPoolTaskExecutor 是 Spring 提供的线程池,JDK 提供的 ThreadPoolExecutor 并不支持设置线程装饰器,但 Spring 提供的线程池和 JDK 提供的线程池都实现了最顶层的 Executor 接口
6. 注意事项
当调用 @Async 注解的方法的类和被调用的方法在同一个类中时,@Async 注解不会生效,因为 Spring 的 AOP 代理是基于整个类的,对于同一个类中的方法调用,不会经过代理,因此 @Async 注解不会被处理(这种现象被称为自调用),与此类似的还有 Spring 中事务失效的问题,具体可以参考我的另一篇博文:Spring中事务失效的常见场景及解决方法
如果项目的启动类中没有启用异步支持,即没有使用 @EnableAsync 注解,那么 @Async 注解同样不会生效
@Async 注解没有指定由 Spring 管理的线程池时,自定义的线程装饰器不会生效