现象描述
现象描述:(未开启多线程时)我们是可以获取到 header中的信息,但是在新开的线程(子线程)中确是无法获取到header信息(报空指针异常)
场景复原
-
application无设置
-
项目所用pom文件配置(直接使用阿里云的搭建一个SpringBoot的web项目即可, 引入Lombok即可)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.whitebrocade</groupId>
<artifactId>multi_thread_qa</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>multi_thread_qa</name>
<description>multi_thread_qa</description>
<properties>
<java.version>11</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.6.13</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>11</source>
<target>11</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.whitebrocade.multi_thread_qa.MultiThreadQaApplication</mainClass>
<skip>true</skip>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
- 工具类
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* @author whiteBrocade
* @version 1.0
* @date 2024/1/1 3:45
* @description: 用于从header获取user_id的SecurityUtil工具类
*/
public class SecurityUtil {
/**
* user_id的常量
*/
private static final String USER_ID = "user_id";
/**
* 从header中获取指定的值
* @return userId的值
*/
public static String getUserId() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
HttpServletRequest httpServletRequest = servletRequestAttributes.getRequest();
// 获取指定的值
String userId = httpServletRequest.getHeader(USER_ID);
return userId;
}
}
- controller层
import com.whitebrocade.multi_thread_qa.SecurityUtil;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
public class DemoController {
/**
* 这个方法很简单,多线程情况下, 子线程调用get
*/
@SneakyThrows
@GetMapping("/test2")
public void test1() {
String userId = SecurityUtil.getUserId();
log.info("主线程: {}, 进入了test2方法, userId的值为: {}", Thread.currentThread().getName(), userId);
// 开启一个子线程
new Thread(() -> {
String userId1 = SecurityUtil.getUserId();
log.info("子线程: {}, 进入了test2方法, userId的值为: {}", Thread.currentThread().getName(), userId1);
}).start();
// 睡眠1秒, 防止主线程执行完,线程还没执行
TimeUnit.SECONDS.sleep(1);
}
}
- 请求, 请求地址http:127.0.0.1:8080(工具使用的是国产的Apifox软件)
- 请求结果
可以看见主线程中是已经获取到user_id了, 而新开的线程却报了一个空指针异常
问题分析
先告诉大伙结论
request信息是存储在ThreadLocal(每个线程一个份)中的,所以子线程根本无法获取到主线程的request信息
问题溯源
可以看到是SecurityUtil中报错了, 点击进入29行代码, 发现这里是获取request报错了, 也就是说明了子线程没有requst, 而硬要获取导致了空指针
点击getRequest()方法(这个方法是ServletRequestAttributes类中的), 看一下这个方法中干了什么, 发现只是通过this引用了本类ServletRequestAttributes中的一个request的属性而已
那么接下来我们肯定是要看一下这个"this.request"中的"request"属性, 点击"request"继续深入, 发现这个属性什么都没有…, 别急, 它既然作为一个属性, 那么肯定就有初始化, 那么我们尝试找一下哪里初始化的
观察一下构造方法, 发现本类中只有有参的构造方法, 而且所有的有参构造基本需要传递一个request对象, 所以通过有参构造外界传递request初始化进行初始化的
再观察之前的SecurityUtil代码, 发现ServletRequestAttributes是通过另外一个方法获取的
点击getRequestAttributes()方法看一下这里做了什么, 是不是这里进行了初始化, 发现不是这里通过get方法获取到RequestAttributes进行了返回了
点击看一下requestAttributesHolder和inheritableRequestAttributesHolder是什么东西, 发现是一个ThreadLocal
变量,
# 主线程的存储的request
private static final ThreadLocal<RequestAttributes> requestAttributesHolder = new NamedThreadLocal("Request attributes");
# 可以被子线继承的request
private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder = new NamedInheritableThreadLocal("Request context");
我们的getRequestAttributes操作相当于从ThreadLocal中获取变量, 而默认使用的就是第一个requestAttributesHolder
, 所以就出现主线程获取有值, 子线程获取没有值的现象
所谓request和response怎么和当前请求就发生在这里, 每次请求都使用一个TheadLocal装载, 保证了每次获取的request都是该次请求的request, 保证了并发的效率和安全, 做到请求之间的隔离
这里的getRequestAttributes决定了子线程是否能获取到值,
public static RequestAttributes getRequestAttributes() {
RequestAttributes attributes = requestAttributesHolder.get();
if (attributes == null) {
attributes = inheritableRequestAttributesHolder.get();
}
return attributes;
}
还有一个问题就是request和response等是什么时候设置进去的?
核心就是Spring MVC的DispatcherServlet接口
下边是DispatcherServlet的UML图, 红色圈起来是核心
- HttpServletBean 进行初始化工作
- FrameworkServlet 初始化 WebApplicationContext,并提供service方法预处理请(核心service以及processRequest)
- DispatcherServlet 具体分发处理(核心service方法)
其实核心方法就是processRequest(request, response), 这里
FrameworkServlet中service方法和processRequest方法的分析放后面一点
先重点分析RequestContextHolder类中setRequestAttributes方法和getRequestAttributes方法
public abstract class RequestContextHolder {
// 类上共享变量
private static final boolean jsfPresent =
ClassUtils.isPresent("javax.faces.context.FacesContext", RequestContextHolder.class.getClassLoader());
// 控制主线程的 ThreadLocal
private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
new NamedThreadLocal<>("Request attributes");
// 控制子线程的 ThreadLocal
private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder =
new NamedInheritableThreadLocal<>("Request context");
// 核心源码
// 线程绑定的的形式保存web请求的相关信息的holder类,通过设置inheritable属性来决定是否能被子线程继承;
// 该类里面包含两个ThreadLocal全局属性;
// 进入 setRequestAttributes(servletRequestAttributes, true);方法源码
public static void setRequestAttributes(@Nullable RequestAttributes attributes, boolean inheritable) {
if (attributes == null) {
resetRequestAttributes();
} else {
if (inheritable) { // 传true
// 就是把request的属性放入到inheritableRequestAttributesHolder 中,供子类继承
inheritableRequestAttributesHolder.set(attributes);
requestAttributesHolder.remove(); // 子线程中删除主线程的信息
} else { // 传false
requestAttributesHolder.set(attributes); // 设置主线程使用
inheritableRequestAttributesHolder.remove(); // 不共享设置
}
}
}
// 子线程获取主线程的request的属性
// 子线程中 requestAttributesHolder.get() 获取属性为空,就会从inheritableRequestAttributesHolder 中去获取属性
@Nullable
public static RequestAttributes getRequestAttributes() {
RequestAttributes attributes = requestAttributesHolder.get();
if (attributes == null) { // 负责主线程的信息为空,就去负责子线程的作用域中取
attributes = inheritableRequestAttributesHolder.get();
}
return attributes;
}
}
延伸问题:上一步 requestAttributesHolder 属性是在什么时候放进去的呢, 从类的说明中看看到DispatcherServlet
类已经默认把web request的属性放到了 requestAttributesHolder 中,DispatcherServlet 继承自FrameworkServlet
, 在FrameworkServlet的processRequest()方法中的resetContextHolders会把web request的属性 放入到requestAttributesHolder中
具体源码如下:org.springframework.web.servlet.FrameworkServlet#service
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpMethod httpMethod = HttpMethod.resolve(request.getMethod());
if (httpMethod == HttpMethod.PATCH || httpMethod == null) {
// // 处理请求核心代码,点击该方法进入
processRequest(request, response);
}
else {
super.service(request, response);
}
}
processRequest方法
/*
此函数是一个Java Servlet的方法,用于处理HTTP请求。以下是该函数的详细步骤:
1. 记录请求开始的时间,以便后续可以计算处理时间。
2. 初始化一个变量failureCause,用于存储处理请求时可能发生的异常。
3. 保存当前请求的LocaleContext和RequestAttributes,以便在处理请求结束后可以恢复到之前的状态。
4. 构建一个新的LocaleContext和RequestAttributes,用于处理当前请求。
5. 注册一个RequestBindingInterceptor到WebAsyncManager,用于在异步请求中绑定请求参数。
6. 调用initContextHolders方法,用于初始化上下文持有器(ContextHolder和RequestContextHolder),以便在当前请求中存储和访问上下文信息。
7. 调用doService方法处理请求。此方法是一个抽象方法,具体的实现将在子类中完成。
8 .捕获ServletException或IOException异常,并将其赋值给failureCause变量,并重新抛出。
9. 捕获任何其他Throwable异常,并使用NestedServletException包装后抛出。
10. 在finally块中,恢复之前的LocaleContext和RequestAttributes。
11. 如果requestAttributes不为null,则调用requestAttributes的requestCompleted方法,表示请求已完成。
12. 调用logResult方法记录请求的处理结果。
13. 调用publishRequestHandledEvent方法发布一个事件,传递请求的开始时间、处理时间和处理结果。
*/
protected final void processRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
long startTime = System.currentTimeMillis();
Throwable failureCause = null;
// 获取请求前的LocaleContext, 用于恢复
LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext();
// 建立新的LocaleContext
LocaleContext localeContext = buildLocaleContext(request);
// 获取请求前的RequestAttributes, 用于恢复
RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
// 建立新的RequestAttributes
ServletRequestAttributes requestAttributes = buildRequestAttributes(request, response, previousAttributes);
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
asyncManager.registerCallableInterceptor(FrameworkServlet.class.getName(), new RequestBindingInterceptor());
// 初始化ContextHolders, 便于访问上下文信息
initContextHolders(request, localeContext, requestAttributes);
try {
// 处理请求
doService(request, response);
} catch (ServletException ex) {
failureCause = ex;
throw ex;
} catch (IOException ex) {
failureCause = ex;
throw ex;
} catch (Throwable ex) {
failureCause = ex;
throw new NestedServletException("Request processing failed", ex);
} finally {
// 恢复, 避免影响后续请求
resetContextHolders(request, previousLocaleContext, previousAttributes);
// 请求已完成
if (requestAttributes != null) {
requestAttributes.requestCompleted();
}
// 记录处理结果
if (logger.isDebugEnabled()) {
if (failureCause != null) {
this.logger.debug("Could not complete request", failureCause);
}
else {
if (asyncManager.isConcurrentHandlingStarted()) {
logger.debug("Leaving response open for concurrent processing");
}
else {
this.logger.debug("Successfully completed request");
}
}
}
// 发布事件, 传递时间, 处理结果信息
publishRequestHandledEvent(request, response, startTime, failureCause);
}
}
点击initContextHolders方法
/*
用于初始化和设置Spring框架中的上下文对象
ps:
LocaleContext和RequestAttributes的区别
1. LocaleContext是Java EE中的一个接口,用于表示一个Locale的上下文环境。它包含了当前线程中的Locale、时间格式、数字格式等信息,可以在应用程序中使用这些信息来进行本地化的处理。
2. RequestAttributes是Spring框架中的一个接口,用于表示一个请求的属性集合。它允许应用程序在线程中存储和访问请求的属性,这些属性可以用于在请求的不同阶段共享数据或传递数据。
因此,LocaleContext和RequestAttributes的区别在于它们代表的上下文环境不同,LocaleContext代表的是Locale的上下文环境,而RequestAttributes代表的是请求的属性集合。
*/
private void initContextHolders(HttpServletRequest request,
@Nullable LocaleContext localeContext, @Nullable RequestAttributes requestAttributes) {
// 将参数localeContext设置为当前线程的LocaleContext,并设置参数threadContextInheritable为true,表示上下文对象可以在子线程中继承
if (localeContext != null) {
LocaleContextHolder.setLocaleContext(localeContext, this.threadContextInheritable);
}
// 将参数requestAttributes设置为当前线程的RequestAttributes,并设置参数threadContextInheritable为true,表示上下文对象可以在子线程中继承
if (requestAttributes != null) {
RequestContextHolder.setRequestAttributes(requestAttributes, this.threadContextInheritable);
}
}
点击:resetContextHolders 方法:
/*
使用给定的HttpServletRequest对象、prevLocaleContext和previousAttributes对象来重置请求上下文的持有者。
*/
private void resetContextHolders(HttpServletRequest request,
@Nullable LocaleContext prevLocaleContext, @Nullable RequestAttributes previousAttributes) {
// 将之前保存的本地上下文(prevLocaleContext)和一个布尔值参数(this.threadContextInheritable)设置为当前线程的本地上下文
LocaleContextHolder.setLocaleContext(prevLocaleContext, this.threadContextInheritable);
// 主线程设置request信息
// 将previousAttributes和this.threadContextInheritable作为参数传递。这个方法将之前保存的请求属性(previousAttributes)和一个布尔值参数(this.threadContextInheritable)设置为当前线程的请求属性
RequestContextHolder.setRequestAttributes(previousAttributes, this.threadContextInheritable);
}
到此异步子线程共享主线程request属性的源码剖析已经结束。需要说明的是这种共享方式适合用于主线程等待子线程完成任务后再结束的情况,否则主线程调用主线程先与子线程结束的话主线程的request 会被销毁,子线程还是共享不了主线程的request属性。针对后面的情况给出一个解决方案,那就是子线程都结束后通知主线程结束
解决方案
线程共享
在新开子线程之前 设置共享
// 设置子线程共享
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
RequestContextHolder.setRequestAttributes(servletRequestAttributes, true);
将上述两行代码设置在子线程执行前, 如下图
设置后的代码
@SneakyThrows
@GetMapping("/test2")
public void test2() {
String userId = SecurityUtil.getUserId();
log.info("主线程: {}, 进入了test2方法, userId的值为: {}", Thread.currentThread().getName(), userId);
// 设置子线程共享
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
RequestContextHolder.setRequestAttributes(servletRequestAttributes, true);
// 开启一个子线程
new Thread(() -> {
String userId1 = SecurityUtil.getUserId();
log.info("子线程: {}, 进入了test2方法, userId的值为: {}", Thread.currentThread().getName(), userId1);
}).start();
// 睡眠1秒, 防止主线程执行完,线程还没执行
TimeUnit.SECONDS.sleep(1);
}
执行结果, 发现此时子线程可以获取了
手动传值
@SneakyThrows
@GetMapping("/test3")
public void test3() {
String userId = SecurityUtil.getUserId();
log.info("主线程: {}, 进入了test3方法, userId的值为: {}", Thread.currentThread().getName(), userId);
// 开启一个子线程
new Thread(() -> {
log.info("子线程: {}, 进入了test3方法, userId的值为: {}", Thread.currentThread().getName(), userId);
}).start();
// 睡眠1秒, 防止主线程执行完,线程还没执行
TimeUnit.SECONDS.sleep(1);
}
发现手动传递下去也是可以的
设置servletRequestAttributes
子线程代码执行前, 手动给主线程的ServletRequestAttributes设置到子线程中
@SneakyThrows
@GetMapping("/test4")
public void test4() {
String userId = SecurityUtil.getUserId();
log.info("主线程: {}, 进入了test4方法, userId的值为: {}", Thread.currentThread().getName(), userId);
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// 开启一个子线程
new Thread(() -> {
RequestContextHolder.setRequestAttributes(servletRequestAttributes);
String userId1 = SecurityUtil.getUserId();
log.info("子线程: {}, 进入了test4方法, userId的值为: {}", Thread.currentThread().getName(), userId1);
}).start();
// 睡眠1秒, 防止主线程执行完,线程还没执行
TimeUnit.SECONDS.sleep(1);
}
这种方式发现也是可以的
注意事项
如果使用的是线程池, 那么要注意还是推荐方式三, 用方式一存在部分问题, 会造成时不时的空指针
总结
由于子线程无法获取请求对象, 导致出现了空指针
解决方式有3种
- 线程共享
- 手动传值
- 设置servletRequestAttributes