ThreadLocal在线程池中被串用

问题分析

在之前的文章中(文章连接如下):
Mybatis拦截器结合ThreadLocal实现数据库updateTime等操作字段的更新
我们用通过ThreadLocal来设置当前请求的登录用户信息,用于在DAO层记录数据表的操作人信息,流程如下:

  1. 用户发起请求,经过SecurityFilter过滤器;
    前提:需要进行登录校验的请求都会通过一个SecurityFilter的过滤器,而不需要登录校验的请求则不会经过这个过滤器;
  2. 在SecurityFilter的过滤器中,往ThreadLocal设置当前请求的登录信息;
  3. 然后在Mybatis拦截器层从ThreadLocal中取出登录信息;
  4. 将登录信息中的用户id设置到数据表的操作人字段。

示意图:
在这里插入图片描述

这样只要有更新数据库的请求都将当前登录人作为数据库的操作人记录下来的,
这个流程看起来没有问题,但是异常发生了:
系统中有一种不经过SecurityFilter的请求也更新了操作人字段,
比如一些提供给外部系统的回调接口,这些接口有特殊的验证方式,不走用户登录验证的SecurityFilter,
按道理这种请求没有设置当前登录信息到ThreadLocal中,不应该更新操作人字段,

现象如下:

比如张三、李四登录系统操作过数据,然后来了一个外部系统接口请求新建了一条数据,这条数据的操作人居然是张三或者李四(很随机),
难道是ThreadLocal出问题吗,可是threadLocal中的值对于每一个线程都是隔离的,不同的接口请求由不同的线程处理的,

推测:

除非!同一个线程用于处理了不同的请求!!比如张三发起请求的这个线程恰好被外部系统的请求使用了,而且这个被复用的线程每次使用完后它的ThreadLocal没有被销毁,
线程是由线程池维护的,线程用完后被线程池回收,且回收后该线程的ThreadLocal没有被销毁!

关于ThreadLocal原理,详见这篇文章:java中的ThreadLocal

验证

为了验证上面的推测,做如下简单3个测试:

  1. 不使用线程池的场景
public class ThreadTest {

	//建立一个整型值的ThreadLocal,初始值为1
	public static ThreadLocal<Integer> threadLocal=ThreadLocal.withInitial(()->1);

	public static void main(String[] args) {
		Runnable runnable=()->{
			//三次累加
			for(int i=0;i<3;i++){
				Integer value=threadLocal.get();
				System.out.println("threadId="+Thread.currentThread().getId()+" , threadLocal value = "+value);
				threadLocal.set(value+1);
			}
		};

		//启动5个线程:
		new Thread(runnable).start();
		new Thread(runnable).start();
		new Thread(runnable).start();
		new Thread(runnable).start();
		new Thread(runnable).start();

	}
}

执行结果:

threadId=12 , threadLocal value = 1
threadId=16 , threadLocal value = 1
threadId=13 , threadLocal value = 1
threadId=13 , threadLocal value = 2
threadId=13 , threadLocal value = 3
threadId=15 , threadLocal value = 1
threadId=14 , threadLocal value = 1
threadId=15 , threadLocal value = 2
threadId=16 , threadLocal value = 2
threadId=12 , threadLocal value = 2
threadId=16 , threadLocal value = 3
threadId=15 , threadLocal value = 3
threadId=14 , threadLocal value = 2
threadId=12 , threadLocal value = 3
threadId=14 , threadLocal value = 3

这个结果是符合预期的,每个线程进行3次累加最后每个线程中threadLocal的值为3,说明threadLocal没有被重用。

  1. 同样的逻辑我们用线程池跑一下
public class ThreadPoolTest {

	//建立一个整型值的ThreadLocal,初始值为1
	public static ThreadLocal<Integer> threadLocal=ThreadLocal.withInitial(()->1);

	public static void main(String[] args) {
		Runnable runnable=()->{
			//三次累加
			for(int i=0;i<3;i++){
				Integer value=threadLocal.get();
				System.out.println("threadId="+Thread.currentThread().getId()+" , threadLocal value = "+value);
				threadLocal.set(value+1);
			}
		};

		///初始化两个线程的线程池:
		ExecutorService threadPool = Executors.newFixedThreadPool(2);
		//执行5个线程任务
		threadPool.submit(runnable);
		threadPool.submit(runnable);
		threadPool.submit(runnable);
		threadPool.submit(runnable);
		threadPool.submit(runnable);

	}
}

结果如下:

threadId=13 , threadLocal value = 1
threadId=12 , threadLocal value = 1
threadId=13 , threadLocal value = 2
threadId=12 , threadLocal value = 2
threadId=13 , threadLocal value = 3
threadId=12 , threadLocal value = 3
threadId=12 , threadLocal value = 4
threadId=13 , threadLocal value = 4
threadId=12 , threadLocal value = 5
threadId=13 , threadLocal value = 5
threadId=12 , threadLocal value = 6
threadId=13 , threadLocal value = 6
threadId=12 , threadLocal value = 7
threadId=12 , threadLocal value = 8
threadId=12 , threadLocal value = 9

这个结果不符合预期,我们发现最大的threadLocal值为threadId为12的线程,被累加到9了,
由于线程池中只有2个线程,要跑5个任务,因此线程被复用了(线程回收后没有被销毁,ThreadLocal也没有销毁,而是被带去执行下一个任务),因此ThreadLocal被重用 了,而threadId为12的线程被复用的次数最多。

  1. 再看看下面的代码:
public class ThreadPoolTest {

	//建立一个整型值的ThreadLocal,初始值为1
	public static ThreadLocal<Integer> threadLocal=ThreadLocal.withInitial(()->1);

	public static void main(String[] args) {
		Runnable runnable=()->{
			//三次累加
			for(int i=0;i<3;i++){
				Integer value=threadLocal.get();
				System.out.println("threadId="+Thread.currentThread().getId()+" , threadLocal value = "+value);
				threadLocal.set(value+1);
			}

			//多了这一行代码:线程执行完后清理threadLocal
			threadLocal.remove();
		};

		///初始化两个线程的线程池:
		ExecutorService threadPool = Executors.newFixedThreadPool(2);
		//执行5个线程任务
		threadPool.submit(runnable);
		threadPool.submit(runnable);
		threadPool.submit(runnable);
		threadPool.submit(runnable);
		threadPool.submit(runnable);

	}
}

执行结果:

threadId=12 , threadLocal value = 1
threadId=13 , threadLocal value = 1
threadId=12 , threadLocal value = 2
threadId=13 , threadLocal value = 2
threadId=12 , threadLocal value = 3
threadId=13 , threadLocal value = 3
threadId=12 , threadLocal value = 1
threadId=13 , threadLocal value = 1
threadId=12 , threadLocal value = 2
threadId=13 , threadLocal value = 2
threadId=12 , threadLocal value = 3
threadId=13 , threadLocal value = 3
threadId=12 , threadLocal value = 1
threadId=12 , threadLocal value = 2
threadId=12 , threadLocal value = 3

这个代码threadLocal值也是符合预期的,跟不使用线程池的结果是一样的,这是为什么呢?
就加了一行代码:threadLocal.remove(),在任务执行完后清理threadLocal,
就算线程被复用了,threadLocal也不会继续累加,因为用完后被清理了,每次启动线程threadLocal值都是从初始值重新开始。

解决方法

基于上面的验证结果,我们知道线程池中使用ThreadLocal,在线程执行完后一定要清理!!!
那么对于http方式的接口请求,每一个request请求都是在一个线程中的,我们如何在每个请求执行完毕的时候来清理ThreadLocal呢?
我们知道servlet中有request监听器,我们通过该监听器的requestDestroyed()方法来清理ThreadLocal。
我是用的是spring boot,配置监听器步骤如下:

  1. 写一个类继承 ServletRequestListener ,配置@WebListener注解:
@WebListener
public class RequestListener implements ServletRequestListener {
	@Override
	public void requestDestroyed(ServletRequestEvent servletRequestEvent) {
		/**
		 * 清空ThreadLocal,避免线程池中线程复用导致ThreadLocal被串用.
		 * 因此,request请求完毕就要销毁ThreadLocal。
		 */
		OperatorHolder.clearOperator();
	}

	@Override
	public void requestInitialized(ServletRequestEvent servletRequestEvent) {

	}
}
  1. 在springboot启动类增加如下@ServletComponentScan注解
//参数指定listener路径
@ServletComponentScan("com.xxx.listener")
@EnableSwagger2
public class Application{
	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}
}

大功告成!






评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值