什么苔咪?!90%的ThreadLocal都在濫用或錯用柳骄!

最近在看一個系統(tǒng)代碼時,發(fā)現(xiàn)系統(tǒng)里面在使用到了 ThreadLocal箕般,乍一看耐薯,好像很高級的樣子。我再仔細(xì)一看丝里,這個場景并不會存在線程安全問題曲初,完全只是在一個方法中傳參使用的啊1邸(震驚)

難道是我水平太低臼婆,看不懂這個高級用法?經(jīng)過和架構(gòu)師請教和確認(rèn)幌绍,這完全就是一個 ThreadLocal 濫用的典型案例啊颁褂!甚至,日常的業(yè)務(wù)系統(tǒng)中傀广,90%以上的 ThreadLocal 都在濫用或錯用颁独!快來看看說的是不是你~

ThreadLocal 簡介

ThreadLocal 也叫線程局部變量,是 Java 提供的一個工具類伪冰,它為每個線程提供一個獨立的變量副本誓酒,從而實現(xiàn)線程間的數(shù)據(jù)隔離

ThreadLocal 中的關(guān)鍵方法如下:

方法定義 方法用途
public T get() 返回當(dāng)前線程所對應(yīng)線程局部變量
public void set(T value) 設(shè)置當(dāng)前線程的線程局部變量的值
public void remove() 刪除當(dāng)前線程局部變量的值

濫用:無傷大雅

在一些沒有必要進(jìn)行線程隔離的場景中使用“好像高級”的 ThreadLocal贮聂,看起來是挺唬人的靠柑,但這其實就是“紙老虎”寨辩。

濫用的典型案例是:在一個方法的內(nèi)部,將入?yún)⑿畔懭?ThreadLocal 進(jìn)行保存歼冰,在后續(xù)需要時從 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);

        // 省略業(yè)務(wù)代碼①

        doSomething();

        // 省略業(yè)務(wù)代碼②
    }

    /**
     * 將入?yún)懭?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 取出參數(shù)停巷,進(jìn)行業(yè)務(wù)處理
     */
    private void doSomething() {
        Map<String, Object> params = this.commonThreadLocal.get();
        String commonId = (String) params.get("commonId");
        if (StringUtils.equals(commonId, COMMON)) {
            // 省略業(yè)務(wù)代碼
        }
    }
}

為什么說無傷大雅呢耍攘?因為這段代碼的寫入 ThreadLocal 和讀取 ThreadLocal 都是在同一個線程中進(jìn)行的,代碼可以正常運(yùn)行畔勤,并且運(yùn)行結(jié)果正確蕾各。

但是,還是這段代碼庆揪,也埋了一個“坑”式曲,稍有不慎,將可能導(dǎo)致錯誤的結(jié)果缸榛。如果在處理業(yè)務(wù)邏輯中(①或者②處)使用了多線程技術(shù)吝羞,創(chuàng)建了其他線程,在其他線程中去獲取ThreadLocal中寫入的值内颗,根據(jù)獲取到的值進(jìn)行相關(guān)業(yè)務(wù)邏輯處理钧排,很可能得到預(yù)期之外的結(jié)果,從而演化為一個錯誤案例均澳。

錯用:血淚教訓(xùn)

錯誤案例

以一個常見的 Web 應(yīng)用為例恨溜,方便起見,我在本機(jī) Idea 使用 Spring Boot 創(chuàng)建一個工程找前,在 Controller 中使用 ThreadLocal 來保存線程中的用戶信息糟袁,初識為 null。業(yè)務(wù)邏輯很簡單躺盛,先從 ThreadLocal 獲取一次值项戴,然后把入?yún)⒅械?uid 設(shè)置到 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();
    //設(shè)置用戶信息
    USER_INFO_THREAD_LOCAL.set(uid);
    //再查詢一次 ThreadLocal 中的用戶信息
    String after = USER_INFO_THREAD_LOCAL.get();

    return before + ";" + after;
}

啟動工程,使用 uid=1躯枢,uid=2 ……作為入?yún)⑦M(jìn)行測試则吟,結(jié)果如下:

http://localhost:8080/user?uid=1沒有問題!

image.png

http://localhost:8080/user?uid=2很穩(wěn)锄蹂!

image.png

多來幾次氓仲,結(jié)果還是很穩(wěn)的。

結(jié)果符合預(yù)期,這真的沒有問題嗎敬扛?

問到這里晰洒,你是不是也有點懷疑了?是不是我要翻車了啥箭?寫到這里就被迫結(jié)束了谍珊。NO!NO急侥!NO砌滞!繼續(xù)看!

我調(diào)整 application.properties 參數(shù)坏怪,方便復(fù)現(xiàn)問題:

server.tomcat.max-threads=1

繼續(xù)執(zhí)行上面的測試:

http://localhost:8080/user?uid=1沒有問題贝润!

image.png

http://localhost:8080/user?uid=2什么?uid2 讀取到了 uid1 的信息B料4蚓颉!

image.png

http://localhost:8080/user?uid=1什么鹏秋?uid1 也讀取到了 uid2 的信息W鹨稀!侣夷!

image.png

這豈不是亂套了横朋,全亂了?百拓?R度觥!

問題原因

為什么數(shù)據(jù)會錯亂呢耐版?

數(shù)據(jù)錯亂,究竟是怎么回事呢压汪?按理說粪牲,在設(shè)置用戶信息之前第一次獲取的值始終應(yīng)該是 null,然后設(shè)置之后再去讀取止剖,讀到的應(yīng)該是設(shè)置之后的值才對啊腺阳。

真相是這樣的,程序運(yùn)行在 Tomcat 中穿香,Tomcat 的工作線程是基于線程池的亭引,線程池其實是復(fù)用了一些固定的線程的

如果線程被復(fù)用皮获,那么很可能從 ThreadLocal 獲取的值是之前其他用戶的遺留下的值焙蚓。

為什么調(diào)整線程池參數(shù),就測試出問題了呢?

Spring Boot 內(nèi)嵌的 Tomcat 服務(wù)器的默認(rèn)線程池最大線程數(shù)是 200购公,但通過修改 application.propertiesapplication.yml 文件來調(diào)整萌京。關(guān)鍵參數(shù)如下:

  • 最大工作線程數(shù) (server.tomcat.max-threads):默認(rèn)值為 200,Tomcat 可以同時處理的最大線程數(shù)宏浩。
  • 最小工作線程數(shù) (server.tomcat.min-spare-threads):默認(rèn)值為 10知残,Tomcat 在啟動時初始化的線程數(shù)。
  • 最大連接數(shù) (server.tomcat.max-connections):默認(rèn)值為 10000比庄,Tomcat 在任何時候可以接受的最大連接數(shù)求妹。
  • 等待隊列長度 (server.tomcat.accept-count):默認(rèn)值為 100,當(dāng)所有線程都在使用時佳窑,等待隊列的最大長度制恍。

我調(diào)整參數(shù)(server.tomcat.max-threads=1)之后,很容易復(fù)用到之前的線程华嘹,復(fù)用線程情況下吧趣,觸發(fā)了代碼中隱藏的 Bug

如果不調(diào)整的話耙厚,在較大流量的場景下也會觸發(fā)這個 Bug强挫。

解決辦法

那應(yīng)該如何修改呢?其實方案很簡單薛躬,在 finally 代碼塊中顯式清除 ThreadLocal 中的數(shù)據(jù)俯渤。這樣,即使復(fù)用了之前的線程型宝,也不會獲取到錯誤的用戶信息八匠。修正后的代碼如下:

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


正確使用

前面是濫用和錯用的例子,那應(yīng)該如何正確使用 ThreadLocal 呢趴酣? 正確的使用場景包括:

  1. 在網(wǎng)關(guān)場景下梨树,使用 ThreadLocal 來存儲追蹤請求的 ID、請求來源等信息岖寞;
  2. RPC 等框架中使用 ThreadLocal 保存請求上下文信息抡四,例如壓測標(biāo)識等;
  3. ……

最常見的案例是用戶登錄攔截仗谆,從 HttpServletRequest 獲取到用戶信息指巡,并保存到 ThreadLocal 中,方便后續(xù)隨時取用隶垮,代碼如下:

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

總結(jié)

本文給大家介紹了 ThreadLocal 的無傷大雅的濫用案例藻雪、血淚教訓(xùn)的錯誤案例,分析問題原因和解決方法狸吞,也給出了正確的案例勉耀,希望對大家理解和使用 ThreadLocal 有幫助指煎。

真正的高手往往使用最樸實無華的招數(shù),寫出無可挑剔的代碼瑰排;有時候炫技式的代碼可能會出錯贯要。

大師級程序員把系統(tǒng)當(dāng)作故事來講,而不是當(dāng)作程序來寫椭住。把故事講好崇渗,即方便自己閱讀,也方便別人閱讀京郑,共勉宅广。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市些举,隨后出現(xiàn)的幾起案子跟狱,更是在濱河造成了極大的恐慌,老刑警劉巖户魏,帶你破解...
    沈念sama閱讀 216,324評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件驶臊,死亡現(xiàn)場離奇詭異,居然都是意外死亡叼丑,警方通過查閱死者的電腦和手機(jī)关翎,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,356評論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來鸠信,“玉大人纵寝,你說我怎么就攤上這事⌒橇ⅲ” “怎么了爽茴?”我有些...
    開封第一講書人閱讀 162,328評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長绰垂。 經(jīng)常有香客問我室奏,道長,這世上最難降的妖魔是什么劲装? 我笑而不...
    開封第一講書人閱讀 58,147評論 1 292
  • 正文 為了忘掉前任窍奋,我火速辦了婚禮,結(jié)果婚禮上酱畅,老公的妹妹穿的比我還像新娘。我一直安慰自己江场,他們只是感情好纺酸,可當(dāng)我...
    茶點故事閱讀 67,160評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著址否,像睡著了一般餐蔬。 火紅的嫁衣襯著肌膚如雪碎紊。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,115評論 1 296
  • 那天樊诺,我揣著相機(jī)與錄音仗考,去河邊找鬼。 笑死词爬,一個胖子當(dāng)著我的面吹牛秃嗜,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播顿膨,決...
    沈念sama閱讀 40,025評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼锅锨,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了恋沃?” 一聲冷哼從身側(cè)響起必搞,我...
    開封第一講書人閱讀 38,867評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎囊咏,沒想到半個月后恕洲,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,307評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡梅割,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,528評論 2 332
  • 正文 我和宋清朗相戀三年霜第,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片炮捧。...
    茶點故事閱讀 39,688評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡庶诡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出咆课,到底是詐尸還是另有隱情末誓,我是刑警寧澤,帶...
    沈念sama閱讀 35,409評論 5 343
  • 正文 年R本政府宣布书蚪,位于F島的核電站喇澡,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏殊校。R本人自食惡果不足惜晴玖,卻給世界環(huán)境...
    茶點故事閱讀 41,001評論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望为流。 院中可真熱鬧呕屎,春花似錦、人聲如沸敬察。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,657評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽莲祸。三九已至蹂安,卻和暖如春椭迎,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背田盈。 一陣腳步聲響...
    開封第一講書人閱讀 32,811評論 1 268
  • 我被黑心中介騙來泰國打工畜号, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人允瞧。 一個月前我還...
    沈念sama閱讀 47,685評論 2 368
  • 正文 我出身青樓简软,卻偏偏與公主長得像,于是被迫代替她去往敵國和親瓷式。 傳聞我的和親對象是個殘疾皇子替饿,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,573評論 2 353

推薦閱讀更多精彩內(nèi)容