前言
多執行緒在訪問同一個共享變數時很可能會出現併發問題,特別是在多執行緒對共享變數進行寫入時,那麼除了加鎖還有其他方法避免併發問題嗎?本文將詳細講解 ThreadLocal 的使用及其原始碼。
一、什麼是 ThreadLocal?
ThreadLocal 是 JDK 包提供的,它提供了執行緒本地變數,也就是說,如果你建立了一個 ThreadLocal 變數,那麼訪問這個變數的每一個執行緒,都建立這個變數的一個本地副本。
這樣可以解決什麼問題呢?當多個執行緒操作這個變數時,實際操作的是自己執行緒本地記憶體裡的資料,從而避免執行緒安全問題。
如下圖,執行緒表中的每個執行緒,都有自己 ThreadLocal 變數,執行緒操作這個變數只是在自己的本地記憶體在,跟其他執行緒是隔離的。
二、如何使用 ThreadLocal
ThreadLocal 就是一個簡單的容器,使用起來也沒有難度,初始化後僅需透過 get/set 方法進行操作即可。
如下程式碼,開闢兩個執行緒對 ThreadLocal 變數進行操作,獲取的值是不同的。
public class FuXing {
/**
* 初始化ThreadLocal
*/
private static final ThreadLocal<String> myThreadLocal = new ThreadLocal<>();
public static void main (String[] args) {
// 執行緒1中操作 myThreadLocal
new Thread(()->{
myThreadLocal.set("thread 1"); //set方法設定值
System.out.println(myThreadLocal.get()); //get方法獲取值"thread 1"
},"thread 1").start();
// 執行緒2中操作 myThreadLocal
new Thread(()->{
myThreadLocal.set("thread 2"); //set方法設定值
System.out.println(myThreadLocal.get()); //get方法獲取值"thread 2"
},"thread 2").start();
}
}
三、ThreadLocal 實現原理
ThreadLocal 是如何保證操作的物件只被當前執行緒進行訪問呢,我們透過原始碼一起進行分析學習。
一般分析原始碼我們都先看它的構造方法是如何初始化的,接著透過對 ThreadLocal 的簡單使用,我們知道了關鍵的兩個方法 set/get,所以原始碼分析也按照這個順序。
1. 構造方法
泛型類的空參構造,沒有什麼特別的
2. set 方法原始碼
原始碼如下,ThreadLocalMap 是什麼呢?由於比較複雜,這裡先不做解釋,你暫時可以理解為是一個 HashMap,其中 key 為 ThreadLocal 當前物件,value 就是我們設定的值,後面會單獨解釋原始碼。
public void set(T value) {
//獲取本地執行緒
Thread t = Thread.currentThread();
//獲取當前執行緒下的threadLocals物件,物件型別是ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
//獲取到則新增值
map.set(this, value);
else
//否則初始化ThreadLocalMap --第一次設定值
createMap(t, value);
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
3. get 方法原始碼
public T get() {
//獲取本地執行緒
Thread t = Thread.currentThread();
//獲取當前執行緒下的threadLocals物件,物件型別是ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
//透過當前的ThreadLocal作為key去獲取對應value
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
//@SuppressWarnings忽略告警的註解
//"unchecked"表示未經檢查的轉換相關的警告,通常出現在泛型程式設計中
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//threadLocals為空或它的Entry為空時,需要對其進行初始化操作。
return setInitialValue();
}
private T setInitialValue() {
//初始化為null
T value = initialValue();
//獲取當前執行緒
Thread t = Thread.currentThread();
//獲取當前執行緒下的threadLocals物件,物件型別是ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
//返回的其實就是個null
return value;
}
protected T initialValue() {
return null;
}
4. remove 方法原始碼
核心也是 ThreadLocalMap 中的 remove 方法,會刪除 key 對應的 Entry,具體原始碼後面統一在 ThreadLocalMap 原始碼中分析。
public void remove() {
//獲取當前執行緒下的threadLocals物件,物件型別是ThreadLocalMap
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
//透過當前的ThreadLocal作為key呼叫remove
m.remove(this);
}
5. ThreadLocalMap 原始碼
ThreadLocalMap 是 ThreadLocal 的一個靜態內部類,看了上面的幾個原始碼解釋,可以瞭解到 ThreadLocalMap 其實才是核心。
簡單的說,ThreadLocalMap 與 HashMap 類似,如,初始容量 16,一定範圍內擴容,Entry 陣列儲存等,那它與 HashMap 有什麼不同呢,下面將對原始碼進行詳解。
ThreadLocalMap 的底層資料結構:
5.1 常量
//初始容量,一定是2的冪等數。
private static final int INITIAL_CAPACITY = 16;
// Entry 陣列
private Entry[] table;
//table的長度
private int size = 0;
//擴容閾值
private int threshold;
//設定擴容閾值,長度的 2 / 3
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
//計算下一個儲存位置
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
// 計算前一個儲存位置
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
5.2 Entry 相關原始碼
由於 Entry 是底層核心原始碼,所有的操作幾乎都是圍繞著它來進行的,所以關於 Entry 的原始碼會比較多,我一一拆分進行分析講解。
靜態內部類 Entry
這個是 ThreadLocalMap 的底層資料結構,Entry 陣列,每個 Entry 物件,這裡的 Entry 繼承了 WeakReference,關於弱引用不懂得,可以看我的另一篇文章《Java 引用》。
然後將 Entry 的 key 設定承了 弱引用,這有什麼作用呢?作用是當 ThreadLocal 失去強引用後,在系統GC時,只要發現弱引用,不管系統堆空間使用是否充足,都會回收掉 key,進而 Entry 被內部清理。
//靜態內部類Entry
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
// key為弱引用
super(k);
value = v;
}
}
獲取 Entry
拿到當前執行緒中對應的 ThreadLocal 所在的 Entry,找不到的話會重新尋找,因為當前的 Entry 可能已經擴容,擴容後會重新計算索引位置,詳情見擴容機制原始碼。
原始碼中的計算索引位置的演算法我沒有解釋,這個我會放在後面解釋,涉及到了如何解決 Hash 衝突的問題,這個和我們熟知的 HashMap 是不同的。
//獲取Entry
private Entry getEntry(ThreadLocal<?> key) {
//計算索引位置
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
//找到了就返回Entry
if (e != null && e.get() == key)
return e;
else
//沒找到則重新尋找,因為可能發生擴容導致索引重新計算
return getEntryAfterMiss(key, i, e);
}
//重新獲取Entry --從當前索引i的位置向後搜尋
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
//迴圈遍歷,獲取對應的 ThreadLocal 所在的 Entry
while (e != null) {
//獲取Entry物件的弱引用,WeakReference的方法
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
//清除無效 Entry,詳解見下方
expungeStaleEntry(i);
else
//計算下一個索引位置
i = nextIndex(i, len);
//可以理解為指標後移
e = tab[i];
}
return null;
}
清除無效 Entry
expunge 刪除,抹去,stale 陳舊的,沒有用的
第 1 個方法:
根據索引刪除對應的桶位,並從給定索引開始,遍歷清除無效的 Entry,何為無效?就是當 Entry 的 key 為 null 時,代表 key 已經被 GC 掉了,對應的 Entry 就無效了。
第 2 個方法:
刪除Entry陣列中所有無效的Entry,方法中的e.get() == null
,代表key被回收了。
第 3 個方法:
清除一些失效桶位,它執行對數數量的掃描,向後遍歷logn個位置,如8,4,2,1。
方法 2、3 最後都透過方法 1 進行桶位的刪除。
//根據索引刪除對應的桶位
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
//刪除該桶位的元素,並將陣列長度減1
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
Entry e;
int i;
//從當前索引開始,直到當前 Entry為null才會停止遍歷
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
//獲取Entry物件的弱引用,WeakReference的方法
ThreadLocal<?> k = e.get();
if (k == null) {//說明key已失效
//刪除該桶位的元素,並將陣列長度減1
e.value = null;
tab[i] = null;
size--;
} else {//說明key有效,需要將其Rehash
//計算rehash後索引位置
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
//移動元素位置,若rehash後索引位置有其他元素,則繼續向後移動,直至為空
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
//直到當前 Entry為null才會停止遍歷,i為其索引
return i;
}
//刪除Entry陣列中所有無效的Entry,用於rehash時
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
//獲取Entry物件的弱引用,Entry不為空而弱引用為空,代表被GC了
if (e != null && e.get() == null)
//根據索引刪除對應的桶位
expungeStaleEntry(j);
}
}
//清楚一些清除桶位,它執行對數數量的掃描
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
//向後遍歷logn個位置,如8,4,2,1
do {
i = nextIndex(i, len);
Entry e = tab[i];
//獲取Entry物件的弱引用,Entry不為空而弱引用為空,代表被GC了
if (e != null && e.get() == null) {
n = len;
removed = true;
//根據索引刪除對應的桶位
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);//對數遞減
return removed;
}
替換無效 Entry
替換失效元素,用在對 Entry 進行 set 操作時,如果 set 的 key 是失效的,則需要用新的替換它。
這裡不僅僅處理了當前的失效元素,還會將其他失效的元素進行清理,因為這裡是當 key 為 null 時才進行的替換操作。
那什麼時候 key 為 null 呢?這個除了主動的 remove 之外,就只有 ThreadLocal 的弱引用被 GC 掉了。
這裡是在 set 操作時出現的,還出現了 key 為 null 的無效元素,代表已經之前發生過 GC 了,很可能Entry 陣列中還可能出現其他無效元素,所以原始碼中會出現向前遍歷和向後遍歷的情況。
向前遍歷好理解,就是透過遍歷找第一個失效元素的索引。向後遍歷比較難理解,這裡我先簡單說一下 ThreadLocal 用的開放地址的方式來解決 hash 衝突的,具體原理我後面會在講 hash 衝突時單獨講。
這種情況下,很可能當前的失效元素對應的並不是 hascode 在 staleSlot 的Entry。因為 hash 衝突後,Entry 會後移,那麼此元素的 hascode 對應的桶位很有可能往後移了,所以我們要向後找到它,並且和當前的 staleSlot 進行替換。
如果不進行此操作的話,很有可能在 set 操作時,在 ThreadLocalMap 中會出現兩個桶位,都被某個ThreadLocal 指向。
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
//記錄失效元素的索引
int slotToExpunge = staleSlot;
//從失效元素位置向前遍歷,直到當前 Entry為null才會停止遍歷
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
//更新失效元素的索引,目的是找第一個失效的元素
slotToExpunge = i;
//從失效元素向後遍歷
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
//找到了對應key
if (k == key) {
//更新該位置的value
e.value = value;
//把失效元素換到當前位置
tab[i] = tab[staleSlot];
//把當前Entry移動到失效元素位置
tab[staleSlot] = e;
//slotToExpunge是第一個失效元素的索引,若條件成立,向前沒有失效元素
if (slotToExpunge == staleSlot)
//從當前索引開始,清理失效元素
slotToExpunge = i;
// 清理失效元素,詳情見清除無效Entry相關原始碼
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
//代表向前遍歷沒有找到第一個失效元素的位置
if (k == null && slotToExpunge == staleSlot)
//所以條件成立的i是向後遍歷的的第一個失效元素的位置
slotToExpunge = i;
}
//沒找到key,則在失效元素索引的位置,新建Entry
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// 條件成立說明在找到了staleSlot前面找到了其他的失效元素
if (slotToExpunge != staleSlot)
// 清理失效元素,詳情見清除無效Entry相關原始碼
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
5.3 構造方法
還有一個基於 parentMap 的構造方法,由於目前僅在建立 InheritableThreadLocal 時呼叫,關於它這裡不詳細展開,後續會針對該類進行詳解。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 初始化陣列
table = new Entry[INITIAL_CAPACITY];
//計算儲存位置
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
//儲存元素,並將size設定為1
table[i] = new Entry(firstKey, firstValue);
size = 1;
//設定擴容閾值
setThreshold(INITIAL_CAPACITY);
}
5.4 set 方法原始碼
設定 key,vlaue,key 就是 ThreadLocal 物件。
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
//計算索引位置
int i = key.threadLocalHashCode & (len-1);
//從當前索引開始,直到當前Entry為null才會停止遍歷
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
//如果key存在且等於當前key,代表之前存在的,直接覆蓋
if (k == key) {
e.value = value;
return;
}
//如果key不存在,說明已失效,需要替換,詳情見替換無效Entry原始碼
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
//沒有key則新建一個Entry即可
tab[i] = new Entry(key, value);
int sz = ++size;
//清理一些失效元素,若清理失敗且達到常量中的擴容閾值,則進行rehash操作
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
//刪除Entry陣列中所有無效的Entry並擴容
private void rehash() {
//刪除Entry陣列中所有無效的Entry
expungeStaleEntries();
if (size >= threshold - threshold / 4)
//擴容,詳情見下面的擴容機制原始碼
resize();
}
5.5 remove 方法原始碼
刪除key對應的entry
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
//計算儲存位置
int i = key.threadLocalHashCode & (len-1);
//從當前索引開始,直到當前Entry為null才會停止遍歷
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
//清除該物件的強引用,下次在透過get方法獲取引用則返回null
e.clear();
//清除無效元素
expungeStaleEntry(i);
return;
}
}
}
5.6 擴容機制原始碼
將元素轉移到新的Entry 陣列,長度是原來的兩倍。
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) { //key失效則值也順便設為null
e.value = null; // Help the GC
} else {
//重新計算索引位置
int h = k.threadLocalHashCode & (newLen - 1);
//移動元素位置,若rehash後索引位置有其他元素,則繼續向後移動,直至為空
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
setThreshold(newLen);
size = count;
table = newTab;
}
四、ThreadLocalMap 的 Hash 衝突
Java 中大部分都是使用拉鍊法法解決 Hash 衝突的,而 ThreadLocalMap 是透過開放地址法來解決 Hash 衝突,這兩者有什麼不同,下面我講介紹一下。
1. 拉鍊法
拉鍊法也叫鏈地址法,經典的就是 HashMap 解決 Hash 衝突的方法,如下圖。將所有的 hash 值相同的元素組成一個連結串列,除此外 HashMap 還進行了連結串列轉紅黑樹的最佳化。
2. 開放地址法
原理是當發生hash衝突時,不引入額外的資料結構,會以當前地址為基準,透過“多次探測”來處理雜湊衝突,探測方式主要包括線性探測、平方探測和多次雜湊等,ThreadLocalMap 使用的是線性探測法。
簡單說,就是一旦發生了衝突,就去探測尋找下一個空的雜湊地址,根據上面的原始碼也能大致瞭解該處理方式。
原始碼中的公式是key.threadLocalHashCode & (length - 1)
。
公式類似 HashMap 的定址演算法,詳情見HashMap原始碼,由於陣列長度是 2 的 n 次冪,所以這裡的與運算就是取模,得到索引 i,這樣做是為了分佈更均勻,減少衝突產生。
threadLocalHashCode 原始碼如下:
private final int threadLocalHashCode = nextHashCode();
//初始化執行緒安全的Integer
private static AtomicInteger nextHashCode =
new AtomicInteger();
//斐波那契雜湊乘數 --結果分佈更均勻
private static final int HASH_INCREMENT = 0x61c88647;
//自增返回下一個hash code
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
線性探測法的缺點:
- 不適用於儲存大量資料,容易產生“聚集現象”;
- 刪除元素需要清除無效元素;
五、注意事項
1. 關於記憶體洩漏
在瞭解了 ThreadLocal 的內部實現以後,我們知道了資料其實儲存在 ThreadLocalMap 中。這就意味著,執行緒只要不退出,則引用一直存在。
當執行緒退出時,Thread 類會對一些資源進行清理,其中就有threadLocals,原始碼如下:
private void exit() {
if (group != null) {
group.threadTerminated(this);
group = null;
}
target = null;
//加速一些資源的清理
threadLocals = null;
inheritableThreadLocals = null;
inheritedAccessControlContext = null;
blocker = null;
uncaughtExceptionHandler = null;
}
因此,當使用的執行緒一直沒有退出(如使用執行緒池),這時如果將一些大物件放入 ThreadLocal 中,且沒有及時清理,就可能會出現記憶體洩漏的風險。
所以我們要養成習慣每次使用完 ThreadLocal 都要呼叫 remove 方法進行清理。
2. 關於資料混亂
透過對記憶體洩漏的解釋,我們瞭解了當使用的執行緒一直沒有退出,而又沒有即使清理 ThreadLocal,則其中的資料會一直存在。
這除了記憶體洩漏還有什麼問題呢?我們在開發過程中,請求一般都是透過 Tomcat 處理,而其在處理請求時採用的就是執行緒池。
這就意味著請求執行緒被 Tomcat 回收後,不一定會立即銷燬,如果不在請求結束後主動 remove 執行緒中的 ThreadLocal 資訊,可能會影響後續邏輯,拿到髒資料。
我在開發過程中就遇到了這個問題,詳情見ThreadLocal中的使用者資訊混亂問題。所以無論如何,在每次使用完 ThreadLocal 都要呼叫 remove 方法進行清理。
3. 關於繼承性
同一個 ThreadLocal 變數,在父執行緒中被設定值後,在子執行緒其實是獲取不到的。透過原始碼我們也知道,我們操作的都是當前執行緒下的 ThreadLocalMap ,所以這其實是正常的。
測試程式碼如下:
public class FuXing {
/**
* 初始化ThreadLocal
*/
private static final ThreadLocal<String> myThreadLocal = new ThreadLocal<>();
public static void main (String[] args) {
myThreadLocal.set("father thread");
System.out.println(myThreadLocal.get()); //father thread
new Thread(()->{
System.out.println(myThreadLocal.get()); //null
},"thread 1").start();
}
}
那麼這可能會導致什麼問題呢?比如我們在本服務呼叫外部服務,或者本服務開啟新執行緒去進行非同步操作,其中都無法獲取 ThreadLocal 中的值。
雖然都有其他解決方法,但是有沒有讓子執行緒也能直接獲取到父執行緒的 ThreadLocal 中的值呢?這就用到了 InheritableThreadLocal。
public class FuXing {
/**
* 初始化ThreadLocal
*/
private static final InheritableThreadLocal<String> myThreadLocal
= new InheritableThreadLocal<>();
public static void main (String[] args) {
myThreadLocal.set("father thread");
System.out.println(myThreadLocal.get()); //father thread
new Thread(()->{
System.out.println(myThreadLocal.get()); //father thread
},"thread 1").start();
}
}
InheritableThreadLocal 就是繼承了 ThreadLocal,在建立和獲取變數例項 inheritableThreadLocals 而不再是threadLocals,原始碼如下。
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
protected T childValue(T parentValue) {
return parentValue;
}
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
}
總結
本文主要講述了 ThreadLocal 的使用以及對其原始碼進行了詳解,瞭解了 ThreadLocal 可以執行緒隔離的原因。透過對 ThreadLocalMap 的分析,知道了其底層資料結構和如何解決 Hash 衝突的。
最後透過對 ThreadLocal 特點的分析,瞭解到有哪些需要注意的點,避免以後開發過程中遇到類似問題,若發現其他問題歡迎指正交流。
參考:
[1] 翟陸續/薛賓田. Java併發程式設計之美.
[2] 葛一鳴/郭超. 實戰Java高併發程式設計.
[3] 靳宇棟. Hello 演算法.