ThreadLocal原始碼深度剖析

卡巴拉的樹發表於2018-01-19

ThreadLocal的作用

ThreadLocal的作用是提供執行緒內的區域性變數,說白了,就是在各執行緒內部建立一個變數的副本,相比於使用各種鎖機制訪問變數,ThreadLocal的思想就是用空間換時間,使各執行緒都能訪問屬於自己這一份的變數副本,變數值不互相干擾,減少同一個執行緒內的多個函式或者元件之間一些公共變數傳遞的複雜度。

ThreadLocal的使用

檢視官方文件,可知ThreadLocal包含以下方法:

ThreadLocal原始碼深度剖析

其中get函式用來獲取與當前執行緒關聯的ThreadLocal的值,如果當前執行緒沒有該ThreadLocal的值,則呼叫initialValue函式獲取初始值返回,initialValue是protected型別的,所以一般我們使用時需要繼承該函式,給出初始值。而set函式是用來設定當前執行緒的該ThreadLocal的值,remove函式用來刪除ThreadLocal繫結的值,在某些情況下需要手動呼叫,防止記憶體洩露。

程式碼示例

public class Main {
    private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue() {
            return Integer.valueOf(0);
        }
    };

    public static void main(String[] args) {
        Thread[] threads = new Thread[5];
        for (int i=0;i<5;i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread() +"'s initial value: " + threadLocal.get());
                    for (int j=0;j<10;j++) {
                        threadLocal.set(threadLocal.get() + j);
                    }
                    System.out.println(Thread.currentThread() +"'s last value: " + threadLocal.get());
                }
            });
        }

        for (Thread t: threads)
            t.start();
    }
}
複製程式碼

上面的例子,threadLocal是每執行緒獨有的,所以就算進行累加後,彼此的值也是互不影響的,最後輸出如下:

ThreadLocal原始碼深度剖析

ThreadLocal原始碼剖析

看了ThreadLocal的基本使用,讀者一定想知道ThreadLocal內部是如何實現的?本文主要的內容就是基於Java1.8的程式碼帶你剖析ThreadLocal的內部實現。首先看下面這幅圖:

ThreadLocal原始碼深度剖析
我們可以看出每個Thread維護一個ThreadLocalMap,儲存在ThreadLocalMap內的就是一個以Entry為元素的table陣列,Entry就是一個key-value結構,key為ThreadLocal,value為儲存的值。類比HashMap的實現,其實就是每個執行緒藉助於一個雜湊表,儲存執行緒獨立的值。我們可以看看Entry的定義:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}
複製程式碼

這裡ThreadLocal和key之間的線是虛線,因為Entry是繼承了WeakReference實現的,當ThreadLocal Ref銷燬時,指向堆中ThreadLocal例項的唯一一條強引用消失了,只有Entry有一條指向ThreadLocal例項的弱引用,假設你知道弱引用的特性,那麼這裡ThreadLocal例項是可以被GC掉的。這時Entry裡的key為null了,那麼直到執行緒結束前,Entry中的value都是無法回收的,這裡可能產生記憶體洩露,後面會說如何解決。

知道大概的資料結構後,我們來探究一下ThreadLocal具體幾個方法的實現:

get方法

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();
    }
複製程式碼

直接看程式碼,可以分析主要有以下幾步:

  1. 獲取當前的Thread物件,通過getMap獲取Thread內的ThreadLocalMap
  2. 如果map已經存在,以當前的ThreadLocal為鍵,獲取Entry物件,並從從Entry中取出值
  3. 否則,呼叫setInitialValue進行初始化。

下面再看上面具體提到的幾個函式:

getMap

 ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
複製程式碼

getMap很簡單,就是返回執行緒中ThreadLocalMap,跳到Thread原始碼裡看,ThreadLocalMap是這麼定義的:

ThreadLocal.ThreadLocalMap threadLocals = null;
複製程式碼

所以ThreadLocalMap還是定義在ThreadLocal裡面的,我們前面已經說過ThreadLocalMap中的Entry定義,下面為了先介紹ThreadLocalMap的定義我們把setInitialValue放在前面說。

setInitialValue

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;
}
複製程式碼

setInititialValue在Map不存在的時候呼叫

  1. 首先是呼叫initialValue生成一個初始的value值,深入initialValue函式,我們可知它就是返回一個null;
  2. 然後還是在get以下Map,如果map存在,則直接map.set,這個函式會放在後文說;
  3. 如果不存在則會呼叫createMap建立ThreadLocalMap,這裡正好可以先說明下ThreadLocalMap了。

ThreadLocalMap

createMap方法的定義很簡單:

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}
複製程式碼

就是呼叫ThreadLocalMap的建構函式生成一個map,下面我們看看ThreadLocalMap的定義:

static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** The value associated with this ThreadLocal. */
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }

    /**
     * The initial capacity -- MUST be a power of two.
     */
    private static final int INITIAL_CAPACITY = 16;

    /**
     * The table, resized as necessary.
     * table.length MUST always be a power of two.
     */
    private Entry[] table;

    /**
     * The number of entries in the table.
     */
    private int size = 0;

    /**
     * The next size value at which to resize.
     */
    private int threshold; // Default to 0

    /**
     * Set the resize threshold to maintain at worst a 2/3 load factor.
     */
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }

    /**
     * Increment i modulo len.
     */
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }

    /**
     * Decrement i modulo len.
     */
    private static int prevIndex(int i, int len) {
        return ((i - 1 >= 0) ? i - 1 : len - 1);
    }
複製程式碼

ThreadLocalMap被定義為一個靜態類,上面是包含的主要成員:

  1. 首先是Entry的定義,前面已經說過;
  2. 初始的容量為INITIAL_CAPACITY = 16
  3. 主要資料結構就是一個Entry的陣列table;
  4. size用於記錄Map中實際存在的entry個數;
  5. threshold是擴容上限,當size到達threashold時,需要resize整個Map,threshold的初始值為len * 2 / 3
  6. nextIndex和prevIndex則是為了安全的移動索引,後面的函式裡經常用到。

而ThreadLocalMap的建構函式如下:

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    table = new Entry[INITIAL_CAPACITY];
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}
複製程式碼

就是使用firstKey和firstValue建立一個Entry,計算好索引i,然後把建立好的Entry插入table中的i位置,再設定好size和threshold。

最後說get函式完成實質性功能的getEntry函式:

map.getEntry

private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}
複製程式碼
  1. 首先是計算索引位置i,通過計算key的hash%(table.length-1)得出;
  2. 根據獲取Entry,如果Entry存在且Entry的key恰巧等於ThreadLocal,那麼直接返回Entry物件;
  3. 否則,也就是在此位置上找不到對應的Entry,那麼就呼叫getEntryAfterMiss。

這麼一深入,引出了getEntryAfterMiss方法:

getEntryAfterMiss

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}
複製程式碼

這個方法我們還得結合上一步看,上一步是因為不滿足e != null && e.get() == key才淪落到呼叫getEntryAfterMiss的,所以首先e如果為null的話,那麼getEntryAfterMiss還是直接返回null的,如果是不滿足e.get() == key,那麼進入while迴圈,這裡是不斷迴圈,如果e一直不為空,那麼就呼叫nextIndex,不斷遞增i,在此過程中一直會做兩個判斷:

  1. 如果k==key,那麼代表找到了這個所需要的Entry,直接返回;
  2. 如果k==null,那麼證明這個Entry中key已經為null,那麼這個Entry就是一個過期物件,這裡呼叫expungeStaleEntry清理該Entry。 這裡解答了前面留下的一個坑,即ThreadLocal Ref銷燬時,ThreadLocal例項由於只有Entry中的一條弱引用指著,那麼就會被GC掉,Entry的key沒了,value可能會記憶體洩露的,其實在每一個get,set操作時都會不斷清理掉這種key為null的Entry的。

為什麼迴圈查詢?

這裡你可以直接跳到下面的set方法,主要是因為處理雜湊衝突的方法,我們都知道HashMap採用拉鍊法處理雜湊衝突,即在一個位置已經有元素了,就採用連結串列把衝突的元素連結在該元素後面,而ThreadLocal採用的是開放地址法,即有衝突後,把要插入的元素放在要插入的位置後面為null的地方,具體關於這兩種方法的區別可以參考:解決雜湊(HASH)衝突的主要方法。所以上面的迴圈就是因為我們在第一次計算出來的i位置不一定存在key與我們想查詢的key恰好相等的Entry,所以只能不斷在後面迴圈,來查詢是不是被插到後面了,直到找到為null的元素,因為若是插入也是到null為止的。

分析完迴圈的原因,其實也可以深入expungeStaleEntry看看是怎麼清理的。

expungeStaleEntry

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;
}
複製程式碼

看上面這段程式碼主要有兩部分:

  1. expunge entry at staleSlot:這段主要是將i位置上的Entry的value設為null,Entry的引用也設為null,那麼系統GC的時候自然會清理掉這塊記憶體;
  2. Rehash until we encounter null: 這段就是掃描位置staleSlot之後,null之前的Entry陣列,清除每一個key為null的Entry,同時若是key不為空,做rehash,調整其位置。

為什麼要做rehash呢?

因為我們在清理的過程中會把某個值設為null,那麼這個值後面的區域如果之前是連著前面的,那麼下次迴圈查詢時,就會只查到null為止。

舉個例子就是:...,<key1(hash1), value1>, <key2(hash1), value2>,...(即key1和key2的hash值相同) 此時,若插入<key3(hash2), value3>,其hash計算的目標位置被<key2(hash1), value2>佔了,於是往後尋找可用位置,hash表可能變為: ..., <key1(hash1), value1>, <key2(hash1), value2>, <key3(hash2), value3>, ... 此時,若<key2(hash1), value2>被清理,顯然<key3(hash2), value3>應該往前移(即通過rehash調整位置),否則若以key3查詢hash表,將會找不到key3

set方法

我們在get方法的迴圈查詢那裡也大概描述了set方法的思想,即開放地址法,下面看具體程式碼:

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,若是有ThreadLocalMap,則呼叫map.set(ThreadLocal<?> key, Object value),若是沒有則呼叫createMap建立。

map.set

private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
    
        if (k == key) {
            e.value = value;
            return;
        }
    
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}
複製程式碼

看上面這段程式碼:

  1. 首先還是根據key計算出位置i,然後查詢i位置上的Entry,
  2. 若是Entry已經存在並且key等於傳入的key,那麼這時候直接給這個Entry賦新的value值。
  3. 若是Entry存在,但是key為null,則呼叫replaceStaleEntry來更換這個key為空的Entry
  4. 不斷迴圈檢測,直到遇到為null的地方,這時候要是還沒在迴圈過程中return,那麼就在這個null的位置新建一個Entry,並且插入,同時size增加1。
  5. 最後呼叫cleanSomeSlots,這個函式就不細說了,你只要知道內部還是呼叫了上面提到的expungeStaleEntry函式清理key為null的Entry就行了,最後返回是否清理了Entry,接下來再判斷sz>thresgold,這裡就是判斷是否達到了rehash的條件,達到的話就會呼叫rehash函式。

上面這段程式碼有兩個函式還需要分析下,首先是

replaceStaleEntry

private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == key) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // Start expunge at preceding stale entry if it exists
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // If key not found, put new entry in stale slot
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // If there are any other stale entries in run, expunge them
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
複製程式碼

首先我們回想上一步是因為這個位置的Entry的key為null才呼叫replaceStaleEntry。

  1. 第1個for迴圈:我們向前找到key為null的位置,記錄為slotToExpunge,這裡是為了後面的清理過程,可以不關注了;
  2. 第2個for迴圈:我們從staleSlot起到下一個null為止,若是找到key和傳入key相等的Entry,就給這個Entry賦新的value值,並且把它和staleSlot位置的Entry交換,然後呼叫CleanSomeSlots清理key為null的Entry。
  3. 若是一直沒有key和傳入key相等的Entry,那麼就在staleSlot處新建一個Entry。函式最後再清理一遍空key的Entry。

說完replaceStaleEntry,還有個重要的函式是rehash以及rehash的條件:

首先是sz > threshold時呼叫rehash

rehash

private void rehash() {
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}
複製程式碼

清理完空key的Entry後,如果size大於3/4的threshold,則呼叫resize函式:

private void resize() {
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    int newLen = oldLen * 2;
    Entry[] newTab = new Entry[newLen];
    int count = 0;

    for (int j = 0; j < oldLen; ++j) {
        Entry e = oldTab[j];
        if (e != null) {
            ThreadLocal<?> k = e.get();
            if (k == null) {
                e.value = null; // Help the GC
            } else {
                int h = k.threadLocalHashCode & (newLen - 1);
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);
                newTab[h] = e;
                count++;
            }
        }
    }

    setThreshold(newLen);
    size = count;
    table = newTab;
}
複製程式碼

由原始碼我們可知每次擴容大小擴充套件為原來的2倍,然後再一個for迴圈裡,清除空key的Entry,同時重新計算key不為空的Entry的hash值,把它們放到正確的位置上,再更新ThreadLocalMap的所有屬性。

remove

最後一個需要探究的就是remove函式,它用於在map中移除一個不用的Entry。也是先計算出hash值,若是第一次沒有命中,就迴圈直到null,在此過程中也會呼叫expungeStaleEntry清除空key節點。程式碼如下:

private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
         if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}
複製程式碼

使用ThreadLocal的最佳實踐

我們發現無論是set,get還是remove方法,過程中key為null的Entry都會被擦除,那麼Entry內的value也就沒有強引用鏈,GC時就會被回收。那麼怎麼會存在記憶體洩露呢?但是以上的思路是假設你呼叫get或者set方法了,很多時候我們都沒有呼叫過,所以最佳實踐就是*

1 .使用者需要手動呼叫remove函式,刪除不再使用的ThreadLocal.

2 .還有儘量將ThreadLocal設定成private static的,這樣ThreadLocal會盡量和執行緒本身一起消亡。

相關文章