
1. 從一個詭異的線上問題說起去年幫一個團隊排查過一個挺有意思的Bug一個請求進來主線程往ThreadLocal里塞了當(dāng)前登錄用戶的信息然后業(yè)務(wù)邏輯里用線程池異步去查數(shù)據(jù)庫、拼裝數(shù)據(jù)結(jié)果異步線程里拿到的用戶信息全是null。更詭異的是有時候又能拿到有時候拿到的是上一個請求的用戶信息——這就不是簡單的沒傳進去了而是數(shù)據(jù)串了。這個現(xiàn)象背后其實就兩個知識點ThreadLocal的隔離邊界是線程而線程池里的線程是復(fù)用的。你往主線程的ThreadLocal里放東西子線程天然看不到而線程池復(fù)用線程時如果上一個任務(wù)沒清理干凈下一個任務(wù)就會讀到臟數(shù)據(jù)。那有沒有一種機制能讓子線程自動繼承父線程的本地變量有就是InheritableThreadLocal。但它的名字很有迷惑性——繼承兩個字讓很多人以為它能解決線程池的問題實際上它解決的是線程創(chuàng)建時的一次性傳遞跟線程池復(fù)用完全是兩碼事。這篇就把這個類從原理到坑點掰開揉碎講清楚包括它到底怎么實現(xiàn)繼承的、為什么在線程池里會失效、以及實際項目里該怎么選型。2. InheritableThreadLocal 到底繼承了什么2.1 先看 ThreadLocal 的存儲結(jié)構(gòu)要理解繼承得先知道ThreadLocal的值存在哪。很多人以為值存在ThreadLocal對象自己身上其實不是。每個Thread對象內(nèi)部都有一個ThreadLocalMap字段key是ThreadLocal實例本身準(zhǔn)確說是弱引用包裝后的value才是你set進去的值。// Thread 類內(nèi)部簡化示意 public class Thread implements Runnable { ThreadLocal.ThreadLocalMap threadLocals null; ThreadLocal.ThreadLocalMap inheritableThreadLocals null; }注意這里有兩個字段threadLocals和inheritableThreadLocals。普通ThreadLocal的值進的是前者InheritableThreadLocal的值進的是后者。這個設(shè)計很關(guān)鍵——兩套存儲互不干擾一個線程可以同時擁有普通本地變量和可繼承本地變量。ThreadLocal.get()的邏輯大致是拿到當(dāng)前線程取它的threadLocals用自己當(dāng)key去查。查不到就調(diào)用initialValue()初始化。InheritableThreadLocal重寫了三個方法把操作對象從threadLocals換成了inheritableThreadLocalspublic class InheritableThreadLocalT extends ThreadLocalT { protected T childValue(T parentValue) { return parentValue; // 默認(rèn)直接返回父值淺拷貝 } ThreadLocalMap getMap(Thread t) { return t.inheritableThreadLocals; } void createMap(Thread t, T firstValue) { t.inheritableThreadLocals new ThreadLocalMap(this, firstValue); } }所以從存儲角度看InheritableThreadLocal就是換了個抽屜放東西的ThreadLocal。2.2 繼承發(fā)生在 new Thread() 的那一刻真正實現(xiàn)繼承的代碼在Thread的構(gòu)造函數(shù)里。當(dāng)你new Thread()時構(gòu)造鏈最終會走到Thread.init()里面有這么一段private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { // ... Thread parent currentThread(); // ... if (inheritThreadLocals parent.inheritableThreadLocals ! null) { this.inheritableThreadLocals ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); } }邏輯很直白創(chuàng)建子線程時如果父線程也就是執(zhí)行new Thread()的那個線程的inheritableThreadLocals不為空就把父線程的整張Map復(fù)制一份給子線程。復(fù)制的過程會遍歷父Map的每個Entry對每個值調(diào)用childValue()做轉(zhuǎn)換默認(rèn)實現(xiàn)是直接返回原值。這里有幾個細節(jié)值得注意繼承是拷貝不是引用。子線程拿到的是一個新的ThreadLocalMap里面裝的是父線程值的副本對于對象類型是引用拷貝指向同一個對象。子線程后續(xù)set新值不會影響父線程。繼承只發(fā)生在創(chuàng)建線程時。線程一旦創(chuàng)建完成父子之間的這條繼承通道就斷了。父線程之后再set新值子線程完全感知不到。默認(rèn)的 childValue 是淺拷貝。如果父線程存的是一個可變對象比如User實例子線程和父線程拿到的是同一個對象引用。子線程改了對象的字段父線程也會看到變化。這是個很容易踩的坑后面會細說。2.3 一個最小可運行示例光說原理太干直接上代碼驗證一下public class InheritableDemo { private static final InheritableThreadLocalString CONTEXT new InheritableThreadLocal(); public static void main(String[] args) throws InterruptedException { CONTEXT.set(父線程的值); Thread child new Thread(() - { System.out.println(子線程讀到: CONTEXT.get()); CONTEXT.set(子線程改過的值); System.out.println(子線程改后: CONTEXT.get()); }); child.start(); child.join(); System.out.println(父線程讀到: CONTEXT.get()); } }輸出結(jié)果子線程讀到: 父線程的值 子線程改后: 子線程改過的值 父線程讀到: 父線程的值可以看到子線程確實繼承到了父線程的值而且子線程的修改沒有反向污染父線程。這就是InheritableThreadLocal最核心的行為。對比一下如果把上面的InheritableThreadLocal換成普通ThreadLocal子線程讀到的就是null。這個對比實驗建議你自己跑一遍比看十遍文檔都管用。3. 線程池場景下它為什么直接失效3.1 線程池的線程不是當(dāng)場創(chuàng)建的InheritableThreadLocal的繼承時機是new Thread()。而線程池的工作機制是核心線程在池子初始化或第一次提交任務(wù)時創(chuàng)建之后一直復(fù)用。也就是說任務(wù)提交時并不會創(chuàng)建新線程自然也就不會觸發(fā)繼承邏輯。看個例子就明白了public class PoolFailDemo { private static final InheritableThreadLocalString CONTEXT new InheritableThreadLocal(); private static final ExecutorService POOL Executors.newFixedThreadPool(2); public static void main(String[] args) throws InterruptedException { CONTEXT.set(請求A的用戶); POOL.submit(() - System.out.println(任務(wù)1讀到: CONTEXT.get())); Thread.sleep(100); CONTEXT.set(請求B的用戶); POOL.submit(() - System.out.println(任務(wù)2讀到: CONTEXT.get())); POOL.shutdown(); } }實際輸出很可能是任務(wù)1讀到: null 任務(wù)2讀到: null為什么是null而不是請求A的用戶因為線程池的線程是在submit之前就創(chuàng)建好的或者創(chuàng)建時機早于CONTEXT.set創(chuàng)建那一刻父線程的inheritableThreadLocals還是空的所以子線程繼承了個寂寞。更坑的是另一種情況如果線程池的線程恰好是在你set之后創(chuàng)建的那這個線程會繼承到當(dāng)時的值然后一直帶著這個值后續(xù)所有復(fù)用它的任務(wù)都會讀到這個陳舊的值。這就是開頭說的數(shù)據(jù)串了的根源。3.2 線程復(fù)用帶來的臟數(shù)據(jù)問題假設(shè)線程池是懶加載的第一個任務(wù)提交時創(chuàng)建了線程此時父線程CONTEXT里是請求A。這個線程被創(chuàng)建后inheritableThreadLocals里就永久存了請求A。之后請求B、請求C提交任務(wù)用的還是這個線程讀到的全是請求A。// 模擬先 set 再創(chuàng)建線程池 CONTEXT.set(請求A); ExecutorService pool Executors.newFixedThreadPool(1); pool.submit(() - System.out.println(A: CONTEXT.get())); // 請求A CONTEXT.set(請求B); pool.submit(() - System.out.println(B: CONTEXT.get())); // 還是請求A這個問題的本質(zhì)是InheritableThreadLocal的值綁定在線程上而線程池的線程生命周期遠長于單個任務(wù)。任務(wù)結(jié)束了值還在線程身上掛著下一個任務(wù)就遭殃了。注意不要試圖用remove()在任務(wù)結(jié)束時清理來解決這個問題。因為繼承只發(fā)生在線程創(chuàng)建時你remove之后下一個任務(wù)并不會重新繼承父線程的新值——它只會讀到null。清理只能防止臟數(shù)據(jù)不能實現(xiàn)每個任務(wù)拿到自己請求的上下文。3.3 那阿里 TransmittableThreadLocal 是怎么做的既然原生方案在線程池里不好使業(yè)界就有對應(yīng)的增強方案。思路是不依賴線程創(chuàng)建時的繼承而是在任務(wù)提交時把父線程的上下文快照下來在任務(wù)執(zhí)行前回填到執(zhí)行線程任務(wù)結(jié)束后再恢復(fù)現(xiàn)場。核心是包裝Runnable/Callable// 偽代碼展示思路 class TtlRunnable implements Runnable { private final Runnable actual; private final Object captured; // 提交時抓取的上下文快照 public void run() { Object backup 當(dāng)前線程的上下文; // 備份執(zhí)行線程原有值 回填(captured); // 用提交時的快照覆蓋 try { actual.run(); } finally { 恢復(fù)(backup); // 還原避免污染線程池 } } }這套機制的關(guān)鍵在于快照-回填-恢復(fù)三步它把上下文的傳遞從線程創(chuàng)建時挪到了任務(wù)提交時從而繞開了線程池復(fù)用的坑。理解了這個思路你就算不用現(xiàn)成框架自己手寫一個簡易版也不難。4. childValue 重寫與可變對象的深坑4.1 默認(rèn)淺拷貝意味著什么前面提過childValue()默認(rèn)直接返回父值。如果父線程存的是不可變對象String、Integer等沒問題。但如果存的是可變對象父子線程共享同一個引用static class UserContext { String name; // getter/setter 省略 } private static final InheritableThreadLocalUserContext CTX new InheritableThreadLocal(); public static void main(String[] args) throws InterruptedException { UserContext ctx new UserContext(); ctx.setName(張三); CTX.set(ctx); Thread child new Thread(() - { CTX.get().setName(李四); // 改的是同一個對象 }); child.start(); child.join(); System.out.println(CTX.get().getName()); // 輸出李四 }父線程明明沒動名字卻從張三變成了李四。因為父子倆的ThreadLocalMap里存的是同一個UserContext實例的引用。這種Bug特別隱蔽因為從ThreadLocal的語義看你會以為父子是隔離的。4.2 重寫 childValue 做深拷貝要避免這個問題就得重寫childValue()在繼承時做一次拷貝private static final InheritableThreadLocalUserContext CTX new InheritableThreadLocal() { Override protected UserContext childValue(UserContext parentValue) { if (parentValue null) return null; UserContext copy new UserContext(); copy.setName(parentValue.getName()); return copy; } };這樣父子各持一份互不影響。但要注意深拷貝是有成本的。如果上下文對象很大、繼承很頻繁拷貝開銷不能忽略。而且如果對象里有嵌套引用你得保證每一層都拷到位漏一層就還是共享。4.3 什么時候該重寫什么時候不該判斷標(biāo)準(zhǔn)很簡單子線程會不會修改這個對象。如果上下文是只讀的比如一個請求ID、一個租戶標(biāo)識子線程只讀不寫那默認(rèn)淺拷貝完全夠用沒必要重寫。如果子線程可能修改對象狀態(tài)或者對象本身是可變集合List、Map那就必須重寫childValue做隔離。如果對象是不可變類型String、包裝類、枚舉淺拷貝和深拷貝沒區(qū)別不用管。我個人的習(xí)慣是上下文對象盡量設(shè)計成不可變的。用final字段、不提供setter、集合用Collections.unmodifiableXxx包一層。這樣從源頭上就杜絕了共享可變狀態(tài)的問題比事后重寫childValue省心得多。5. 內(nèi)存泄漏弱引用救不了的那部分5.1 ThreadLocalMap 的 key 是弱引用ThreadLocalMap的Entry設(shè)計很講究key是ThreadLocal實例的弱引用value是強引用。static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); // key 弱引用 value v; // value 強引用 } }為什么這么設(shè)計如果key是強引用那么只要線程活著ThreadLocal對象就永遠不被回收哪怕外部已經(jīng)沒有任何地方引用它了。用弱引用后當(dāng)外部對ThreadLocal的強引用消失GC就能回收keyEntry變成(null, value)。但問題來了key被回收了value還在而且是被Entry強引用的。如果線程一直活著比如線程池的核心線程這個value就永遠回收不掉這就是經(jīng)典的內(nèi)存泄漏。5.2 InheritableThreadLocal 讓泄漏面更大普通ThreadLocal的泄漏只影響當(dāng)前線程。InheritableThreadLocal更麻煩——它會被復(fù)制到所有子線程。父線程創(chuàng)建了10個子線程每個子線程的inheritableThreadLocals里都有一份Entry。父線程清理了自己的子線程的還在。在線程池場景下如果線程池的線程是通過new Thread()創(chuàng)建的比如自定義ThreadFactory那每個池內(nèi)線程都可能持有一份繼承來的上下文。這些線程長期存活泄漏的value就一直掛著。5.3 正確的清理姿勢不管用哪種ThreadLocal核心原則都是誰set的誰負責(zé)remove且必須在finally里。CONTEXT.set(userInfo); try { // 業(yè)務(wù)邏輯 } finally { CONTEXT.remove(); // 必須放在 finally }幾個容易忽略的點remove要放在最外層。如果業(yè)務(wù)邏輯里又嵌套了set要確保最終能清干凈。線程池任務(wù)里remove要放在任務(wù)執(zhí)行的finally而不是提交任務(wù)的線程里。因為值是在執(zhí)行線程上提交線程remove沒用。不要依賴GC自動清理。雖然ThreadLocalMap在get/set時會順帶清理key為null的Entry但那是碰運氣式的清理不保證及時也不保證覆蓋所有Entry。提示如果你用的是增強版的上下文傳遞方案快照-回填-恢復(fù)那套恢復(fù)步驟本身就起到了清理作用通常不需要額外remove。但前提是框架實現(xiàn)正確自己手寫的話要格外小心。6. 選型決策三種場景對應(yīng)三種方案6.1 場景對照表把前面講的串起來實際項目里怎么選看這張表就夠了場景線程創(chuàng)建方式推薦方案理由一次性創(chuàng)建子線程用完即棄new Thread()InheritableThreadLocal繼承時機正好無復(fù)用問題線程池任務(wù)需要獨立上下文線程池復(fù)用增強傳遞方案快照-回填-恢復(fù)繞開創(chuàng)建時機限制任務(wù)級隔離線程池但上下文全局只讀線程池復(fù)用普通ThreadLocal 啟動時初始化不需要傳遞直接全局配置父子線程都要寫同一對象任意重寫childValue做深拷貝避免共享可變狀態(tài)6.2 為什么不能一招鮮很多人問既然增強方案能解決線程池問題那是不是所有場景都用它就行了不一定。增強方案有額外開銷每次任務(wù)提交要抓快照每次執(zhí)行要回填和恢復(fù)涉及對象拷貝和Map操作。對于高頻短任務(wù)這個開銷占比可能不小。而InheritableThreadLocal在一次性子線程場景下幾乎零成本因為繼承就發(fā)生在線程創(chuàng)建那一下。所以選型的核心是問自己三個問題線程是復(fù)用的嗎復(fù)用就走增強方案不復(fù)用可以考慮原生繼承。上下文需要跨任務(wù)隔離嗎需要隔離就必須在任務(wù)邊界做快照和恢復(fù)。上下文對象可變嗎可變就得考慮深拷貝不可變就省事了。6.3 一個容易忽略的替代思路有時候我們其實不需要繼承而是需要顯式傳遞。比如把上下文作為參數(shù)一路傳下去雖然代碼啰嗦但最清晰、最不容易出錯。很多團隊一上來就上ThreadLocal結(jié)果調(diào)試時根本不知道值從哪來、被誰改了。我的經(jīng)驗是能用參數(shù)傳的就別用ThreadLocal。只有當(dāng)傳遞鏈路太長比如跨十幾層調(diào)用、或者框架回調(diào)無法加參數(shù)時才考慮用ThreadLocal這類隱式傳遞機制。隱式傳遞是把雙刃劍用好了省事用不好就是排查噩夢。7. 幾個實測中踩出來的經(jīng)驗最后分享幾個我在實際項目里踩過的坑都是文檔里不會寫的。第一個坑以為繼承了就一勞永逸。有次寫了個工具類在靜態(tài)初始化塊里set了配置然后指望所有子線程都能讀到。結(jié)果線程池里的線程讀不到因為線程池創(chuàng)建時機早于靜態(tài)塊執(zhí)行。后來改成在每次任務(wù)提交時顯式傳遞才解決。教訓(xùn)是繼承的時機是線程創(chuàng)建不是值設(shè)置這兩個時間點必須對齊。第二個坑子線程改了對象父線程跟著變。前面講過的淺拷貝問題。當(dāng)時上下文里放了個Map子線程往里塞了個臨時數(shù)據(jù)結(jié)果父線程讀到了臟數(shù)據(jù)。排查了半天才想到是共享引用。后來把上下文對象改成不可變問題根除。第三個坑忘記remove導(dǎo)致內(nèi)存緩慢增長。一個長跑服務(wù)跑了一周后堆內(nèi)存居高不下。dump下來發(fā)現(xiàn)大量ThreadLocalMap$Entryvalue是請求對象。原因是某段代碼在set之后沒remove而線程池線程長期存活。加上finally { remove(); }后內(nèi)存曲線立刻平穩(wěn)。第四個坑在子線程里remove父線程的值。這個錯誤比較低級但真有人犯在子線程里調(diào)CONTEXT.remove()以為能清掉父線程的。實際上remove只作用于當(dāng)前線程的Map父線程的值紋絲不動。要清父線程的得在父線程里remove。第五個坑線程池的ThreadFactory沒考慮繼承。自定義ThreadFactory時如果在線程創(chuàng)建前沒有正確設(shè)置父線程的上下文繼承到的就是空值。而且不同線程創(chuàng)建時機不同繼承到的值可能不一致導(dǎo)致行為隨機。這種問題最難查因為時好時壞。說到底InheritableThreadLocal是個看起來簡單、用起來有邊界的工具。它的繼承語義很清晰但邊界條件線程創(chuàng)建時機、線程復(fù)用、可變對象、內(nèi)存泄漏才是真正決定成敗的地方。把這幾條吃透你在選型和排錯時就能少走很多彎路。