最近在看一個系統程式碼時,發現系統裡面在使用到了 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 有幫助。
真正的高手往往使用最樸實無華的招數,寫出無可挑剔的程式碼;有時候炫技式的程式碼可能會出錯。
大師級程式設計師把系統當作故事來講,而不是當作程式來寫。把故事講好,即方便自己閱讀,也方便別人閱讀,共勉。