源碼篇:ThreadLocal的奇思妙想(萬字圖文)
前言
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();
//下面代碼來自://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的源碼雖然並不多,但是其中有很多奇思妙想,有種蘿蔔雕花的感覺,這就是高手寫的代碼嗎?
系列文章