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);
	}
}

大功告成!






發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章