什麼?!90%的ThreadLocal都在濫用或錯用!

James_Shangguan發表於2024-08-23

最近在看一個系統程式碼時,發現系統裡面在使用到了 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.propertiesapplication.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 呢? 正確的使用場景包括:

  1. 在閘道器場景下,使用 ThreadLocal 來儲存追蹤請求的 ID、請求來源等資訊;
  2. RPC 等框架中使用 ThreadLocal 儲存請求上下文資訊;
  3. ……

最常見的案例是使用者登入攔截,從 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,備註技術渠道(如部落格園),進入技術交流群,我們一起討論和交流,共同進步!

也歡迎大家關注我的部落格園、公眾號(碼上暴富),點贊、留言、轉發。你的支援,是我更文的最大動力!

相關文章