一篇文章看懂 ThreadLocal 原理,記憶體泄露,缺點以及執行緒池復用的值傳遞問題
- 2020 年 4 月 8 日
- 筆記
編輯:業餘草 來源:https://www.xttblog.com/?p=4946
一篇文章看懂 ThreadLocal 原理,記憶體泄露,缺點以及執行緒池復用的值傳遞問題。

ThreadLocal 相信不少人都用過,也看過不少相關的教程。但我還是想補充一些 ThreadLocal 的原理,記憶體泄露,缺點以及執行緒池復用的值傳遞問題。
執行緒關聯的原理
ThreadLocal 並不是一個獨立的存在, 它與 Thread 類是存在耦合的, java.lang.Thread 類針對 ThreadLocal 提供了如下支援:
/* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class. */ ThreadLocal.ThreadLocalMap threadLocals = null;
每個執行緒都將自己維護一個 ThreadLocal.ThreadLocalMap 類在上下文中; 所以, ThreadLocal 的 set 方法其實是將 target value 放到當前執行緒的 ThreadLocalMap 中, 而 ThreadLocal 類自己僅僅作為該 target value 所對應的 key:
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); }
get 方法也是類似的道理, 從執行緒的 ThreadLocalMap 中獲取以當前 ThreadLocal 為 key 對應的 value:
public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); }
需要注意的是, 如果沒有 set 過 value, 此處 get() 將返回 null, 不過 initialValue() 方法是一個 protected 方法, 所以子類可以重寫邏輯實現自定義的初始默認值。
private T setInitialValue() { T value = initialValue(); Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); return value; } protected T initialValue() { return null; }
綜上所述: ThreadLocal 實現執行緒關聯的原理是與 Thread 類綁定, 將數據存儲在對應 Thread 的上下文中。

下面說說 ThreadLocal 在使用過程中需要主要的兩個地方。
謹防 ThreadLocal 導致的記憶體泄露和 OOM
討論這個問題之前, 需要先介紹一下 ThreadLocal.ThreadLocalMap 類中維護了的一個自定義數據結構 Entry, 其定義如下:
static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
這裡要注意的是, Entry 類繼承了弱引用 WeakReference, 更具體的說, Entry 中的 key (ThreadLocal 類型) 使用弱引用, value 依舊使用強引用。
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.
這其實是一個令初學者感到困惑的設計: 假設 Entry 不繼承 WeakReference, 令 key 也使用強引用, 那麼結合上一節的內容, 只要該 thread 不退出, 通過 Thread -> ThreadLocal.ThreadLocalMap -> key 這條引用鏈, 該 key 就可以一直與 gc root 保持連通; 這時即便在外部這個 key 對應的 threadLocal 已經沒有有效引用鏈了, 但只要該 thread 不退出, jvm 依舊會判定該 threadlocal 不可回收。
於是尷尬的事情發生了: 由於 ThreadLocal.ThreadLocalMap 這個內部類沒有對外暴露 public 方法, 在 Thread 類裡面 ThreadLocal.ThreadLocalMap 也是 package accessible 的, 這意味著我們已經沒有任何方法訪問到該 key 對應的 value 了, 可它就是無法被回收, 這便是一個典型的記憶體泄露。
而如果使用 WeakReference 這個問題就解決了: 當該 key 對應的 threadlocal 在外部已經失效後, 便僅存在 thread 里的 weak reference 指向它, 下次 gc 時這個 key 就會被回收掉。
針對這一特性, ThreadLocal.ThreadLocalMap 也配套了與之相適應的內部清理方法:
private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // expunge entry at staleSlot tab[staleSlot].value = null; tab[staleSlot] = null; size--; // Rehash until we encounter null Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode & (len - 1); if (h != i) { tab[i] = null; // Unlike Knuth 6.4 Algorithm R, we must scan until null because multiple entries could have been stale. while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; }
在該方法里, 除了清理指定下標 staleSlot 的 entry 外, 還會遍歷整個 entry table, 當發現有 key 為 null 時, 就會觸發 rehash 壓縮整個 table, 以達到清理的作用。
下面就要提到這裡的一個隱藏的坑, ThreadLocal 並沒有配合使用 ReferenceQueue 來監聽已經回收的 key 以實現自動回調 expungeStaleEntry 方法清理空間的功能; 所以 threadlocal 實例是回收了, 但是引用本身還在, 其所對應的 value 也就還在:
However, since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space.
實際上, expungeStaleEntry 方法是被安插到了 ThreadLocal.ThreadLocalMap 中的 get, set, remove 等方法中, 並被 ThreadLocal 的 get, set, remove 方法間接調用, 必須顯式得調用這些方法, 才能主動式地清理空間。
在某些極端場景下, 如果某些 threadlocal 設置的 value 是大對象, 而所涉及的 thread 卻沒來得及在 threadlocal 被 gc 前作 remove, 再加上之後也沒有什麼其他 threadlocal 去作 get / set 操作, 那這些大對象是沒機會被回收的, 這將造成嚴重的記憶體泄露甚至是 OOM。所以使用 ThreadLocal 要謹記一點: 用完主動 remove, 主動釋放記憶體, 而且是放在 finally 塊裡面 remove, 以確保執行。
在很多系統中, 我們會定義一個 static final 的全局 ThreadLocal, 這樣其實就不存在 threadlocal 被回收的情況了, 上面說的 WeakReference 機制也將效用有限, 這種環境下我們就更加需要用完後主動作 remove 了。
謹防執行緒復用組件下的 value 串位
在下一節中我還會繼續講到 value 串位的問題; 這一節所講的串位與下一節相比, 有相似之處也有不同的問題場景; 與此同時, 這一節的串位與上一小節的內容也有一絲關聯。
通常而言, 我們的程式碼總是跑在應用容器里, 如 tomcat, jetty, 或者是 dubbo 這樣的服務框架內; 這些基礎組件都有一個共性: 執行緒池化復用; 在這種場景下, 執行緒被執行緒池託管, 在整個應用的生命周期中, 這些 worker 執行緒往往是不會輕易退出的。
試想一種極端場景: 在一個處理執行緒內, 我們條件性得 (並非每次都會) 使用 ThreadLocal.set 方法設置一個 value, 然後在後續邏輯中又使用 ThreadLocal.get 方法獲取該值; 一個處理執行緒在上一個任務執行結束之前未作 ThreadLocal.remove 清理 value, 剛巧這個執行緒在接手下一個任務時未滿足條件, 沒有調用 ThreadLocal.set 方法設置 value, 此時它所綁定的是上一個任務的 value, 在後面調用 ThreadLocal.get 時, 拿到的就是串位的數據了。
這也再一次提醒我們: 使用 ThreadLocal, 在邏輯處理完後, 一定要作 remove。
InheritableThreadLocal 的特點及其使用問題
首先要說的是, 上文所講的 ThreadLocal 的問題與注意點, 對 InheritableThreadLocal 都是成立的, 這裡便不再贅述。
與 ThreadLocal 類似, InheritableThreadLocal 類也不是獨立存在的, Thread 類針對 InheritableThreadLocal 作了如下支援:
/* * InheritableThreadLocal values pertaining to this thread. This map is * maintained by the InheritableThreadLocal class. */ ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
只是, InheritableThreadLocal 要額外實現子執行緒傳遞 threadlocal 的任務, 所以 Thread 類在構造方法中還提供了額外的支援以將父執行緒的 ThreadLocalMap 傳遞給子執行緒。
public Thread() { init(null, null, "Thread-" + nextThreadNum(), 0); } private void init(ThreadGroup g, Runnable target, String name, long stackSize) { init(g, target, name, stackSize, null, true); } /* * @param inheritThreadLocals if {@code true}, inherit initial values for inheritable thread-locals from the constructing thread */ private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { ...... if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); }
下面要說的是 InheritableThreadLocal 在執行緒復用組件下的串位問題。
上一小節所講的 ThreadLocal 的 value 串位問題, 對於 InheritableThreadLocal 來說也是存在的, 這點自不必說; 然對於 InheritableThreadLocal 所提供的額外功能 父子執行緒傳遞 value 來說, 還有一種執行緒復用場景, 會遇到類似的坑。
在 jdk 1.5 之前我們沒有執行緒池的時候, 子執行緒的創建都是手工及時完成的, 那種場景下父子執行緒的關係是唯一綁定的, 絕對不會出現 value 串位的問題; 然而 Doug Lea 大神開發了 ThreadPoolExecutor, 這徹底改變了我們使用多執行緒的習慣, 它不僅僅在各種容器中出現, 我們的日常程式碼中凡涉及多執行緒的地方, 大多也會採用執行緒池的方式實現。
那麼問題來了: 在執行緒池中, worker 執行緒是被複用的, worker 執行緒的父執行緒是誰並沒有人關心, 反正 worker 執行緒的父執行緒大多數都比 worker 執行緒本身要短命許多; 而執行緒的初始化只發生在其創建的時候, 根據上面的內容, InheritableThreadLocal 傳遞 value 只發生在子執行緒初始化的時候, 也就是執行緒剛創建的時候; 所以, 往執行緒池中提交任務的時候, 除非是執行緒池剛好創建了一個新執行緒, 才能順利得將 value 傳遞下去, 否則大多數時候都只是復用已經存在的執行緒, 那執行緒中的 value 早已不是當前執行緒想要傳遞的值。
改進 InheritableThreadLocal 的方案
InheritableThreadLocal value 串位問題的根本原因在於它依賴 Thread 類本身的機制傳遞 value, 而 Thread 類由於其於執行緒池內 「復用存在」 的形式而導致 InheritableThreadLocal 的機制失效; 所以針對 InheritableThreadLocal 的改進, 突破點就在於如何擺脫對 Thread 類的依賴。
現在業界內比較好的解決思路是將對 Thread 類的依賴轉移為對 Runnable / Callable 的依賴, 因為提交任務時 Runnable / Callable 是實時構造出來的, 父執行緒可以在其構造之時將 value 植入其中。
下面以阿里為例, 介紹一種典型的實現; 阿里巴巴開源了其對 InheritableThreadLocal 的改進方案: alibaba/transmittable-thread-local。
縱觀其源碼, TransmittableThreadLocal 的核心設計之一在於其自己維護了一個靜態全局的 holder, 存儲了所有的 TransmittableThreadLocal 實例:
static ThreadLocal<Map<TransmittableThreadLocal<?>, ?>> holder = new ThreadLocal<Map<TransmittableThreadLocal<?>, ?>>() { @Override protected Map<TransmittableThreadLocal<?>, ?> initialValue() { return new WeakHashMap<TransmittableThreadLocal<?>, Object>(); } };
這裡的一個設計細節是, 其使用 WeakHashMap 作為存儲 TransmittableThreadLocal 實例的容器; 這裡與上文所講的 ThreadLocal.ThreadLocalMap.Entry 使用 WeakReference 作為 key 的原理是類似的, 可以便捷得發現已經無效的 threadlocal, 而且 WeakHashMap 使用了 ReferenceQueue 去監聽 key 的 gc 情況, 不用像 ThreadLocal 那樣每次需要遍歷全表以尋找 stale entries。同時, TransmittableThreadLocal 提供一個 copy() 方法實時複製所有 TransmittableThreadLocal 實例及其在當前執行緒的 value:
static Map<TransmittableThreadLocal<?>, Object> copy() { Map<TransmittableThreadLocal<?>, Object> copy = new HashMap<TransmittableThreadLocal<?>, Object>(); for (TransmittableThreadLocal<?> threadLocal : holder.get().keySet()) { copy.put(threadLocal, threadLocal.copyValue()); } return copy; }
TransmittableThreadLocal 的另一個核心設計是它封裝了自己的 Runnable 和 Callable; 以其封裝的 TtlRunnable 為例, 其提供了一個 private 類型的構造器:
private TtlRunnable(Runnable runnable, boolean releaseTtlValueReferenceAfterRun) { this.copiedRef = new AtomicReference<Map<TransmittableThreadLocal<?>, Object>>(TransmittableThreadLocal.copy()); this.runnable = runnable; this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun; }
可以發現, 在 TtlRunnable 構造之初, 除了包裝原始的 Runnable 之外, 其複製了當前執行緒下所有的 TransmittableThreadLocal 實例及其對應的 value, 放到了一個 AtomicReference 包裝的 map 之中, 這樣就完成了由父執行緒向 Runnable 的 value 傳遞。下面是最關鍵的 run() 方法的處理:
public void run() { Map<TransmittableThreadLocal<?>, Object> copied = copiedRef.get(); // 非核心邏輯已省略 ...... Map<TransmittableThreadLocal<?>, Object> backup = TransmittableThreadLocal.backupAndSetToCopied(copied); try { runnable.run(); } finally { TransmittableThreadLocal.restoreBackup(backup); } }
拿到父執行緒所有的 threadlocal -> value 鍵值對後, 需要將其一一設置到自己的 ThreadLocal 中:
static Map<TransmittableThreadLocal<?>, Object> backupAndSetToCopied(Map<TransmittableThreadLocal<?>, Object> copied) { Map<TransmittableThreadLocal<?>, Object> backup = new HashMap<TransmittableThreadLocal<?>, Object>(); for (Iterator<? extends Map.Entry<TransmittableThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator(); iterator.hasNext(); ) { Map.Entry<TransmittableThreadLocal<?>, ?> next = iterator.next(); TransmittableThreadLocal<?> threadLocal = next.getKey(); backup.put(threadLocal, threadLocal.get()); if (!copied.containsKey(threadLocal)) { iterator.remove(); threadLocal.superRemove(); } } // 將 runnable 攜帶的父執行緒 threadlocal -> value 鍵值對, 真正用 ThreadLocal.set 將 value 設置到子執行緒中去 for (Map.Entry<TransmittableThreadLocal<?>, Object> entry : copied.entrySet()) { @SuppressWarnings("unchecked") TransmittableThreadLocal<Object> threadLocal = (TransmittableThreadLocal<Object>) entry.getKey(); threadLocal.set(entry.getValue()); } doExecuteCallback(true); return backup; }
接下來在調用原始 Runnable 的 run() 方法時, 便能夠順利 get 到父執行緒的 value 了。
以上關於 ThreadLocal 的內容就介紹完了,建議大家收藏起來多看幾遍。