前言
ThreadLocal的文章在網上也有不少,但是看了一些後,理解起來總感覺有繞,而且看了ThreadLocal的原始碼,無論是執行緒隔離、類環形陣列、弱引用結構等等,實在是太有意思了!我必須也要讓大家全面感受下其中所蘊含的那些奇思妙想! 所以這裡我想寫一篇超幾兒通俗易懂解析ThreadLocal的文章,相關流程會使用大量圖示解析,以證明:我是乾貨,不是水比!
ThreadLocal這個類加上龐大的註釋,總共也才七百多行,而且你把這個類的程式碼拷貝出來,你會發現,它幾乎沒有報錯!耦合度極低!(唯一的報錯是因為ThreadLocal類引用了Thread類裡的一個包內可見變數,所以把程式碼複製出來,這個變數訪問就報錯了,僅僅只有此處報錯!)
ThreadLocal的執行緒資料隔離,替換演算法,擦除演算法,都是有必要去了解了解,僅僅少量的程式碼,卻能實現如此精妙的功能,讓我們來體會下 Josh Bloch 和 Doug Lea 倆位大神,巧奪天工之作吧!
一些說明
這篇文章畫了不少圖,大概畫了十八張圖,關於替換演算法和擦除演算法,這倆個方法所做的事情,如果不畫圖,光用文字描述的話,十分的抽象且很難描述清楚;希望這些流程圖,能讓大家更能體會這些精煉程式碼的魅力!
使用
嗶嗶原理之前,必須要先來看下使用
- 使用起來出奇的簡單,僅僅使用
set()
和get()
方法即可
public class Main {
public static void main(String[] args) {
ThreadLocal<String> threadLocalOne = new ThreadLocal<>();
ThreadLocal<String> threadLocalTwo = new ThreadLocal<>();
new Thread(new Runnable() {
@Override
public void run() {
threadLocalOne.set("執行緒一的資料 --- threadLocalOne");
threadLocalTwo.set("執行緒一的資料 --- threadLocalTwo");
System.out.println(threadLocalOne.get());
System.out.println(threadLocalTwo.get());
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(threadLocalOne.get());
System.out.println(threadLocalTwo.get());
threadLocalOne.set("執行緒二的資料 --- threadLocalOne");
threadLocalTwo.set("執行緒二的資料 --- threadLocalTwo");
System.out.println(threadLocalOne.get());
System.out.println(threadLocalTwo.get());
}
}).start();
}
}
- 列印結果
- 一般來說,我們在主存(或稱工作執行緒)建立一個變數;在子執行緒中修改了該變數資料,子執行緒結束的時候,會將修改的資料同步到主存的該變數上
- 但是,在此處,可以發現,倆個執行緒都使用同一個變數,但是線上程一里面設定的資料,完全沒影響到執行緒二
- cool!簡單易用,還實現了資料隔離與不同的執行緒
執行緒一的資料 --- threadLocalOne
執行緒一的資料 --- threadLocalTwo
null
null
執行緒二的資料 --- threadLocalOne
執行緒二的資料 --- threadLocalTwo
前置知識
在解釋ThreadLocal整體邏輯前,需要先了解幾個前置知識
下面這些前置知識,是在說set和get前,必須要先了解的知識點,瞭解下面這些知識點,才能更好去了解整個存取流程
執行緒隔離
在上面的ThreadLocal的使用中,我們發現一個很有趣的事情,ThreadLocal在不同的執行緒,好像能夠儲存不同的資料:就好像ThreadLocal本身具有儲存功能,到了不同執行緒,能夠生成不同的'副本'儲存資料一樣
實際上,ThreadLocal到底是怎麼做到的呢?
- 來看下set()方法,看看到底怎麼存資料的:此處涉及到ThreadLocalMap型別,暫且把他當成Map,詳細的後面欄目分析
- 其實這地方做了一個很有意思的操作:執行緒資料隔離的操作,是Thread類和ThreadLocal類相互配合做到的
- 在下面的程式碼中可以看出來,在塞資料的時候,會獲取執行該操作的當前執行緒
- 拿到當前執行緒,取到threadLocals變數,然後彷彿以當前例項為key,資料value的形式往這個map裡面塞值(有區別,set欄目再詳細說)
- 所以使用ThreadLocal在不同的執行緒中進行寫操作,實際上資料都是繫結在當前執行緒的例項上,ThreadLocal只負責讀寫操作,並不負責儲存資料,這就解釋了,為什麼ThreadLocal的set資料,只在操作的執行緒中有用
- 大家有沒有感覺這種思路有些巧妙!
//存資料
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocal.ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
//獲取當前Thread的threadLocals變數
ThreadLocal.ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
//Thread類
public class Thread implements Runnable {
...
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
...
}
- 來看下圖示
- 圖上只花了了一個ThreadLocal,想多花幾個,然後線交叉了,暈
- threadLocals是可以儲存多個ThreadLocal,多個存取流程同理如下
- 總結下:通過上面的很簡單的程式碼,就實現了執行緒的資料隔離,也能得到幾點結論
- ThreadLocal物件本身是不儲存資料的,它本身的職能是執行相關的set、get之類的操作
- 當前執行緒的例項,負責儲存ThreadLocal的set操作傳入的資料,其資料和當前執行緒的例項繫結
- 一個ThreadLocal例項,在一個執行緒中只能儲存一類資料,後期的set操作,會覆蓋之前set的資料
- 執行緒中threadLocals是陣列結構,能儲存多個不同ThreadLocal例項set的資料
Entry
- 說到Entry,需要先知道下四大引用的基礎知識
強引用:不管記憶體多麼緊張,gc永不回收強引用的物件
軟引用:當記憶體不足,gc對軟引用物件進行回收
弱引用:gc發現弱引用,就會立刻回收弱引用物件
軟引用:在任何時候都可能被垃圾回收器回收
Entry就是一個實體類,這個實體類有倆個屬性:key、value,key是就是我們們常說的的弱引用
當我們執行ThreadLocal的set操作,第一次則新建一個Entry或後續set則覆蓋改Entry的value,塞到當前Thread的ThreadLocals變數中
- 來看下Entry程式碼
- 此處key取得是ThreadLocal自身的例項,可以看出來Entry持有的key屬性,屬於弱引用屬性
- value就是我們傳入的資料:型別取決於我們定義的泛型
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
- Entry有個比較巧妙的結構,繼承弱引用類,然後自身內部又定義了一個強引用屬性,使得該類有一強一弱的屬性
- 結構圖
你可能會想,what?我用ThreadLocal來set一個資料,然後gc一下,我Entry裡面key變數引用鏈就斷開了?
- 來試一下
public class Main {
public static void main(String[] args) {
ThreadLocal<String> threadLocalOne = new ThreadLocal<>();
new Thread(new Runnable() {
@Override
public void run() {
threadLocalOne.set("執行緒一的資料 --- threadLocalOne");
System.gc();
System.out.println(threadLocalOne.get());
}
}).start();
}
}
- 結果
執行緒一的資料 --- threadLocalOne
看來這裡gc了個寂寞。。。
在這裡,必須明確一個道理:gc回收弱引用物件,是先回收弱引用的物件,弱引用鏈自然斷開;而不是先斷開引用鏈,再回收物件。Entry裡面key對ThreadLocal的引用是弱引用,但是threadLocalOne對ThreadLocal的引用是強引用啊,所以ThreadLocal這個物件是沒法被回收的
- 來看下上面程式碼真正的引用關係
- 此處可以演示下,threadLocalOne對ThreadLocal的引用鏈斷開,Entry裡面key引用被gc回收的情況
public class Main {
static ThreadLocal<String> threadLocalOne = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
threadLocalOne.set("執行緒一的資料 --- threadLocalOne");
try {
threadLocalOne = null;
System.gc();
//下面程式碼來自:https://blog.csdn.net/thewindkee/article/details/103726942
Thread t = Thread.currentThread();
Class<? extends Thread> clz = t.getClass();
Field field = clz.getDeclaredField("threadLocals");
field.setAccessible(true);
Object threadLocalMap = field.get(t);
Class<?> tlmClass = threadLocalMap.getClass();
Field tableField = tlmClass.getDeclaredField("table");
tableField.setAccessible(true);
Object[] arr = (Object[]) tableField.get(threadLocalMap);
for (Object o : arr) {
if (o == null) continue;
Class<?> entryClass = o.getClass();
Field valueField = entryClass.getDeclaredField("value");
Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField("referent");
valueField.setAccessible(true);
referenceField.setAccessible(true);
System.out.println(String.format("弱引用key:%s 值:%s", referenceField.get(o), valueField.get(o)));
}
} catch (Exception e) { }
}
}).start();
}
}
- 結果
- key為null了!上面有行程式碼:threadLocalOne = null,這個就是斷開了對ThreadLocal物件的強引用
- 大家如果有興趣的話,可以把threadLocalOne = null去掉,再執行的話,會發現,key不會為空
- 反射程式碼的功能就是取到Thread中threadLocals變數,迴圈取其中的Entry,列印Entry的key、value值
弱引用key:null 值:執行緒一的資料 --- threadLocalOne
弱引用key:java.lang.ThreadLocal@387567b2 值:java.lang.ref.SoftReference@2021fb3f
- 總結
- 大家心裡可能會想,這變數一直持有強引用,key那個弱引用可有可無啊,而且子執行緒程式碼執行時間一般也不長
- 其實不然,我們可以想想Android app裡面的主執行緒,就是一個死迴圈,以事件為驅動,裡面可以搞巨多巨難的邏輯,這個強引用的變數被賦其它值就很可能了
- 如果key是強引用,那麼這個Entry裡面的ThreadLocal基本就很難被回收
- key為弱引用,當ThreadLocal物件強引用鏈斷開後,其很容易被回收了,相關清除演算法,也能很容易清理key為null的Entry
- 一個弱引用都能玩出這麼多花樣
ThreadLocalMap環形結構
- 我們們來看下ThreadLocalMap程式碼
- 先去掉一堆暫時沒必要關注的程式碼
- table就是ThreadLocalMap的主要結構了,資料都存在這個陣列裡面
- 所以說,ThreadLocalMap的主體結構就是一個Entry型別的陣列
public class ThreadLocal<T> {
...
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 table, resized as necessary.
* table.length MUST always be a power of two.
*/
private Entry[] table;
...
}
}
- 在此處你可能又有疑問了,這東西不就是一個陣列嗎?怎麼和環形結構扯上關係了?
- 陣列正常情況下確實是下面的這種結構
- 但是,ThreadLocalMap類裡面,有個方法做了一個騷操作,看下程式碼
public class ThreadLocal<T> {
...
static class ThreadLocalMap {
...
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
...
}
}
- 這個nextIndex方法,大家看懂了沒?
- 它的主要作用,就是將傳入index值加一
- 但是!當index值長度超過陣列長度後,會直接返回0,又回到了陣列頭部,這就完成了一個環形結構
- 總結
- 這樣做有個很大的好處,能夠大大的節省記憶體的開銷,能夠充分的利用陣列的空間
- 取資料的時候會降低一些效率,時間置換空間
set
總流程
- 塞資料的操作,來看下這個set操作的程式碼:下面的程式碼,邏輯還是很簡單的
- 獲取當前執行緒例項
- 獲取當前執行緒中的threadLocals例項
- threadLocals不為空執行塞值操作
- threadLocals為空,new一個ThreadLocalMap賦值給threadLocals,同時塞入一個Entry
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 getMap(Thread t) {
return t.threadLocals;
}
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生成Entry陣列,設定了一個預設長度,預設為:16
private static final int INITIAL_CAPACITY = 16;
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
...
}
- 流程圖
map.set
- 上面說了一些細枝末節,現在來說說最重要的map.set(this, 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.
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();
}
取雜湊值
- 上面程式碼有個計算雜湊值的操作
- 從key.threadLocalHashCode這行程式碼上來看,就好像將自身的例項計算hash值
- 其實看了完整的程式碼,發現傳入key,只不過是為了呼叫nextHashCode方法,用它來計算雜湊值,並不是將當前ThreadLocal物件轉化成hash值
public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();
private static final int HASH_INCREMENT = 0x61c88647;
private static AtomicInteger nextHashCode = new AtomicInteger();
private void set(ThreadLocal<?> key, Object value) {
...
int i = key.threadLocalHashCode & (len-1);
...
}
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
}
- 這地方用了一個原子類的操作,來看下getAndAdd() 方法的作用
- 這就是個相加的功能,相加後返回原來的舊值,保證相加的操作是個原子性不可分割的操作
public class Main {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger();
System.out.println(atomicInteger.getAndAdd(1)); //0
System.out.println(atomicInteger.getAndAdd(1)); //1
System.out.println(atomicInteger.getAndAdd(1)); //2
}
}
- HASH_INCREMENT = 0x61c88647,為什麼偏偏將將0x61c88647這個十六進位制相加呢,為什麼不能是1,2,3,4,5,6呢?
該值的相加,符合斐波那契雜湊法,可以使得的低位的二進位制數值分佈的更加均勻,這樣會減少在陣列中產生hash衝突的次數
具體分析可檢視:從 ThreadLocal 的實現看雜湊演算法
等等大家有沒有看到 threadLocalHashCode = nextHashCode(),nextHashCode()是獲取下一個節點的方法啊,這是什麼鬼?
難道每次使用key.threadLocalHashCode的時候,HashCode都會變?
- 看下完整的賦值語句
- 這是在初始化變數的時候,就直接定義賦值的
- 說明例項化該類的時候,nextHashCode()獲取一次HashCode之後,就不會再次獲取了
- 加上用的final修飾,僅能賦值一次
- 所以threadLocalHashCode變數,在例項化ThreadLocal的時候,獲取HashCode一次,該數值就定下來了,在該例項中就不會再變動了
public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();
}
好像又發現一個問題!threadHashCode通過 nextHashCode() 獲取HashCode,然後nextHashCode是使用AtomicInteger型別的 nextHashCode變數相加,這玩意每次例項化的時候不都會歸零嗎?
難道我們每次新的ThreadLocal例項獲取HashCode的時候,都要從0開始相加?
- 來看下完整程式碼
- 大家看下AtomicInteger型別的nextHashCode變數,他的修飾關鍵字是static
- 這說明該變數的數值,是和這個類繫結的,和這個類生成的例項無關,而且從始至終,只會例項化一次
- 當不同的ThreadLocal例項呼叫nextHashCode,他的數值就會相加一次
- 而且每個例項只能呼叫一次nextHashCode()方法,nextHashCode數值會很均勻的變化
public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();
private static final int HASH_INCREMENT = 0x61c88647;
private static AtomicInteger nextHashCode = new AtomicInteger();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
}
總結
- 通過寥寥數行的初始化,幾個關鍵字,就能形成在不同例項中,都能穩步變化的HashCode數值
- 這些基礎知識大家或許都知道,又有多少能這樣信手拈來呢?
取index值
上面程式碼中,用取得的hash值,與ThreadLocalMap例項中陣列長度減一的與操作,計算出了index值
這個很重要的,因為大於長度的高位hash值是不需要的
此處會將傳入的ThreadLocal例項計算出一個hash值,怎麼計算的後面再說,這地方有個位與的操作,這地方是和長度減一的與操作,這個很重要的,因為大於長度的高位hash值是不需要的
- 假設hash值為:010110011101
- 長度(此處選擇預設值:16-1):01111
- 看下圖可知,這個與操作,可去掉高位無用的hash值,取到的index值可限制在陣列長度中
塞值
- 看下塞值進入ThreadLocalMap陣列的操作
- 關於Key:因為Entry是繼承的WeakReference類,get()方法是獲取其內部弱引用物件,所以可以通過get()拿到當前ThreadLocal例項
- 關於value:直接 .value 就OK了
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);
...
}
分析下塞值流程
-
實際上面的迴圈還值得去思考,來思考下這迴圈處理的事情
-
迴圈中獲取當前index值,從Entry陣列取到當前index位置的Entry物件
- 如果獲取的這Entry是null,則直接結束這個迴圈體
- 在Entry陣列的index塞入一個新生成的節點
- 如果獲取的這Entry不為null
- key值相等,說明Entry物件存在,覆蓋其value值即可
- key為null,說明該節點可被替換(替換演算法後面講),new一個Entry物件,在此節點儲存資料
- 如果key不相等且不為null,迴圈獲取下一節點的Entry物件,並重覆上述邏輯
整體的邏輯比較清晰,如果key已存在,則覆蓋;不存在,index位置是否可用,可用則使用該節點,不可用,往後尋找可用節點:線性探測法
- 替換舊節點的邏輯,實在有點繞,下面單獨提出來說明
替換演算法
在上述set方法中,當生成的index節點,已被佔用,會向後探測可用節點
- 探測的節點為null,則會直接使用該節點
- 探測的節點key值相同,則會覆蓋value值
- 探測的節點key值不相同,繼續向後探測
- 探測的節點key值為null,會執行一個替換舊節點的操作,邏輯有點繞,下面來分析下
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 == null) {
replaceStaleEntry(key, value, i);
return;
}
}
...
}
- 來看下replaceStaleEntry方法中的邏輯
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
// Back up to check for prior stale entry in current run.
// We clean out whole runs at a time to avoid continual
// incremental rehashing due to garbage collector freeing
// up refs in bunches (i.e., whenever the collector runs).
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
// Find either the key or trailing null slot of run, whichever
// occurs first
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// If we find key, then we need to swap it
// with the stale entry to maintain hash table order.
// The newly stale slot, or any other stale slot
// encountered above it, can then be sent to expungeStaleEntry
// to remove or rehash all of the other entries in run.
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 we didn't find stale entry on backward scan, the
// first stale entry seen while scanning for key is the
// first still present in the run.
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);
}
-
上面的程式碼,很明顯倆個迴圈是重點邏輯,這裡面有倆個很重要的欄位:slotToExpunge和staleSlot
- staleSlot:記錄傳進來節點key為null的位置
- slotToExpunge:標定是否需要執行最後的清理方法
-
第一個迴圈:很明顯是往前列表頭結點方向探測,是否還有key為null的節點,有的話將其下標賦值給slotToExpunge;這個探測是一個連續的不為null的節點鏈範圍,有空節點,立馬結束迴圈
-
第二個迴圈:很明顯主要是向後探測,探測整個陣列,這裡有很重要邏輯
- 這地方已經開始有點繞了,我giao,大家要好好想想
- 當探測的key和傳入的需要設值的key相同時,會複寫探測到Entry的value,然後將探測到位置和傳入位置,倆者相互調換
-
為什麼會出現探測到Entry和傳入key相同?
- 相同是因為,存在到陣列的時候,產生了hash衝突,會自動向後探測合適的位置儲存
- 當你第二次用ThreadLocal存值的時候,hash產生的index,比較倆者key,肯定是不可能相同,因為產生了hash衝突,真正儲存Entry,在往後的位置;所以需要向後探測
- 假設探測的時候,一直沒有遇到key為null的Entry:正常迴圈的話,肯定是能探測到key相同的Entry,然後進行復寫value的操作
- 但是在探測的時候,遇到key為null的Entry的呢?此時就進入了替換舊Entry演算法,所以替換演算法就也有了一個向後探測的邏輯
- 探測到相同key值的Entry,就說明了找到了我們需要複寫value的Entry例項
-
為什麼要調換倆者位置呢?
-
這個問題,大家可以好好想想,我們時候往後探測,而這key為null的Entry例項,屬於較快的探測到Entry
-
而這個Entry例項的key又為null,說明這個Entry可以被回收了,此時正處於佔著茅坑不拉屎的位置
-
此時就可以把我需要複寫Entry例項和這個key為null的Entry調換位置
-
可以使得我們需要被操作的Entry例項,在下次被操作時候,可以儘快被找到
-
-
調換了位置之後,就會執行擦除舊節點演算法
- 上面是探查連續的Entry節點,未碰到null節點的情況;如果碰到null節點,會直接結束探測
- 請注意,如果陣列中,有需要複寫value的節點;在計算的hash值處,向後探測的過程,一定不會碰到null節點
- 畢竟,第一次向後探測可用節點是,碰到第一個null節點,就停下來使用了
- 在第二個迴圈中,還有一段程式碼,比較有意思,這判斷邏輯的作用是
- 以key為null的Entry,以它為界限
- 向前探測的時候,未碰到key為null的Entry
- 而向後探測的時候,碰到的key為null的Entry
- 然後改變slotToExpunge的值,使其和staleSlot不相等
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
...
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
...
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
...
}
- 可以看出來這倆個迴圈的操作,是有關聯性,對此,我表示
為什麼這倆個迴圈都這麼執著的,想改變slotToExpunge的數值呢?
- 來看下關於slotToExpunge的關鍵程式碼
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
...
int slotToExpunge = staleSlot;
...
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
明白了吧!都是為了替換方法裡面的最後一段邏輯:為了判斷是否需要執行擦除演算法
總結
-
雙向探測流程
- 替換演算法會以傳入的key為null的Entry節點為界限,在一個連續的Entry範圍往倆邊探測
- 什麼是連續的Entry範圍?這邊陣列的節點都不能為null,碰到為null節點會結束探測
- 先向前探測:如果碰到key為null的Entry,會將其下標賦值給slotToExpunge
- 向後探測使:如果向前探測沒有碰到key的節點,只要向後探測的時候碰到為null的節點,會將其下標賦值給slotToExpunge
- 上面向倆邊探測的邏輯,是為了:遇到key為null的節點,能確保slotToExpunge不等於staleSlot
- 替換演算法會以傳入的key為null的Entry節點為界限,在一個連續的Entry範圍往倆邊探測
-
在向後探測的時候,如果遇到key值對比相同的Entry,說明遇到我們需要複寫value的Entry
- 此時複寫value的Entry,用我們傳入的value數值將其原來的value數值覆蓋
- 然後將傳入key為null的Entry(通過傳入的下標得知Entry)和需要複寫value的Entry交換位置
- 最後執行擦除演算法
-
如果在向後探測的時候,沒有遇到遇到key值對比相同的Entry
- 傳入key為null的Entry,將其value賦值為null,斷開引用
- 建立一個新節點,放到此位置,key為傳入當前ThreadLocal例項,value為傳入的value資料
- 然後根據lotToExpunge和staleSlot是否相等,來判斷是否要執行擦除演算法
總結
來總結下
- 再來看下總流程
- 上面分析完了替換舊節點方法邏輯,終於可以把map.set的那塊替換演算法操作流程補起來了
- 不管後續遇到null,還是遇到需要被複寫value的Entry,這個key為null的Entry都將被替換掉
這倆個圖示,大概描述了ThreadLocal進行set操作的整個流程;現在,進入下一個欄目吧,來看看ThreadLocal的get操作!
get
get流程,總體要比set流程簡單很多,可以輕鬆一下了
總流程
- 來看下程式碼
- 總體流程非常簡單,將自身作為key,傳入map.getEntry方法,獲取符合例項的Entry,然後拿到value,返回就行了
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();
}
- 如果通過map.getEntry獲取的Entry為null,會返回setInitialValue(),來看下這個方法是幹嘛的
- 從這個方法可知,如果我們沒有進行set操作,直接進行get操作,他會給ThreadLocal的threadLocals方法賦初值
- setInitialValue() 方法,返回的是initialValue() 方法的資料,可知預設為null
- 所以通過key沒查到對應的Entry,get方法會返回null
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;
}
protected T initialValue() {
return null;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
map.getEntry
- 從上面的程式碼可以看出來,getEntry方法是獲取符合條件的節點
- 這裡邏輯很簡單,通過當前ThreadLocal例項獲取HashCode,然後算出index值
- 直接獲取當前index下標的Entry,將其key和當前ThreadLocal例項對比,看是否一樣
- 相同:說明沒有發生Hash碰撞,可以直接使用
- 不相同:說明發生了Hash碰撞,需要向後探測尋找,執行getEntryAfterMiss()方法
- 此時,就需要來看看getEntryAfterMiss()方法邏輯了
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);
}
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;
}
整體邏輯還是很清晰了,通過while迴圈,不斷獲取Entry陣列中的下一個節點,迴圈中有三個邏輯走向
- 當前節點的key等於當前ThreadLocal例項:直接返回這個節點的Entry
- 當前節點的key為null:執行擦除舊節點演算法,繼續迴圈
- 當前節點的可以不等於當前ThreadLocal例項且不為null:獲取下一節點的下標,然後繼續上面的邏輯
- 如果沒有獲取到符合條件的Entry節點,會直接返回null
總結
ThreadLocal的流程,總體上比較簡單
-
將當前ThreadLocal例項當為key,查詢Entry陣列當前節點(使用ThreadLocal中的魔術值算出的index)是否符合條件
-
不符合條件將返回null
- 從未進行過set操作
- 未查到符合條件的key
-
符合條件就直接返回當前節點
- 如果遇到雜湊衝突,算出的index值的Entry陣列上存在Entry,但是key不相等,就向後查詢
- 如果遇到key為null的Entry,就執行擦除演算法,然後繼續往後尋找
- 如果遇到key相當的Entry,就直接結束尋找,返回這個Entry節點
-
這裡大家一定要明確一個概念:在set的流程,發生了hash衝突,是在衝突節點向後的連續節點上,找到符合條件的節點儲存,所以查詢的時候,只需要在連續的節點上查詢,如果碰到為null的節點,就可以直接結束查詢
擦除演算法
在set流程和get流程都使用了這個擦除舊節點的邏輯,它可以及時清除掉Entry陣列中,那些key為null的Entry,如果key為null,說明這些這節點,已經沒地方使用了,所以就需要清除掉
- 來看看這個方法程式碼
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;
}
前置操作
從上面的程式碼,可以發現,再進行主要的迴圈體,有個前置操作
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
...
}
- 這地方做了很簡單的置空操作,如果Entry節點的key為空,說明這個節點可以被清除,value置空,和陣列的連結斷開
主體邏輯
- 很明顯,迴圈體裡面的邏輯是最重要,而且迴圈體裡面做了一個相當有趣的操作!
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
...
// 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;
}
- 上面的迴圈體裡面,就是不斷的獲取下一節點的Entry例項,然後判斷key值進行相關處理
- key為null:中規中矩的,將value置空,斷開與陣列的連結
- key不為null:這時候就有意思了
- 首先,會獲取當前ThreadLocal例項的hash值,然後取得index值
- 判斷h(idnex值)和i是否相等,不相等進行下述操作,因為Entry陣列是環形結構,是完成存在相等的情況
- 會將當前迴圈到節點置空,該節點的Entry記為e
- 從通過hreadLocal例項的hash值獲取到index處,開始進行迴圈
- 迴圈到節點Entry為null,則結束迴圈
- 將e賦值給為null的節點
- 這裡面的邏輯就是關鍵了
- 大家可能對這個文字的描述,感覺比較抽象,來個圖,來體會下這短短几行程式碼的妙處
總結
程式碼很少,但是實現的功能卻並不少
- 擦除舊節點的方法,在Entry上探測的時候
- 遇到key為空的節點,會將該節點置空
- 遇到key不為空的節點,會將該節點移到靠前位置(具體移動邏輯,請參考上述說明)
- 互動到靠前節點位置,可以看出,主要的目的,是為了:
- ThreadLocal例項計算出的index節點位置往後的位置,能讓節點保持連續性
- 也能讓交換的節點,更快的被操作
擴容
在進行set操作的時候,會進行相關的擴容操作
- 來看下擴容程式碼入口:resize方法便是擴容方法
public void set(T value) {
...
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
private void set(ThreadLocal<?> key, Object value) {
...
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
private void rehash() {
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
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;
}
觸發條件
先來看下擴容的觸發條件吧
- 整體程式碼
public void set(T value) {
...
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
private void set(ThreadLocal<?> key, Object value) {
...
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
private void rehash() {
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
上面主要的程式碼就是:!cleanSomeSlots(i, sz) && sz >= threshold
- 來看下threshold是什麼
- 只要Entry陣列含有Entry例項大於等於陣列的長度的三分之二,便能滿足後一段判定
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);
}
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
- 來看看前一段的判定,看下cleanSomeSlots,只要返回false,就能觸發擴容方法了
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
n >>>= 1:表達是無符號右移一位,正數高位補0,負數高位補1
舉例:0011 ---> 0001
在上面的cleanSomeSlots方法中,只要在探測節點的時候,沒有遇到Entry的key為null的節點,該方法就會返回false
- rehash方法就非常簡單了
- 執行擦除方法
- 只要size(含有Entry例項數)長度大於等於3/4 threshold,就執行擴容操作
private void rehash() {
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
總結
滿足下面倆個條件即可
- Entry陣列中不含key為null的Entry例項
- 陣列中含有是例項數大於等於threshold的四分之三(threshold為陣列長度的 三分之二)
擴容邏輯
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;
}
-
從上面的邏輯,可以看出來,將舊陣列的資料賦值到擴容陣列,並不是全盤賦值到擴容陣列的對應位置
-
遍歷舊陣列,取出其中的Entry例項
- key為null:需要將該節點value置空,等待GC處理(Help the GC,hhhh)
- 這裡你可能有個疑問,不是說陣列的節點key不為null,才會觸發擴容機制嗎?
- 在多執行緒的環境裡,執行擴容的時候,key的強引用斷開,導致key被回收,從而key為null,這是完全存在的
- key不為null:算出index值,向擴容陣列中儲存,如果該節點衝突,向後找到為null的節點,然後儲存
- key為null:需要將該節點value置空,等待GC處理(Help the GC,hhhh)
-
這裡的擴容儲存和ArrayList之類是有區別
總結
可以發現
-
set,替換,擦除,擴容,基本無時無刻,都是為了使hash衝突節點,向衝突的節點靠近
-
這是為了提高讀寫節點的效率
remove
remove方法是非常簡單的,ThreadLocal擁有三個api:set、get、remove;雖然非常簡單,但是還有一些必要,來稍微瞭解下
- remove程式碼
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
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例項,獲取當前的index,然後從此開始查詢符合條件Entry,找到後,會將其key值清掉,然後執行擦除演算法
e.clear就是,弱引用的清理弱引用的方法,很簡單,將弱引用referent變數置空就行了,這個變數就是持有弱引用物件的變數
最後
文章寫到這裡,基本上到了尾聲了,寫了差不多萬餘字,希望大家看完後,對ThreadLocal能有個更加深入的認識
ThreadLocal的原始碼雖然並不多,但是其中有很多奇思妙想,有種蘿蔔雕花的感覺,這就是高手寫的程式碼嗎?
系列文章