執行緒本地存儲 ThreadLocal

執行緒本地存儲 · 語雀 (yuque.com)

執行緒本地存儲提供了執行緒記憶體儲變數的能力,這些變數是執行緒私有的。

執行緒本地存儲一般用在跨類、跨方法的傳遞一些值。

執行緒本地存儲也是解決特定場景下執行緒安全問題的思路之一(每個執行緒都訪問本執行緒自己的變數)。

Java 語言提供了執行緒本地存儲,ThreadLocal 類。

1659940416781-c0e6634b-3e1d-4982-8dd4-df6dddeaf0c7.jpeg

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() 過程

1658590040855-f69d5460-ffa7-49b9-9f49-e93b9988977b.png

每一個 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

1658636013370-07d35558-51e5-4188-aaca-d7bbaa115f11.png

記憶體泄露的解決辦法

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

上面源碼我們注意幾點:

  1. 是通過遞增的 AtomicInteger 作為 ThreadLocal 的 hashCode 的;
  2. 計算數組索引位置的公式是:hashCode 取模 數組大小-1,由於 hashCode 不斷自增,所以不同的 hashCode 大概率上會計算到同一個數組的索引位置(但這個不用擔心,在實際項目中,ThreadLocal 都很少,基本上不會衝突);
  3. 通過 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;
}

源碼註解也比較清晰,我們注意兩點:

  1. 擴容後數組大小是原來數組的兩倍,下一次的擴容閾值為數組長度的三分之二;
  2. 擴容時是沒有執行緒安全問題的,因為 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 的原理

1659933918179-bf853ad8-db00-4320-b20d-3e7479fd3f3c.png

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

Tags: