併發容器之ThreadLocal

你聽___發表於2018-05-06

併發容器之ThreadLocal

1. ThreadLocal的簡介

在多執行緒程式設計中通常解決執行緒安全的問題我們會利用synchronzed或者lock控制執行緒對臨界區資源的同步順序從而解決執行緒安全的問題,但是這種加鎖的方式會讓未獲取到鎖的執行緒進行阻塞等待,很顯然這種方式的時間效率並不是很好。執行緒安全問題的核心在於多個執行緒會對同一個臨界區共享資源進行操作,那麼,如果每個執行緒都使用自己的“共享資源”,各自使用各自的,又互相不影響到彼此即讓多個執行緒間達到隔離的狀態,這樣就不會出現執行緒安全的問題。事實上,這就是一種“空間換時間”的方案,每個執行緒都會都擁有自己的“共享資源”無疑記憶體會大很多,但是由於不需要同步也就減少了執行緒可能存在的阻塞等待的情況從而提高的時間效率。

雖然ThreadLocal並不在java.util.concurrent包中而在java.lang包中,但我更傾向於把它當作是一種併發容器(雖然真正存放資料的是ThreadLoclMap)進行歸類。從ThreadLocal這個類名可以顧名思義的進行理解,表示執行緒的“本地變數”,即每個執行緒都擁有該變數副本,達到人手一份的效果,各用各的這樣就可以避免共享資源的競爭

2. ThreadLocal的實現原理

要想學習到ThreadLocal的實現原理,就必須瞭解它的幾個核心方法,包括怎樣存怎樣取等等,下面我們一個個來看。

void set(T value)

set方法設定在當前執行緒中threadLocal變數的值,該方法的原始碼為:

public void set(T value) {
	//1. 獲取當前執行緒例項物件
    Thread t = Thread.currentThread();
	//2. 通過當前執行緒例項獲取到ThreadLocalMap物件
    ThreadLocalMap map = getMap(t);
    if (map != null)
		//3. 如果Map不為null,則以當前threadLocl例項為key,值為value進行存入
        map.set(this, value);
    else
		//4.map為null,則新建ThreadLocalMap並存入value
        createMap(t, value);
}
複製程式碼

方法的邏輯很清晰,具體請看上面的註釋。通過原始碼我們知道value是存放在了ThreadLocalMap裡了,當前先把它理解為一個普普通通的map即可,也就是說,資料value是真正的存放在了ThreadLocalMap這個容器中了,並且是以當前threadLocal例項為key。先簡單的看下ThreadLocalMap是什麼,有個簡單的認識就好,下面會具體說的。

首先ThreadLocalMap是怎樣來的?原始碼很清楚,是通過getMap(t)進行獲取:

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

該方法直接返回的就是當前執行緒物件t的一個成員變數threadLocals:

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
複製程式碼

也就是說ThreadLocalMap的引用是作為Thread的一個成員變數,被Thread進行維護的。回過頭再來看看set方法,當map為Null的時候會通過createMap(t,value)方法:

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

該方法就是new一個ThreadLocalMap例項物件,然後同樣以當前threadLocal例項作為key,值為value存放到threadLocalMap中,然後將當前執行緒物件的threadLocals賦值為threadLocalMap

現在來對set方法進行總結一下: 通過當前執行緒物件thread獲取該thread所維護的threadLocalMap,若threadLocalMap不為null,則以threadLocal例項為key,值為value的鍵值對存入threadLocalMap,若threadLocalMap為null的話,就新建threadLocalMap然後在以threadLocal為鍵,值為value的鍵值對存入即可。

T get()

get方法是獲取當前執行緒中threadLocal變數的值,同樣的還是來看看原始碼:

public T get() {
	//1. 獲取當前執行緒的例項物件
    Thread t = Thread.currentThread();
	//2. 獲取當前執行緒的threadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
		//3. 獲取map中當前threadLocal例項為key的值的entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
			//4. 當前entitiy不為null的話,就返回相應的值value
            T result = (T)e.value;
            return result;
        }
    }
	//5. 若map為null或者entry為null的話通過該方法初始化,並返回該方法返回的value
    return setInitialValue();
}
複製程式碼

弄懂了set方法的邏輯,看get方法只需要帶著逆向思維去看就好,如果是那樣存的,反過來去拿就好。程式碼邏輯請看註釋,另外,看下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;
}
複製程式碼

這段方法的邏輯和set方法幾乎一致,另外值得關注的是initialValue方法:

protected T initialValue() {
    return null;
}
複製程式碼

這個方法是protected修飾的也就是說繼承ThreadLocal的子類可重寫該方法,實現賦值為其他的初始值。關於get方法來總結一下:

通過當前執行緒thread例項獲取到它所維護的threadLocalMap,然後以當前threadLocal例項為key獲取該map中的鍵值對(Entry),若Entry不為null則返回Entry的value。如果獲取threadLocalMap為null或者Entry為null的話,就以當前threadLocal為Key,value為null存入map後,並返回null。

void remove()

public void remove() {
	//1. 獲取當前執行緒的threadLocalMap
	ThreadLocalMap m = getMap(Thread.currentThread());
 	if (m != null)
		//2. 從map中刪除以當前threadLocal例項為key的鍵值對
		m.remove(this);
}
複製程式碼

get,set方法實現了存資料和讀資料,我們當然還得學會如何刪資料**。刪除資料當然是從map中刪除資料,先獲取與當前執行緒相關聯的threadLocalMap然後從map中刪除該threadLocal例項為key的鍵值對即可**。

3. ThreadLocalMap詳解

從上面的分析我們已經知道,資料其實都放在了threadLocalMap中,threadLocal的get,set和remove方法實際上具體是通過threadLocalMap的getEntry,set和remove方法實現的。如果想真正全方位的弄懂threadLocal,勢必得在對threadLocalMap做一番理解。

3.1 Entry資料結構

ThreadLocalMap是threadLocal一個靜態內部類,和大多數容器一樣內部維護了一個陣列,同樣的threadLocalMap內部維護了一個Entry型別的table陣列。

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

通過註釋可以看出,table陣列的長度為2的冪次方。接下來看下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是一個以ThreadLocal為key,Object為value的鍵值對,另外需要注意的是這裡的**threadLocal是弱引用,因為Entry繼承了WeakReference,在Entry的構造方法中,呼叫了super(k)方法就會將threadLocal例項包裝成一個WeakReferenece。**到這裡我們可以用一個圖(下圖來自http://blog.xiaohansong.com/2016/08/06/ThreadLocal-memory-leak/)來理解下thread,threadLocal,threadLocalMap,Entry之間的關係:

ThreadLocal各引用間的關係

注意上圖中的實線表示強引用,虛線表示弱引用。如圖所示,每個執行緒例項中可以通過threadLocals獲取到threadLocalMap,而threadLocalMap實際上就是一個以threadLocal例項為key,任意物件為value的Entry陣列。當我們為threadLocal變數賦值,實際上就是以當前threadLocal例項為key,值為value的Entry往這個threadLocalMap中存放。需要注意的是**Entry中的key是弱引用,當threadLocal外部強引用被置為null(threadLocalInstance=null),那麼系統 GC 的時候,根據可達性分析,這個threadLocal例項就沒有任何一條鏈路能夠引用到它,這個ThreadLocal勢必會被回收,這樣一來,ThreadLocalMap中就會出現key為null的Entry,就沒有辦法訪問這些key為null的Entry的value,如果當前執行緒再遲遲不結束的話,這些key為null的Entry的value就會一直存在一條強引用鏈:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永遠無法回收,造成記憶體洩漏。**當然,如果當前thread執行結束,threadLocal,threadLocalMap,Entry沒有引用鏈可達,在垃圾回收的時候都會被系統進行回收。在實際開發中,會使用執行緒池去維護執行緒的建立和複用,比如固定大小的執行緒池,執行緒為了複用是不會主動結束的,所以,threadLocal的記憶體洩漏問題,是應該值得我們思考和注意的問題,關於這個問題可以看這篇文章----詳解threadLocal記憶體洩漏問題

3.2 set方法

與concurrentHashMap,hashMap等容器一樣,threadLocalMap也是採用雜湊表進行實現的。在瞭解set方法前,我們先來回顧下關於雜湊表相關的知識(摘自這篇的threadLocalMap的講解部分以及這篇文章的hash)。

  • 雜湊表

理想狀態下,雜湊表就是一個包含關鍵字的固定大小的陣列,通過使用雜湊函式,將關鍵字對映到陣列的不同位置。下面是

理想雜湊表的一個示意圖

在理想狀態下,雜湊函式可以將關鍵字均勻的分散到陣列的不同位置,不會出現兩個關鍵字雜湊值相同(假設關鍵字數量小於陣列的大小)的情況。但是在實際使用中,經常會出現多個關鍵字雜湊值相同的情況(被對映到陣列的同一個位置),我們將這種情況稱為雜湊衝突。為了解決雜湊衝突,主要採用下面兩種方式: 分離連結串列法(separate chaining)和開放定址法(open addressing)

  • 分離連結串列法

分散連結串列法使用連結串列解決衝突,將雜湊值相同的元素都儲存到一個連結串列中。當查詢的時候,首先找到元素所在的連結串列,然後遍歷連結串列查詢對應的元素,典型實現為hashMap,concurrentHashMap的拉鍊法。下面是一個示意圖:

分離連結串列法示意圖

圖片來自 http://faculty.cs.niu.edu/~freedman/340/340notes/340hash.htm

  • 開放定址法

開放定址法不會建立連結串列,當關鍵字雜湊到的陣列單元已經被另外一個關鍵字佔用的時候,就會嘗試在陣列中尋找其他的單元,直到找到一個空的單元。探測陣列空單元的方式有很多,這裡介紹一種最簡單的 -- 線性探測法。線性探測法就是從衝突的陣列單元開始,依次往後搜尋空單元,如果到陣列尾部,再從頭開始搜尋(環形查詢)。如下圖所示:

開放定址法示意圖

圖片來自 http://alexyyek.github.io/2014/12/14/hashCollapse/

關於兩種方式的比較,可以參考 這篇文章ThreadLocalMap 中使用開放地址法來處理雜湊衝突,而 HashMap 中使用的分離連結串列法。之所以採用不同的方式主要是因為:在 ThreadLocalMap 中的雜湊值分散的十分均勻,很少會出現衝突。並且 ThreadLocalMap 經常需要清除無用的物件,使用純陣列更加方便。

在瞭解這些相關知識後我們再回過頭來看一下set方法。set方法的原始碼為:

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.

    Entry[] tab = table;
    int len = tab.length;
	//根據threadLocal的hashCode確定Entry應該存放的位置
    int i = key.threadLocalHashCode & (len-1);

	//採用開放地址法,hash衝突的時候使用線性探測
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
		//覆蓋舊Entry
        if (k == key) {
            e.value = value;
            return;
        }
		//當key為null時,說明threadLocal強引用已經被釋放掉,那麼就無法
		//再通過這個key獲取threadLocalMap中對應的entry,這裡就存在記憶體洩漏的可能性
        if (k == null) {
			//用當前插入的值替換掉這個key為null的“髒”entry
            replaceStaleEntry(key, value, i);
            return;
        }
    }
	//新建entry並插入table中i處
    tab[i] = new Entry(key, value);
    int sz = ++size;
	//插入後再次清除一些key為null的“髒”entry,如果大於閾值就需要擴容
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}
複製程式碼

set方法的關鍵部分請看上面的註釋,主要有這樣幾點需要注意:

  1. threadLocal的hashcode?

     private final int threadLocalHashCode = nextHashCode();
     private static final int HASH_INCREMENT = 0x61c88647;
     private static AtomicInteger nextHashCode =new AtomicInteger();
     /**
      * Returns the next hash code.
      */
     private static int nextHashCode() {
         return nextHashCode.getAndAdd(HASH_INCREMENT);
     }
    複製程式碼

    從原始碼中我們可以清楚的看到threadLocal例項的hashCode是通過nextHashCode()方法實現的,該方法實際上總是用一個AtomicInteger加上0x61c88647來實現的。0x61c88647這個數是有特殊意義的,它能夠保證hash表的每個雜湊桶能夠均勻的分佈,這是Fibonacci Hashing,關於更多介紹可以看這篇文章的threadLocal雜湊值部分。也正是能夠均勻分佈,所以threadLocal選擇使用開放地址法來解決hash衝突的問題。

  2. 怎樣確定新值插入到雜湊表中的位置?

    該操作原始碼為:key.threadLocalHashCode & (len-1),同hashMap和ConcurrentHashMap等容器的方式一樣,利用當前key(即threadLocal例項)的hashcode與雜湊表大小相與,因為雜湊表大小總是為2的冪次方,所以相與等同於一個取模的過程,這樣就可以通過Key分配到具體的雜湊桶中去。而至於為什麼取模要通過位與運算的原因就是位運算的執行效率遠遠高於了取模運算。

  3. 怎樣解決hash衝突?

    原始碼中通過nextIndex(i, len)方法解決hash衝突的問題,該方法為((i + 1 < len) ? i + 1 : 0);,也就是不斷往後線性探測,當到雜湊表末尾的時候再從0開始,成環形。

  4. 怎樣解決“髒”Entry?

    在分析threadLocal,threadLocalMap以及Entry的關係的時候,我們已經知道使用threadLocal有可能存在記憶體洩漏(物件建立出來後,在之後的邏輯一直沒有使用該物件,但是垃圾回收器無法回收這個部分的記憶體),在原始碼中針對這種key為null的Entry稱之為“stale entry”,直譯為不新鮮的entry,我把它理解為“髒entry”,自然而然,Josh Bloch and Doug Lea大師考慮到了這種情況,在set方法的for迴圈中尋找和當前Key相同的可覆蓋entry的過程中通過replaceStaleEntry方法解決髒entry的問題。如果當前table[i]為null的話,直接插入新entry後也會通過cleanSomeSlots來解決髒entry的問題,關於cleanSomeSlots和replaceStaleEntry方法,會在詳解threadLocal記憶體洩漏中講到,具體可看那篇文章

  5. 如何進行擴容?

threshold的確定

也幾乎和大多數容器一樣,threadLocalMap會有擴容機制,那麼它的threshold又是怎樣確定的了?

	private int threshold; // Default to 0
	/**
     * The initial capacity -- MUST be a power of two.
     */
    private static final int INITIAL_CAPACITY = 16;
	
    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);
    }
	
	/**
     * Set the resize threshold to maintain at worst a 2/3 load factor.
     */
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }
複製程式碼

根據原始碼可知,在第一次為threadLocal進行賦值的時候會建立初始大小為16的threadLocalMap,並且通過setThreshold方法設定threshold,其值為當前雜湊陣列長度乘以(2/3),也就是說載入因子為2/3(載入因子是衡量雜湊表密集程度的一個引數,如果載入因子越大的話,說明雜湊表被裝載的越多,出現hash衝突的可能性越大,反之,則被裝載的越少,出現hash衝突的可能性越小。同時如果過小,很顯然記憶體使用率不高,該值取值應該考慮到記憶體使用率和hash衝突概率的一個平衡,如hashMap,concurrentHashMap的載入因子都為0.75)。這裡threadLocalMap初始大小為16載入因子為2/3,所以雜湊表可用大小為:16*2/3=10,即雜湊表可用容量為10。

擴容resize

從set方法中可以看出當hash表的size大於threshold的時候,會通過resize方法進行擴容。

/**
 * Double the capacity of the table.
 */
private void resize() {
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
	//新陣列為原陣列的2倍
    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();
			//遍歷過程中如果遇到髒entry的話直接另value為null,有助於value能夠被回收
            if (k == null) {
                e.value = null; // Help the GC
            } else {
				//重新確定entry在新陣列的位置,然後進行插入
                int h = k.threadLocalHashCode & (newLen - 1);
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);
                newTab[h] = e;
                count++;
            }
        }
    }
	//設定新雜湊表的threshHold和size屬性
    setThreshold(newLen);
    size = count;
    table = newTab;
}	
複製程式碼

方法邏輯請看註釋,新建一個大小為原來陣列長度的兩倍的陣列,然後遍歷舊陣列中的entry並將其插入到新的hash陣列中,主要注意的是,在擴容的過程中針對髒entry的話會令value為null,以便能夠被垃圾回收器能夠回收,解決隱藏的記憶體洩漏的問題

3.3 getEntry方法

getEntry方法原始碼為:

private Entry getEntry(ThreadLocal<?> key) {
	//1. 確定在雜湊陣列中的位置
    int i = key.threadLocalHashCode & (table.length - 1);
	//2. 根據索引i獲取entry
    Entry e = table[i];
	//3. 滿足條件則返回該entry
    if (e != null && e.get() == key)
        return e;
    else
		//4. 未查詢到滿足條件的entry,額外在做的處理
        return getEntryAfterMiss(key, i, e);
}
複製程式碼

方法邏輯很簡單,若能當前定位的entry的key和查詢的key相同的話就直接返回這個entry,否則的話就是在set的時候存在hash衝突的情況,需要通過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)
			//找到和查詢的key相同的entry則返回
            return e;
        if (k == null)
			//解決髒entry的問題
            expungeStaleEntry(i);
        else
			//繼續向後環形查詢
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}
複製程式碼

這個方法同樣很好理解,通過nextIndex往後環形查詢,如果找到和查詢的key相同的entry的話就直接返回,如果在查詢過程中遇到髒entry的話使用expungeStaleEntry方法進行處理。到目前為止**,為了解決潛在的記憶體洩漏的問題,在set,resize,getEntry這些地方都會對這些髒entry進行處理,可見為了儘可能解決這個問題幾乎無時無刻都在做出努力。**

3.4 remove

/**
 * Remove the entry for 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) {
			//將entry的key置為null
            e.clear();
			//將該entry的value也置為null
            expungeStaleEntry(i);
            return;
        }
    }
}
複製程式碼

該方法邏輯很簡單,通過往後環形查詢到與指定key相同的entry後,先通過clear方法將key置為null後,使其轉換為一個髒entry,然後呼叫expungeStaleEntry方法將其value置為null,以便垃圾回收時能夠清理,同時將table[i]置為null。

4. ThreadLocal的使用場景

ThreadLocal 不是用來解決共享物件的多執行緒訪問問題的,資料實質上是放在每個thread例項引用的threadLocalMap,也就是說每個不同的執行緒都擁有專屬於自己的資料容器(threadLocalMap),彼此不影響。因此threadLocal只適用於 共享物件會造成執行緒安全 的業務場景。比如hibernate中通過threadLocal管理Session就是一個典型的案例,不同的請求執行緒(使用者)擁有自己的session,若將session共享出去被多執行緒訪問,必然會帶來執行緒安全問題。下面,我們自己來寫一個例子,SimpleDateFormat.parse方法會有執行緒安全的問題,我們可以嘗試使用threadLocal包裝SimpleDateFormat,將該例項不被多執行緒共享即可。

public class ThreadLocalDemo {
    private static ThreadLocal<SimpleDateFormat> sdf = new ThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) {
            executorService.submit(new DateUtil("2019-11-25 09:00:" + i % 60));
        }
    }

    static class DateUtil implements Runnable {
        private String date;

        public DateUtil(String date) {
            this.date = date;
        }

        @Override
        public void run() {
            if (sdf.get() == null) {
                sdf.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
            } else {
                try {
                    Date date = sdf.get().parse(this.date);
                    System.out.println(date);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
複製程式碼
  1. 如果當前執行緒不持有SimpleDateformat物件例項,那麼就新建一個並把它設定到當前執行緒中,如果已經持有,就直接使用。另外,if (sdf.get() == null){....}else{.....}可以看出為每一個執行緒分配一個SimpleDateformat物件例項是從應用層面(業務程式碼邏輯)去保證的。
  2. 在上面我們說過threadLocal有可能存在記憶體洩漏,在使用完之後,最好使用remove方法將這個變數移除,就像在使用資料庫連線一樣,及時關閉連線。

參考資料

《java高併發程式設計》 這篇文章的threadLocalMap講解和threadLocal的hashCode講解不錯 這篇文章講解了hash,不錯 解決hash衝突 鏈地址法和開放地址法的比較

相關文章