執行緒本地存儲 ThreadLocal
執行緒本地存儲提供了執行緒記憶體儲變數的能力,這些變數是執行緒私有的。
執行緒本地存儲一般用在跨類、跨方法的傳遞一些值。
執行緒本地存儲也是解決特定場景下執行緒安全問題的思路之一(每個執行緒都訪問本執行緒自己的變數)。
Java 語言提供了執行緒本地存儲,ThreadLocal 類。
ThreadLocal 的使用及注意事項
public class TestClass {
public static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
// 設置值
threadLocal.set(1);
test();
}
private static void test() {
// 獲取值,返回 1
threadLocal.get();
// 防止記憶體泄漏
threadLocal.remove();
}
}
static 修飾的變數是在類在載入時就分配地址了,在類卸載才會被回收,因此使用 static 的 ThreadLocal,延長了 ThreadLocal 的生命周期,可能會導致記憶體泄漏。
分配使用了 ThreadLocal,又不調用 get()、set()、remove() 方法,並且當前執行緒遲遲不結束的話,那麼就會導致記憶體泄漏。
ThreadLocal 的 set() 過程
每一個 Thread 實例對象中,都會有一個 ThreadLocalMap 實例對象;
ThreadLocalMap 是一個 Map 類型,底層數據結構是 Entry 數組;
一個 Entry 對象中又包含一個 key 和 一個 value
- key 是 ThreadLocal 實例對象的弱引用
- value 就是通過 ThreadLocal#set() 方法實際存儲的值
static class Entry extends WeakReference<ThreadLocal<?>> {
/**
* The value associated with this ThreadLocal.
*/
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
下面我們通過源碼分析 ThreadLocal#set() 的過程。
- 獲取當前執行緒
- 獲取當前執行緒的 ThreadLocalMap
- 將存儲的值設置到 ThreadLocalMap
public void set(T value) {
// 獲取當前執行緒
Thread t = Thread.currentThread();
// 獲取當前執行緒的 ThreadLocalMap
ThreadLocal.ThreadLocalMap map = getMap(t);
if (map != null) {
// 將存儲的值設置到 ThreadLocalMap
map.set(this, value);
} else {
// 首次設置存儲的值,需要創建 ThreadLocalMap
createMap(t, value);
}
}
ThreadLocalMap 的記憶體泄露
介紹記憶體泄漏
記憶體泄漏(Memory leak)
本質上,記憶體泄漏可以定義為:當進程不再需要某些記憶體的時候,這些不再被需要的記憶體依然沒有被進程回收。
造成記憶體泄漏的原因:不再需要(沒有作用)的實例對象依然存在著強引用關係,無法被垃圾收集器回收
記憶體泄露的原因分析
ThreadLocalMap 是一個 Map 類型,底層數據結構是 Entry 數組;
一個 Entry 對象的 key 是 ThreadLocal 實例對象的弱引用。
一個對象如果只剩下弱引用,則該對象在垃圾收集時就會被回收
ThreadLocalMap 使用 ThreadLocal 實例對象的弱引用作為 key 時,如果一個 ThreadLocal 實例對象沒有強引用引用它,比如手動將 ThreadLocal A 這個對象賦值為 null,那麼系統垃圾收集時,這個 ThreadLocal A 勢必會被回收,這樣一來 ThreadLocalMap 中就出現了 key 為 null 的 Entry,Java 程式沒有辦法訪問這些 key 為 null 的 Entry,故沒有辦法刪除 Entry 對 value 的強引用,則這個 value 無法被回收,直到執行緒的生命周期結束。
- 如果當前執行緒遲遲不結束的話(比如使用了執行緒池,或者當前執行緒還在執行其他耗時的任務)那麼這些 key 為 null 的 Entry 的 value 就會一直存在一條強引用鏈,導致 value 無法被回收。
- 只有當前執行緒結束以後,ThreadRef 就不存在於棧中了,強引用斷開,Thread 對象、ThreadLocalMap 對象、Entry 數組、Entry 對象、value 依次回收。
造成記憶體泄漏的原因是:由於 ThreadLocalMap 的生命周期跟 Thread 一樣長,當 Thread 的生命周期過長時,導致 value 無法回收,而不是因為弱引用。
- Entry 對象的 key 是 ThreadLocal 實例對象的弱引用,造成 value 無法被回收。實際是 ThreadLocalMap 的設計中,已經考慮到了這種情況,也加上了一些防護措施,我們在下面記憶體泄漏的解決辦法中介紹。
- 如果 Entry 對象的 key 是 ThreadLocal 實例對象的強引用的話,那麼會造成 key 和 value 都無法被回收。
強引用鏈如下圖紅線所示:
強引用鏈的表述如下:
ThreadRef 引用 Thread,Thread 引用 ThreadLocalMap,ThreadLocalMap 引用 Entry,Entry 引用 value
記憶體泄露的解決辦法
Entry 對象的 key 是 ThreadLocal 實例對象的弱引用,造成 value 無法被回收。
實際是 ThreadLocalMap 的設計中,已經考慮到了這種情況,也加上了一些防護措施。
在調用 ThreadLocal 的 get()、set() 方法操作數據,從指定位置開始遍歷 Entry 時,會找到 Entry 不為 null,但 key 為 null 的 Entry,並刪除 key 為 null 的 Entry 的 value 和對應的 Entry。
但是,如果 ThreadLocal 實例對象的強引用被刪除後,執行緒長時間存活,又沒有再對該執行緒的 ThreadLocalMap 實例對象進行操作,也就是沒有再調用 get()、set() 方法,那麼依然會存在記憶體泄漏。
所以,避免記憶體泄漏最好的做法是:主動調用 ThreadLocal 對象的 remove() 方法,將設置的執行緒本地變數的值刪除。
/**
* Remove the entry for key.
*/
private void remove(ThreadLocal<?> key) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
get()、set()、remove() 實際都會調用 ThreadLocalMap#expungeStaleEntry() 方法來進行刪除 Entry,下面我們來看一下程式碼實現。
// 入參 staleSlot 是當前被刪除對象在 Entry 數組中的位置
private int expungeStaleEntry(int staleSlot) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// 刪除 staleSlot 位置的 value,key 已經在進入該方法前刪除了 / 已經被回收
// expunge entry at staleSlot
tab[staleSlot].value = null;
// 將 Entry 對象賦值為 null,斷開 Entry 實例對象的強引用
tab[staleSlot] = null;
// Entry 數組大小 - 1
size--;
// Rehash until we encounter null
ThreadLocal.ThreadLocalMap.Entry e;
int i;
// for 循環的作用是從當前位置開始向後循環處理 Entry 中的 ThreadLocal 對象
// 將從指定位置開始,遇到 null 之前的所有 ThreadLocal 對象 rehash
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
// 獲取 ThreadLocal 的虛引用引用的實例對象
ThreadLocal<?> k = e.get();
if (k == null) {
// 虛引用引用的實例對象為 null,說明 ThreadLocal 已經被回收了
// 則刪除 value 和 Entry,讓虛擬機能夠回收
e.value = null;
tab[i] = null;
size--;
} else {
// rehash
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
// 從當前 h 的位置向後找,找到一個 null 的位置將 e 填入
// 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;
}
ThreadLocalMap 的哈希衝突
ThreadLocalMap 里處理 hash 衝突的機制不是像 HashMap 一樣使用鏈表(拉鏈法)。
它採用的是另一種經典的處理方式,沿著衝突的索引向後查找空閑的位置(開放定址法中的線性探測法)。
下面我們通過 ThreadLocal 的 set()、get() 方法源碼,分析 ThreadLocalMap 的哈希衝突解決方案。
// set() 的關鍵方法,被 set(Object value) 調用
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// 計算 key 在數組中的下標,其實就是 ThreadLocal 的 hashCode 和 數組大小-1 取余
int i = key.threadLocalHashCode & (len - 1);
// 整體策略:查看 i 索引位置有沒有值,有值的話,索引位置 + 1,直到找到沒有值的位置
// 這種解決 hash 衝突的策略,也導致了其在 get 時查找策略有所不同,體現在 getEntryAfterMis
// nextIndex() 就是讓在不超過數組長度的基礎上,把數組的索引位置 + 1
for (ThreadLocal.ThreadLocalMap.Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 找到記憶體地址一樣的 ThreadLocal,直接替換
// 即,修改執行緒本地變數
if (k == key) {
e.value = value;
return;
}
// 當前 key 是 null,說明 ThreadLocal 被清理了,直接替換掉並返回
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 當前 i 位置是無值的,可以被當前 thradLocal 使用
tab[i] = new ThreadLocal.ThreadLocalMap.Entry(key, value);
int sz = ++size;
// 當數組大小大於等於擴容閾值(數組大小的三分之二)時,進行擴容
if (!cleanSomeSlots(i, sz) && sz >= threshold) {
rehash();
}
}
上面源碼我們注意幾點:
- 是通過遞增的 AtomicInteger 作為 ThreadLocal 的 hashCode 的;
- 計算數組索引位置的公式是:hashCode 取模 數組大小-1,由於 hashCode 不斷自增,所以不同的 hashCode 大概率上會計算到同一個數組的索引位置(但這個不用擔心,在實際項目中,ThreadLocal 都很少,基本上不會衝突);
- 通過 hashCode 計算的索引位置 i 處如果已經有值了,會從 i 開始,通過 +1 不斷的往後尋找,直到找到索引位置為空的地方,把當前 ThreadLocal 作為 key 放進去。
// get 的關鍵方法,被 get() 方法調用
// 得到當前 thradLocal 對應的值,值的類型是由 thradLocal 的泛型決定的
// 首先嘗試根據 hashcode 取模 數組大小-1 = 索引位置 i 尋找,找不到的話,自旋把 i+1,直到找到
private ThreadLocal.ThreadLocalMap.Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
ThreadLocal.ThreadLocalMap.Entry e = table[i];
// e 不為空,並且 e 的 ThreadLocal 的記憶體地址和 key 相同,直接返回,否則就是沒有找到,繼續尋找
if (e != null && e.get() == key) {
return e;
} else {
// 這個取數據的邏輯,是因為 set 時數組索引位置衝突造成的
return getEntryAfterMiss(key, i, e);
}
}
// 自旋 i+1,直到找到為止
private ThreadLocal.ThreadLocalMap.Entry getEntryAfterMiss(ThreadLocal<?> key, int i, ThreadLocal.ThreadLocalMap.Entry e) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
// 記憶體地址一樣,表示找到了
if (k == key) {
return e;
}
// 刪除不再使用的 Entry,避免記憶體泄漏
if (k == null) {
expungeStaleEntry(i);
} else {
// 繼續使索引位置 + 1
i = nextIndex(i, len);
}
e = tab[i];
}
return null;
}
ThreadLocalMap 的擴容策略
// set() 的部分源碼
if (!cleanSomeSlots(i, sz) && sz >= threshold){
rehash();
}
// 稱為啟發式清理,從指定下標開始遍歷
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
ThreadLocal.ThreadLocalMap.Entry e = tab[i];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
private void rehash() {
// 探測式清理,從數組的下標為 0 處開始遍歷,清理所有無用的 Entry
expungeStaleEntries();
// 擴容使用較低的閾值,以避免遲滯
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
由上面源碼我們可以看出,ThreadLocalMap 擴容的時機是,ThreadLocalMap 中的 ThreadLocal 的個數超過閾值,並且 cleanSomeSlots() 返回 false(啟發式清理),然後嘗試清理所有 key 為 null 的 Entry,清理完之後 ThreadLocal 的個數仍然大於閾值的四分之三,ThreadLocalMap 就要開始擴容了, 我們一起來看下擴容的邏輯:
// 擴容
private void resize() {
// 拿出舊的數組
ThreadLocal.ThreadLocalMap.Entry[] oldTab = table;
int oldLen = oldTab.length;
// 新數組的大小為老數組的兩倍
int newLen = oldLen * 2;
// 初始化新數組
ThreadLocal.ThreadLocalMap.Entry[] newTab = new ThreadLocal.ThreadLocalMap.Entry[newLen];
int count = 0;
// 老數組的值拷貝到新數組上
for (int j = 0; j < oldLen; ++j) {
ThreadLocal.ThreadLocalMap.Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
// 計算 ThreadLocal 在新數組中的位置
int h = k.threadLocalHashCode & (newLen - 1);
// 如果索引 h 的位置值不為空,往後+1,直到找到值為空的索引位置
while (newTab[h] != null)
h = nextIndex(h, newLen);
// 給新數組賦值
newTab[h] = e;
count++;
}
}
}
// 給新數組初始化下次擴容閾值,為數組長度的三分之二
setThreshold(newLen);
size = count;
table = newTab;
}
源碼註解也比較清晰,我們注意兩點:
- 擴容後數組大小是原來數組的兩倍,下一次的擴容閾值為數組長度的三分之二;
- 擴容時是沒有執行緒安全問題的,因為 ThreadLocalMap 是執行緒的一個屬性,一個執行緒同一時刻只能對 ThreadLocalMap 進行操作,因為同一個執行緒執行業務邏輯必然是串列的,那麼操作 ThreadLocalMap 必然也是串列的。
ThreadLocalMap 擴容策略的語言描述:
在 ThreadLocalMap.set() 方法的最後,如果執行完啟發式清理工作後,未清理到任何 Entry,且當前數組中 Entry 的數量已經達到了擴容閾值(數組長度的三分之二),就開始執行 rehash() 邏輯。
rehash() 首先是會進行探測式清理工作,從數組的起始位置開始遍歷,查找 key 為 null 的 Entry 並清理。清理完成之後如果 ThreadLocal 的個數仍然大於等於擴容閾值的四分之三,那麼就進行擴容操作,擴容為原來數組長度的兩倍,並且設置下一次的擴容閾值為新數組長度的三分之二。
InheritableThreadLocal 與繼承性
通過 ThreadLocal 創建的執行緒變數,其子執行緒是無法繼承的。
也就是說你在執行緒中通過 ThreadLocal 創建了執行緒變數 V,而後該執行緒創建了子執行緒,你在子執行緒中是無法通過 ThreadLocal 來訪問父執行緒的執行緒變數 V 的。
public class TestClass {
public static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
threadLocal.set(1);
// 返回 1
threadLocal.get();
new Thread(new Runnable() {
@Override
public void run() {
// 返回 null
threadLocal.get();
}
}).start();
}
}
如果你需要子執行緒繼承父執行緒的執行緒變數,那該怎麼辦呢?
JDK 的 InheritableThreadLocal 類可以完成父執行緒到子執行緒的值傳遞。
InheritableThreadLocal 是 ThreadLocal 子類,所以用法和 ThreadLocal 相同。
使用時,改為 ThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();
即可。
InheritableThreadLocal 在創建子執行緒的時候(初始化執行緒時),在 Thread#init() 方法中拷貝父執行緒中本地變數的值到子執行緒的本地變數中,子執行緒就擁有了和父執行緒一樣的本地變數。
下面是 Thread#init() 中,和 ThreadLocal 相關的程式碼,我們一起來看下這個功能是怎麼實現的
public class Thread implements Runnable {
// 如果是使用 ThreadLocal 進行 set(),則使用該變數保存
ThreadLocal.ThreadLocalMap threadLocals = null;
// 如果是使用 InheritableThreadLocal 進行 set(),則使用該變數保存
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc) {
// ...
Thread parent = currentThread();
// ...
if (parent.inheritableThreadLocals != null) {
// 根據 parent.inheritableThreadLocals 重新 new 一個 ThreadLocalMap 對象
this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}
// ...
}
}
不過,完全不建議你在執行緒池中使用 InheritableThreadLocal,不僅僅是因為它具有 ThreadLocal 相同的缺點:可能導致記憶體泄露,更重要的原因是:執行緒池中執行緒的創建是動態的,很容易導致繼承關係錯亂,如果你的業務邏輯依賴 InheritableThreadLocal,那麼很可能導致業務邏輯計算錯誤,而這個錯誤往往比記憶體泄露更要命。
同時,如果父執行緒的本地變數是引用數據類型的話,父子執行緒共享相同的數據,存在執行緒安全問題,甚至導致業務邏輯計算錯誤。要想做到父子執行緒的本地變數互不影響,則需要繼承 InheritableThreadLocal 並重寫 childValue() 方法實現對象的深拷貝 。
並且對於使用執行緒池等會池化復用執行緒的執行組件的情況,執行緒由執行緒池創建好,並且執行緒是池化起來反覆使用的;這時父子執行緒關係的 ThreadLocal 值傳遞已經沒有意義,應用需要的實際上是把任務提交給執行緒池時的ThreadLocal 值傳遞到任務執行時。阿里開源的 TransmittableThreadLocal 類繼承並加強 InheritableThreadLocal 類,解決上述的問題。
TransmittableThreadLocal
TransmittableThreadLocal 的 GitHub://github.com/alibaba/transmittable-thread-local
TransmittableThreadLocal 的 API 文檔://alibaba.github.io/transmittable-thread-local
TransmittableThreadLocal 是阿里開源的一個增強 InheritableThreadLocal 的庫。
TransmittableThreadLocal 的功能:在使用執行緒池等會池化復用執行緒的執行組件情況下,提供 ThreadLocal 值的傳遞功能,解決非同步執行時上下文傳遞的問題。
TTL 的使用及注意事項
TTL 的 User Guide://github.com/alibaba/transmittable-thread-local#-user-guide
TransmittableThreadLocal 有三種使用方式(具體使用見 GitHub 的 README):
- 修飾 Runnable 或 Callable
- 修飾執行緒池
- 使用 Java Agent 來修飾 JDK 執行緒池實現類
注意事項:
使用 TtlRunnable 和 TtlCallable 來修飾傳入執行緒池的 Runnable 和 Callable 時,即使是同一個 Runnable 任務多次提交到執行緒池時,每次提交時都需要通過修飾操作(即TtlRunnable.get(task))以抓取這次提交時的 TransmittableThreadLocal 上下文的值;即如果同一個任務下一次提交時不執行修飾而仍然使用上一次的 TtlRunnable,則提交的任務運行時會是之前修飾操作所抓取的上下文。
修飾執行緒池其實本質上也是修飾 Runnable,只是將這個邏輯移到了 ExecutorServiceTtlWrapper.submit() 方法內,對所有提交的 Runnable 進行修飾。
public class Main {
static int val = 0;
public static void main(String[] args) {
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal();
ExecutorService executorService = Executors.newFixedThreadPool(1);
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("child thread get " + ttl.get());
}
};
for (int i = 0; i < 5; i++) {
val++;
ttl.set("value-set-in-parent " + val);
executorService.execute(TtlRunnable.get(task));
}
executorService.shutdown();
}
}
TTL 的原理
TTL 做的是,使用裝飾器模式裝飾 Runnable 等任務,將原本與 Thread 綁定的執行緒變數,快取一份到 TtlRunnable 對象中,每次調用任務的 run() 前後進行 set() 和還原數據。
TTL 的需求場景
總結
使用 ThreadLocal 庫友好地解決了執行緒本地存儲的問題,但是它還存在父子執行緒值傳遞丟失的問題,於是 JDK 又引入了 InheritableThreadLocal 對象。
InheritableThreadLocal 的出現又引出了下一個問題,那就是涉及到執行緒池等復用執行緒場景時,還是會存在變數複製混亂的缺陷。阿里巴巴提供了解決方案,用 TransmittableThreadLocal 來增強 InheritableThreadLocal 對象。
參考資料
30 | 執行緒本地存儲模式:沒有共享,就沒有傷害-極客時間 (geekbang.org)
ThreadLocal原理分析及記憶體泄漏演示-極客時間 (geekbang.org)
ThreadLocal如何在父子執行緒及執行緒池中傳遞?-極客時間 (geekbang.org)
//github.com/alibaba/transmittable-thread-local