一、簡介
ThreadLocal是java中提供的一個意在處理多線程對於共享變量訪問操作衝突問題的一個工具,其實可以認爲是處理多線程併發問題的一個工具。我們知道java中的內存交互方式是java的JMM(java內存模型)模型所規範的,每個線程都有屬於自己的工作內存,且線程之間工作內存之間是互相隔離的,每個線程不可以直接操作主存,都需要從主存讀取信息到工作內存,然後同步存入主存,並且在計算機底層的高速緩存和主存之間交互方式也類似於此,由此可能會產生多線程關於共享變量的問題(參考文章JUC-volatile關鍵字作用)。
一般針對多線程併發問題的處理方向有以下兩種方式方式,第一種類似於synchronized或者lock這種保證同一時刻只有具有資格的線程才能操作共享資源,來保證併發安全;另外是本文中即將介紹的ThreadLocal這種本地變量的方式,每個線程維護了自己的共享變量的副本,不影響其他線程該變量的變化來避免線程之間共享變量併發安全問題。
本質上來講前者是以時間換空間的方式,後者是以空間換時間的方式,並且後者方式的使用是需要特定情景的,需要這個共享變量是無狀態的,因爲副本只會在線程期間作用。二者之間各有各的特點,可以針對自己業務場景的需要選擇合適的處理併發問題的方式。接下來讓我們詳細的講解Threadlocal工具。
二、使用方式
ThreadLocal工具從jdk版本1.2就已經提供了,是由著名的併發大神Doug Lea和集合框架的大神Josh Bloch共同創造的,意在通過以空間方式解決無狀態共享變量併發問題,也可以說是提供了一種新思路。從名字可以看出來,“Local”就是每個線程都維護了共享變量的一份副本,線程內如何操作都只是操作自己內部的副本,不會影響到其他線程,可以看到如下實例:
public class ThreadLocalTest {
private static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>();
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
threadLocal.set(11);
System.out.println(Thread.currentThread().getName()+"---"+threadLocal.get());
}
});
thread.start();
System.out.println("begin");
threadLocal.set(10);
System.out.println(Thread.currentThread().getName()+"---"+threadLocal.get());
}
}
使用的時候,聲明ThreadLocal變量,因爲是使用泛型,可以指定你想要存儲的對象類型,更加方便靈活。可以看到在類中有個靜態成員變量threadLocal,使用泛型Integer,在主線程中起了一個子線程操作了該成員變量,主線程後續也操作了該變量,通過在兩個線程分別打印日誌查看threadLocal所存儲的值。結果如下:
各自線程打印出各自設置的值,或許這個例子,並不能證明Threadlocal維護了變量的副本,通過下面的源碼講解,就會理解這個輸出結果的必然性。
三、源碼講解
本次講解的ThreadLocal是基於jdk1.8版本的,大致的類結構如下,可以看到其實還是比較簡單的,對外暴露的方法只有幾個,大致就是構造方法、remove()、set()、get()以及initialValue()基本方法:
存儲容器?如果要掌握ThreadLocal,那麼就必須掌握它的一個靜態內部類ThreadLocalMap,該類實例是存儲變量的實際容器對象,內部還有一個靜態內部類Entry,以key-value的形式存儲數據,key是ThreadLocal實例對象,value就是我們想要存儲的值對象。
在ThreadLocalMap內部維護了一個初始長度16的Entry的數組table,並且通過維護一個臨界值來進行數組擴容,除此之外,維護了一個size字段,記錄table數組中Entry節點的數量,提供了ThreadLocalMap構造方法,以及對Entry的操作私有操作方法。
在我們的Thread類中,維護了一個ThreadLocal.ThreadLocalMap類型的引用,如下:
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
從這個引用邏輯可以大致看出,ThreadLocal是爲線程和存儲對象(ThreadLocalMap)之間建立了一個映射關係,提供了操作儲存信息的一些api,也就是針對本線程的ThreadLocalMap進行操作。
ThreadLcoal set()方法?接下來我們從方法的入口開始,也就是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);//初始化map,並設置值
}
1.獲取當前線程,並根據當前線程獲取線程引用的ThreadLocalMap對象;
2.如果map不爲空,則進行set方法,入參是this(當前ThreadLocal對象),value想要存儲的值;
3.如果map爲空,則進行map的創建工作,並把當前存儲的值存入;
下面看下當map非空時的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;//獲取該ThreadLocalMap內部的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();
}
每一個Thread都會引用一個ThreadLocalMap實例,每一個ThreadLocalMap實例創建的時候,都化初始化一個默認初始長度爲16的Entry節點類型的table數組,因此map非空的情況下,數組也是經過初始化的,內部set方法流如下:
1.獲取當前ThreadLocalMap實例內的table數組,獲取數組的長度;
2.哈希魔數和數組長度做位運算,獲取本次存儲的數組下標;
3.從本次下邊位置開始循環,獲取Entry節點,如果該下標節點不爲null,則獲取該節點的key,也就是ThreadLocal對象;
4.判斷本次存儲的ThreadLocal和該下標的key是否相同,若相同,則將value更新,返回;
5.如果不相同,則判斷該下標的key是否爲null,如果爲null,則走replace流程,如果不是null,則進行下一節點繼續判斷;
6.若在循環過程中,不爲空的節點,始終沒有key相同,且key不爲null,直到遇到空的節點,跳出循環;
7.跳出循環後,則在這個爲空的節點下標處新建一個Entry,維護size,然後進行數組整理以及根據臨界值決定擴容;
當map爲空的時候,會走創建map的過程,代碼如下:
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
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);
}
爲當前線程創建一個ThreadLocalMap實例,包含了table數組的初始化,以及本次的值存儲過程:
1.table初始化,長度是INITIAL_CAPACITY,默認是16;
2.根據數組默認值長度和哈希魔數位運算得到本次數組存儲的下標位置;
3.新建Entry節點,存入table的下標位置,維護size=1,更新擴容值;
到此,本次存儲值到ThreadLocal結束。
ThreadLocal get()方法?下面我們跟蹤一下get()方法的執行流程:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);//獲取線程關聯的map實例
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
1.獲取線程關聯的ThreadLocalMap實例對象,如果map爲null,則走初始化方法邏輯並返回,初始化方法可以進行重寫;
2.如果map不爲空,則到map獲取當前ThreadLocal對象的Entry對象節點;
3.Entry不爲null,返回Entry的value;
因爲前面我們看到,放入的過程中可能會因爲哈希衝突放入下一個幾點,我們看下map.getEntry(this)方法:
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);
}
根據哈希魔數和數組長度位運算得到預算的數組下標,如果該下標的key剛好等於key,則返回該Entry節點;如果該下邊爲null或者該下標存儲的key與本次key不相等,則進入方法getEntryAfterMiss(key, i, e),代碼如下:
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;
}
前面set()方法解決hash衝突的辦法是進行數組下一位置判斷,因此獲取的時候也要遵循這一規則,循環獲取不等於null的節點,判斷key值,遇到k爲null的節點進行數組整理,如果e爲null則跳出循環,返回null。
ThreadLocal核心方法就是上面提到的get和set方法,方法的邏輯也是比較簡單的,因此最上面提到的程序運行結果,我們可以知道結果的必然性。
四、關注點
在ThreadLocal中有幾個值得我們關注的地方,比如它的hash方式,它可能出現內存泄漏的問題以及線程複用等問題,下面我們描述下這幾種情況的含義。
1.內存泄漏
上面提到ThreadLocal使用自定義方式實現內部map數組,沒有使用java提供的集合中提供的工具。在ThreadLocalMap中的Entry靜態內部類是一個繼承了弱引用的類,因此Entry的key,也就是對ThreadLocal對象的是弱引用,當一個對象沒有任何強引用,只剩下弱引用的時候,gc就會把這個對象進行回收,哪怕此時堆內存是充足的,下面是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對象,無其他強引用之後,因爲ThreadLocalMap的key強引用導致了ThreadLocal對象無法被回收,爲了不影響ThreadLocal對象正常的生命週期,把key設置爲弱引用,因此不會影響ThreadLocal對象正常的生命週期。
反過來考慮到當ThreadLocal對象被回收之後,ThreadLocalMap實例中的一個key引用變成了null,此時key所對應的object已經變成了不可到達的了,但是此時object無法被gc回收,因此可以認爲此時發生了所謂的內存泄漏。ThreadLocal對這種情況做了處理,在常用的幾個操作方法中,如果遇到key爲null的Entry節點,則會進行整理,避免內存泄漏。比如上述get以及set方法中使用到的expungeStaleEntry()方法進行整理,清除key爲null的節點。
2.線程複用
上面我們已經剖析了ThreadLocal內部的執行邏輯,可以知道線程局部變量如果不主動處理的話,會在線程存活期間一直有效,當我們的線程生命週期是可控或者局部變量的一直存活不會對我們產生影響的時候,可以選擇不考慮這種情況,但是如果線程複用使得線程不會死亡的時候,就需要考慮Threadlocal所管理的線程局部變量是否需要處理,是本次使用完立即清除掉,還是鑑於對堆內存的考慮,之後長時間不會在使用,需要釋放內存。無論是出於哪種情況的考慮,如果是線程池所支持的線程複用,還是一些容器例如tomcat使得業務線程的服用,我們最好在本線程使用結束後,主動清除該值,以免造成業務錯誤,或者內存浪費。可以使用remove()方法進行清除:
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;
}
}
}
可以看到remove()方法的大致邏輯,找到下標,循環獲取key的entry,找到後調用clear()方法,使entry引用爲null,然後走整理清除方法。
s3.哈希魔數
ThreadLocalMap中是自定義實現的一個Entry節點類型的table數組,來作爲存儲的map結構使用,提到map我們就會想到hash方式確定數組位置,在ThreadLocal中使用變量threadLocalHashCode和數組長度做位運算得到本次數組存儲的下標位置,這個變量關聯了一個比較神奇的變量數字,HASH_INCREMENT=0x61c88647,該變量使用static和final修飾,是一個類終量,每當我們新建一個ThreadLocal實例的時候,threadLocalHashCode都會增加HASH_INCREMENT步長。
假設在一個線程內,有兩個ThreadLocal,那麼這兩個ThreadLocal的threadLocalHashCode相差HASH_INCREMENT,當和數組長度進行hash取數組下邊的時候,能最大的散列位置,儘可能避免hash衝突。這個數字是個斐波那契數列,也是一個黃金分割的比值數。
五、使用場景
當我們理解ThreadLocal的執行過程之後,很容易知道它適合的使用場景,它的工作方式是線程內保存共享變量的副本,因此考慮到此功能,適合在線程期間其有效作用範圍的業務場景,比如session的存儲,數據庫連接的保持。
結合我本次使用ThreadLocal使用場景,使用了spring容器管理的單例的bean,編寫了一些有單獨處理邏輯的handle類,可複用性比較高,每個handler維護了一個指向下一個handle類,在上層使用策略模式,通過設置每個handle的下一個處理類,組裝handle類執行的串,這個過程中我們可以看到有隱藏的併發危險,加上一個策略設置A->B->C,而另外一個策略設置A->B->D,我們知道線程之間變量不可見,都是刷到主存中,若此時下一個策略運行中,B節點獲取下一個運行handle的時候,從主存讀取到的是上一個策略刷進主存的handle類C,這時就會發生業務錯誤。
上面問題處理方式還是比較多的,你可以不使用spring來管理這些handle類,組裝策略的時候新建handle,這樣不會共享一個實例,也不會有問題。我這邊是選擇了使用ThreadLocal來存儲每一個handle的下一個handle的指針,這樣在線程內部互不干擾,就可以按照策略設置好的handle串進行處理了。
六、資源地址
文檔:《Thinking in java》jdk1.8版本源碼