Java併發編程的藝術(十六)——併發容器之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衝突 鏈地址法和開放地址法的比較

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章