最近在看一個系統程式碼時,發現系統裡面在使用到了 ThreadLocal,乍一看,好像很高階的樣子。我再仔細一看,這個場景並不會存線上程安全問題,完全只是在一個方法中傳參使用的啊!(震驚)
難道是我水平太低,看不懂這個高階用法?經過和架構師請教和確認,這完全就是一個 ThreadLocal 濫用的典型案例啊!甚至,日常的業務系統中,90%以上的 ThreadLocal 都在濫用或錯用!快來看看說的是不是你~
ThreadLocal 簡介
ThreadLocal 也叫執行緒區域性變數,是 Java 提供的一個工具類,它為每個執行緒提供一個獨立的變數副本,從而實現執行緒間的資料隔離。
ThreadLocal 中的關鍵方法如下:
方法定義 | 方法用途 |
---|---|
public T get() | 返回當前執行緒所對應執行緒區域性變數 |
public void set(T value) | 設定當前執行緒的執行緒區域性變數的值 |
public void remove() | 刪除當前執行緒區域性變數的值 |
濫用:無傷大雅
在一些沒有必要進行執行緒隔離的場景中使用“好像高階”的 ThreadLocal,看起來是挺唬人的,但這其實就是“紙老虎”。
濫用的典型案例是:在一個方法的內部,將入參資訊寫入 ThreadLocal 進行儲存,在後續需要時從 ThreadLocal 中取出使用。一段簡單的示例程式碼,可以參考:
public class TestService {
private static final String COMMON = "1";
private ThreadLocal<Map<String, Object>> commonThreadLocal = new ThreadLocal<>();
public void testThreadLocal(String commonId, String activityId) {
setCommonThreadLocal(commonId, activityId);
// 省略業務程式碼①
doSomething();
// 省略業務程式碼②
}
/**
* 將入參寫入 ThreadLocal
*
* @param commonId
* @param activityId
*/
private void setCommonThreadLocal(String commonId, String activityId) {
Map<String, Object> params = new HashMap<>();
params.put("commonId", commonId);
params.put("activityId", activityId);
this.commonThreadLocal.set(params);
}
/**
* 從 ThreadLocal 取出引數,進行業務處理
*/
private void doSomething() {
Map<String, Object> params = this.commonThreadLocal.get();
String commonId = (String) params.get("commonId");
if (StringUtils.equals(commonId, COMMON)) {
// 省略業務程式碼
}
}
}
為什麼說無傷大雅呢?因為這段程式碼的寫入 ThreadLocal 和讀取 ThreadLocal 都是在同一個執行緒中進行的,程式碼可以正常執行,並且執行結果正確。
但是,還是這段程式碼,也埋了一個“坑”,稍有不慎,將可能導致錯誤的結果。如果在處理業務邏輯中(①或者②處)使用了多執行緒技術,建立了其他執行緒,在其他執行緒中去獲取ThreadLocal中寫入的值,根據獲取到的值進行相關業務邏輯處理,很可能得到預期之外的結果,從而演化為一個錯誤案例。
錯用:血淚教訓
錯誤案例
以一個常見的 Web 應用為例,方便起見,我在本機 Idea 使用 Spring Boot 建立一個工程,在 Controller 中使用 ThreadLocal 來儲存執行緒中的使用者資訊,初識為 null。業務邏輯很簡單,先從 ThreadLocal 獲取一次值,然後把入參中的 uid 設定到 ThreadLocal 中,隨後再獲取一次值,最後返回兩次獲得的 uid。程式碼如下:
private static final ThreadLocal<String> USER_INFO_THREAD_LOCAL = ThreadLocal.withInitial(() -> null);
@RequestMapping("user")
public String user(@RequestParam("uid") String uid) {
//查詢 ThreadLocal 中的使用者資訊
String before = USER_INFO_THREAD_LOCAL.get();
//設定使用者資訊
USER_INFO_THREAD_LOCAL.set(uid);
//再查詢一次 ThreadLocal 中的使用者資訊
String after = USER_INFO_THREAD_LOCAL.get();
return before + ";" + after;
}
啟動工程,使用 uid=1,uid=2 ……作為入參進行測試,結果如下:
http://localhost:8080/user?uid=1 :沒有問題!
http://localhost:8080/user?uid=2 :很穩!
多來幾次,結果還是很穩的。
結果符合預期,這真的沒有問題嗎?
問到這裡,你是不是也有點懷疑了?是不是我要翻車了?寫到這裡就被迫結束了。NO!NO!NO!繼續看!
我調整 application.properties 引數,方便復現問題:
server.tomcat.max-threads=1
繼續執行上面的測試:
http://localhost:8080/user?uid=1 :沒有問題!
http://localhost:8080/user?uid=2 :什麼?uid2 讀取到了 uid1 的資訊!!!
http://localhost:8080/user?uid=1 :什麼?uid1 也讀取到了 uid2 的資訊!!!
這豈不是亂套了,全亂了,整個晉西北都亂成了一鍋粥!
問題原因
為什麼資料會錯亂呢?
資料錯亂,究竟是怎麼回事呢?按理說,在設定使用者資訊之前第一次獲取的值始終應該是 null,然後設定之後再去讀取,讀到的應該是設定之後的值才對啊。
真相是這樣的,程式執行在 Tomcat 中,Tomcat 的工作執行緒是基於執行緒池的,執行緒池其實是複用了一些固定的執行緒的。
如果執行緒被複用,那麼很可能從 ThreadLocal 獲取的值是之前其他使用者的遺留下的值。
為什麼調整執行緒池引數,就測試出問題了呢?
Spring Boot 內嵌的 Tomcat 伺服器的預設執行緒池最大執行緒數是 200,但透過修改 application.properties
或 application.yml
檔案來調整。關鍵引數如下:
- 最大工作執行緒數 (server.tomcat.max-threads):預設值為 200,Tomcat 可以同時處理的最大執行緒數。
- 最小工作執行緒數 (server.tomcat.min-spare-threads):預設值為 10,Tomcat 在啟動時初始化的執行緒數。
- 最大連線數 (server.tomcat.max-connections):預設值為 10000,Tomcat 在任何時候可以接受的最大連線數。
- 等待佇列長度 (server.tomcat.accept-count):預設值為 100,當所有執行緒都在使用時,等待佇列的最大長度。
我調整引數(server.tomcat.max-threads=1)之後,很容易複用到之前的執行緒,複用執行緒情況下,觸發了程式碼中隱藏的 Bug。
如果不調整的話,在較大流量的場景下也會觸發這個 Bug。
解決辦法
那應該如何修改呢?其實方案很簡單,在 finally 程式碼塊中顯式清除 ThreadLocal 中的資料。這樣,即使複用了之前的執行緒,也不會獲取到錯誤的使用者資訊。修正後的程式碼如下:
private static final ThreadLocal<String> USER_INFO_THREAD_LOCAL = ThreadLocal.withInitial(() -> null);
@RequestMapping("right")
public String right(@RequestParam("uid") String uid) {
String before = USER_INFO_THREAD_LOCAL.get();
USER_INFO_THREAD_LOCAL.set(uid);
try {
String after = USER_INFO_THREAD_LOCAL.get();
return before + ";" + after;
} finally {
USER_INFO_THREAD_LOCAL.remove();
}
}
正確使用
前面是濫用和錯用的例子,那應該如何正確使用 ThreadLocal 呢? 正確的使用場景包括:
- 在閘道器場景下,使用 ThreadLocal 來儲存追蹤請求的 ID、請求來源等資訊;
- RPC 等框架中使用 ThreadLocal 儲存請求上下文資訊;
- ……
最常見的案例是使用者登入攔截,從 HttpServletRequest 獲取到使用者資訊,並儲存到 ThreadLocal 中,方便後續隨時取用,程式碼如下:
public class ContextHttpInterceptor implements HandlerInterceptor {
private static final ThreadLocal<Context> contextThreadLocal = new ThreadLocal<Context>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
try {
Context context = new Context();
String pin = request.getParameter("pin");
if (StringUtils.isNotBlank(pin)) {
context.setPin(pin);
}
contextThreadLocal.set(context);
} catch (Exception e) {
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse resposne, Object o,
ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse resposne,
Object o, Exception e) throws Exception {
contextThreadLocal.remove();
}
}
public class Context {
private String pin;
public String getPin() {
return pin;
}
public void setPin(String pin) {
this.pin = pin;
}
}
總結
本文給大家介紹了 ThreadLocal 的無傷大雅的濫用案例、血淚教訓的錯誤案例,分析問題原因和解決方法,也給出了正確的案例,希望對大家理解和使用 ThreadLocal 有幫助。
真正的高手往往使用最樸實無華的招數,寫出無可挑剔的程式碼;有時候炫技式的程式碼可能會出錯。
大師級程式設計師把系統當作故事來講,而不是當作程式來寫。把故事講好,即方便自己閱讀,也方便別人閱讀,共勉。
一起學習
歡迎各位在評論區或者私信我一起交流討論,或者加我主頁 weixin,備註技術渠道(如部落格園),進入技術交流群,我們一起討論和交流,共同進步!
也歡迎大家關注我的部落格園、公眾號(碼上暴富),點贊、留言、轉發。你的支援,是我更文的最大動力!