Java ThreadLocal — 解決搶同一份資料最好的辦法,是讓大家根本沒得搶
解決多執行緒搶同一份資料的最好辦法,常常是讓他們根本沒東西可搶。
聽起來像廢話,但這句話背後藏著一個一直被名字誤導的工具。ThreadLocal,名字裡明明白白寫著 Local,很多人第一次看到都以為它是拿來做「執行緒之間安全地共用某個東西」的。字面上「執行緒本地」,感覺就是一個放在執行緒層級、大家一起用的儲存格。
結果它幹的事,跟你想的剛好相反。
它不但沒有讓大家共用一份,反而是替每一個執行緒偷偷發了一份獨立的副本。你以為在存取同一個變數,其實每個執行緒摸到的都是自己那份,彼此看不見、也不會踩到對方。所謂執行緒安全,它不是靠「把共用的東西鎖好」達成的,而是靠「乾脆不共用」——沒有共享,就沒有競爭,連鎖都不用上。
這篇就從這個反直覺的點一路拆下去。你會看到它底層到底怎麼做到「一人一份」,為什麼這招在某些場景反而會漏記憶體漏到你哭,以及它到底該用在哪、不該用在哪。
先搞懂:解決搶資料,本來就有兩條路
多執行緒的麻煩幾乎都長一個樣:好幾個執行緒同時要動同一份資料,讀到一半被別人改掉,或是兩個同時寫,最後結果對不上。
處理這件事,傳統上大家想到的是加鎖。想像辦公室只有一支簽字筆,誰要簽名先喊一聲「我要用」,用完放回去,下一個才能拿。這就是 synchronized、Lock 在做的事:東西還是那一份,靠排隊來保證同一時間只有一個人在動它。
加鎖能解決問題,但它有代價。排隊就是等,等就是慢。而且鎖一多,你還得擔心誰先拿誰後拿、會不會兩個人各抓著對方要的東西不放,然後整條卡死。
還有另一條路,很多人沒認真想過:不要共用那支筆了,直接發給每個人一支。
一人一支筆,誰都不用等誰,也不可能搶到同一支。競爭這個問題不是被「管理」了,是直接被「消滅」了。ThreadLocal 走的就是這條路。
它怎麼做到「一人一份」
現在來看它底層到底在幹嘛。
你可能會直覺地想:那 ThreadLocal 物件裡面是不是有一個 Map,key 是執行緒、value 是各自的資料?每次呼叫 get(),就拿「當前執行緒」當 key 去查那個 map?
想法很合理,但 Java 沒這樣做。它把整個關係反過來了。
真正的做法是:資料不是掛在 ThreadLocal 身上,而是掛在每一個執行緒自己身上。每個 Thread 物件裡面,都帶著一個屬於自己的小背包,這個背包的正式名字叫 ThreadLocalMap。
那 ThreadLocal 物件是什麼?它是背包裡的一把鑰匙、一個標籤。
當你寫 userContext.get(),實際發生的事情是:Java 先找出「現在是誰在跑這行程式」,也就是當前執行緒,然後翻開那個執行緒自己的背包,拿 userContext 這把鑰匙去開對應的格子,把裡面的東西拿出來。
1 | public T get() { |
看懂這段,那個「反直覺」就徹底講通了。同一個 userContext 變數,A 執行緒 get() 出來的跟 B 執行緒 get() 出來的完全是兩份不同的資料——因為他們拿著同一把鑰匙,翻的卻是各自的背包。鑰匙一樣,背包不一樣,開出來的當然不是同一格。
沒有一份資料被兩個執行緒同時碰到。所以不用鎖。所以執行緒安全。
這招平常都用在哪
講到這裡你大概會問,那我什麼時候會真的用到它。三個最常見的場景。
第一個是那種「本來就不該共用、共用會出事」的物件。經典受害者是 SimpleDateFormat。這東西本身不是執行緒安全的,多個執行緒同時拿它格式化日期,會格出亂七八糟的結果甚至噴例外。過去大家的解法是每次用都 new 一個,但 new 是有成本的。用 ThreadLocal 包起來,每個執行緒第一次用的時候各自 new 一份,之後重複用自己那份,既安全又不用一直重新建。
第二個場景更常見:使用者上下文。想想一個 web 請求進來,你在最外層攔截器解析出登入者是誰、這次請求的 traceId 是什麼,然後這些資訊 Controller 要用、Service 要用、最底層寫 log 的地方也要用。
你有兩個選擇。一是把 traceId 當參數,從最上層一路往下傳,每一個方法簽名都多一個參數,傳到你想吐。二是開場的時候塞進 ThreadLocal,反正同一個請求從頭到尾都在同一個執行緒上跑,中間任何一個方法要用,直接 get() 就拿得到,完全不用經過參數。
Spring 的 RequestContextHolder、各種 log 框架的 MDC,底層都是靠這招在同一個執行緒內「隔空傳話」。第三個場景是資料庫連線之類、想讓一個執行緒在一次任務中共用同一個資源的情況,道理一樣。
然後,坑來了
前面講得這麼美好,該來講講它最惡名昭彰的地方了:記憶體洩漏。
這個坑不會在你隨手寫個 demo 的時候出現。它專門在執行緒池的場景下咬你,而執行緒池幾乎是所有正式服務的標配。
問題出在背包裡那把鑰匙的引用方式。前面說 ThreadLocalMap 是個 key-value 的背包,key 是 ThreadLocal 物件本身,value 是你存的資料。關鍵細節是:這個 key,用的是弱引用(WeakReference);而 value,是強引用。
1 | static class Entry extends WeakReference<ThreadLocal<?>> { |
弱引用的意思是,如果外面沒有別人強抓著這個 ThreadLocal 物件了,垃圾回收會直接把它收掉。這本來是好意——怕你忘了清,幫你留條後路。
但問題就卡在這個「一半」上。某個 ThreadLocal 變數的作用域結束了,外面沒人引用它了,GC 把它回收,於是背包裡那把 key 變成了 null。可是 value 呢?value 是強引用,還好端端地掛在背包裡,沒人動得了它。
現在你有一個 key 是 null、value 還活著、但你再也拿不到的格子。因為查資料要用鑰匙,鑰匙沒了,這格資料就成了背包裡一個你永遠打不開、也丟不掉的死角。
單獨一個執行緒還好,執行緒跑完就死了,背包跟著一起被回收,死角自然清空。但執行緒池裡的執行緒是不會死的——它跑完一個任務,擦擦手,回去接下一個任務,背包從頭到尾都是同一個。於是這些 key 為 null 的死角,一個接一個地累積在背包裡,越積越多,記憶體就這樣一點一點漏掉。跑久了的服務,你會看到記憶體莫名其妙一直往上爬。
解法其實一句話:用完記得 remove。
1 | try { |
放在 finally 裡,保證不管中間有沒有噴例外,離開的時候都會把自己那格擦乾淨。這一步在單機小程式可有可無,在跑執行緒池的正式服務裡,是不能省的紀律。少了它,遲早炸給你看。
別把它用歪了
最後講幾個誠實的邊界,免得你學會一招就到處亂用。
ThreadLocal 是「一人一份、互不相通」,這是它的本事,也是它的天花板。所以它不是拿來做執行緒之間傳資料的工具。你想讓 A 執行緒把東西交給 B 執行緒?別找它,那是它設計上就做不到的事——每個執行緒只看得到自己的背包。硬要用它做執行緒間通訊,就是在跟它的本質對著幹。
還有一個常見需求:父執行緒開了一個子執行緒,想讓子執行緒繼承父執行緒 ThreadLocal 裡的值。普通 ThreadLocal 做不到,子執行緒的背包是全新的空背包。這種情況要用 InheritableThreadLocal,它會在建立子執行緒的時候幫忙把父執行緒的值複製一份過去。
再來是一個不會報錯、但會慢慢反噬的濫用:把太多東西塞進 ThreadLocal 當隱形的全域變數。它方便就方便在「不用傳參數」,但這個方便反過來就是它的陰暗面——資料流變得看不見了。一個值到底是哪裡塞進去的、在哪被改過,你從方法簽名上完全看不出來,追起 bug 來會恨得牙癢癢。方便和可追蹤,這裡有個要你自己拿捏的取捨。
一個可以帶走的判斷習慣
把 ThreadLocal 這件事往上抽一層,它其實在教你一種面對「共用」問題時的判斷順序。
大多數人碰到「好幾個東西要用同一份狀態」,反射動作是去想「那我要怎麼把這份共用管好」——加鎖、排隊、做同步。但在動手管理共享之前,永遠先問一句:它們真的需要共用嗎?
共享從來不是免費的。只要東西是共用的,你就得付出協調的成本:誰先誰後、會不會撞、要不要鎖。很多時候你會發現,那個「非共用不可」的假設根本站不住腳——每人一份,問題直接消失,連協調都省了。ThreadLocal 就是把這個念頭做成了工具——與其想辦法把搶奪管理得更好,它乾脆讓大家沒得搶。
當然它也提醒你,天下沒有白吃的午餐。你選了「每人一份」來換掉鎖的成本,換來的新帳單是:資料流變隱形、用完要記得收。工程上幾乎每個漂亮的解法都是這樣——它不是消滅了成本,是把成本從一個你受不了的地方,搬到一個你付得起的地方。看懂它把成本搬去了哪,你就真的懂這個工具了,而不是只記得它的名字。










