ThreadLocal類
該類主要用於不同執行緒儲存自己的執行緒本地變數。本文先通過一個示例簡單介紹該類的使用方法,然後從ThreadLocal類的初始化、儲存結構、增刪資料和hash值計算等幾個方面,分析對應原始碼。採用的版本為jdk1.8。
ThreadLocal-使用方法
ThreadLocal物件可以在多個執行緒中被使用,通過set()方法設定執行緒本地變數,通過get()方法獲取設定的執行緒本地變數。我們先通過一個示例簡單瞭解下使用方法:
public static void main(String[] args){
ThreadLocal<String> threadLocal = new ThreadLocal<>();
// 執行緒1
new Thread(()->{
// 檢視是否有初始值
System.out.println("執行緒1的初始值:"+threadLocal.get());
// 設定執行緒1的值
threadLocal.set("V1");
// 輸出
System.out.println("執行緒1的值:"+threadLocal.get());
// 等待一段時間,等執行緒2設定值後再檢視執行緒1的值
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("執行緒1的值:"+threadLocal.get());
}).start();
// 執行緒2
new Thread(()->{
// 等待執行緒1設定初始值
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 檢視執行緒2的初始值
System.out.println("執行緒2的值:"+threadLocal.get());
// 設定執行緒2的值
threadLocal.set("V2");
// 檢視執行緒2的值
System.out.println("執行緒2的值:"+threadLocal.get());
}).start();
}
由於threadlocal設定的值是在每個執行緒中都有一個副本的,執行緒之間不會互相影響。程式碼執行的結果如下所示:
執行緒1的初始值:null
執行緒1的值:V1
執行緒2的值:null
執行緒2的值:V2
執行緒1的值:V1
ThreadLocal-初始化
ThreadLocal類只有一個無參的構造方法,如下所示:
/**
* Creates a thread local variable.
* @see #withInitial(java.util.function.Supplier)
*/
public ThreadLocal() {
}
但其實還有一個帶引數的構造方法,不過是它的子類。ThreadLocal中定義了一個內部類SuppliedThreadLocal,為繼承自ThreadLocal類的子類。可以通過該類進行給定初始值的初始化,其定義如下:
static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {
private final Supplier<? extends T> supplier;
SuppliedThreadLocal(Supplier<? extends T> supplier) {
this.supplier = Objects.requireNonNull(supplier);
}
@Override
protected T initialValue() {
return supplier.get();
}
}
通過TheadLocal threadLocal = Thread.withInitial(supplier);這樣的語句可以進行給定初始值的初始化。在某個執行緒第一次呼叫get()方法時,會執行initialValue()方法設定執行緒變數為傳入supplier中的值。
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
return new SuppliedThreadLocal<>(supplier);
}
ThreadLocal-儲存結構
在jdk1.8版本中,使用的是TheadLocalMap這一個容器儲存執行緒本地變數。
該容器的設計思想和HashMap有很多共同之處。比如:內部定義了Entry節點儲存鍵值對(使用ThreadLocal物件作為鍵);使用一個陣列儲存entry節點;設定一個閾值,超過閾值時進行擴容;通過鍵的hash值與陣列長度進行&操作確定下標索引等。但也有很多不同之處,具體我們在後續介紹ThreadLocalMap類時再詳細分析。
static class ThreadLocalMap {
// Entry節點定義
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 儲存元素的陣列
private Entry[] table;
// 容器內元素數量
private int size = 0;
// 閾值
private int threshold; // Default to 0
// 修改和新增元素
private void set(ThreadLocal<?> key, Object value){
...
}
// 移除元素
private void remove(ThreadLocal<?> key) {
...
}
...
}
ThreadLocal-增刪資料
ThreadLocal類提供了get(),set()和remove()方法來操作當前執行緒的threadlocal變數副本。底層則是基於ThreadLocalMap容器來實現資料操作。
不過要注意的是:ThreadLocal中並沒有ThreadLocalMap的成員變數,ThreadLocalMap物件是Thread類中的一個成員,所以需要通過通過當前執行緒的Thread物件去獲取該容器。
每一個執行緒Thread物件都會有一個map容器,該容器會隨著執行緒的終結而回收。
設定執行緒本地變數的方法。
public void set(T value) {
// 獲取當前執行緒對應的Thread物件,其是map鍵值對中的健
Thread t = Thread.currentThread();
// 獲取當前執行緒物件的容器map
ThreadLocalMap map = getMap(t);
// 如果容器不為null,則直接設定元素。否則用執行緒物件t和value去初始化容器物件
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
// 通過當前執行緒的執行緒物件獲取容器
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
// 建立map容器,本質是初始化Thread物件的成員變數threadLocals
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
獲取執行緒本地變數的方法。
public T get() {
// 獲取當前執行緒物件
Thread t = Thread.currentThread();
// 獲取當前執行緒物件的容器map
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
// 如果容器不為null且容器內有當前threadlocal物件對應的值,則返回該值
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 如果容器為null或者容器內沒有當前threadlocal物件繫結的值,則先設定初始值並返回該初始值
return setInitialValue();
}
// 設定初始值。主要分為兩步:1.載入和獲取初始值;2.在容器中設定該初始值。
// 第二步其實和set(value)方法實現一模一樣。
private T setInitialValue() {
// 載入並獲取初始值,預設是null。如果是帶參初始化的子類SuppliedThreadLocal,會有一個輸入初始值。
// 當然也可以繼承ThreadLocal類重寫該方法設定初始值
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
// 如果容器不為null,則直接設定元素。否則用執行緒物件t和value去初始化容器物件
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
移除執行緒本地變數的方法
public void remove() {
// 如果容器不為null就呼叫容器的移除方法,移除和該threadlocal繫結的變數
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
ThreadLocal-hash值計算
ThreadLocal的hash值用於ThreadLocalMap容器計算陣列下標。類中定義threadLocalHashCode表示其hash值。類中定義了靜態方法和靜態原子變數計算hash值,也就是說所有的threadLocal物件共用一個增長器。
// 當前ThreadLocal物件的hash值
private final int threadLocalHashCode = nextHashCode();
// 用來計算hash值的原子變數,所有的threadlocal物件共用一個增長器
private static AtomicInteger nextHashCode = new AtomicInteger();
// 魔法數字,使hash雜湊均勻
private static final int HASH_INCREMENT = 0x61c88647;
// 計算hash值的靜態方法
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
我們使用同樣的方法定義一個測試類,定義多個不同測試類物件,看看hash值的生成情況。如下所示,可以看到hash值都不同,是共用的一個增長器。
public class Test{
private static final int HASH_INCREMENT = 0x61c88647;
public static AtomicInteger nextHashCode = new AtomicInteger();
public final int nextHashCode = nextHashCode();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
public static void main(String[] args){
for (int i = 0; i < 5; i++) {
Test test = new Test();
System.out.println(test.nextHashCode);
}
}
// 輸出的hash值
0
1640531527
-1013904242
626627285
-2027808484
}
ThreadLocalMap類
ThreadLocalMap類是ThreadLocal的內部類。其作為一個容器,為ThreadLocal提供操作執行緒本地變數的功能。每一個Thread物件中都會有一個ThreadLocalMap物件例項(成員變數threadLocals,初始值為null)。因為map是Thread物件的非公共成員,不會被併發呼叫,所以不用考慮併發風險。
後文將從資料儲存設計、初始化、增刪資料等方面分析對應原始碼。
ThreadLocalMap-資料儲存設計
該map和hashmap類似,使用一個Entry陣列來儲存節點元素,定義size變數表示當前容器中元素的數量,定義threshold變數用於計算擴容的閾值。
// Entry陣列
private Entry[] table;
// 容器內元素個數
private int size = 0;
// 擴容計算用閾值
private int threshold;
不同的是Entry節點為WeakReference類的子類,使用引用欄位作為鍵,將弱引用欄位(通常是ThreadLocal物件)和值繫結在一起。使用弱引用是為了使得threadLocal物件可以被回收,(如果將key作為entry的一個成員變數,那執行緒銷燬前,threadLocal物件不會被回收掉,即使該threadLocal物件不再使用)。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
ThreadLocalMap-初始化
提供了帶初始鍵和初始值的map構造方法,還有一個基於已有map的構造方法(用於ThreadLocal的子類InheritableThreadLocal初始化map容器,目的是將父執行緒的map傳入子執行緒,會在建立子執行緒的過程中自動執行)。如下所示:
// 基於初始鍵值的建構函式
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 基於輸入鍵值構建節點
table = new Entry[INITIAL_CAPACITY];
// 根據鍵的hash值計算所在陣列下標
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 採用懶載入的方式,只建立一個必要的節點
table[i] = new Entry(firstKey, firstValue);
size = 1;
// 設定閾值為初始長度的2/3,初始長度預設為12,那麼閾值為為8
setThreshold(INITIAL_CAPACITY);
}
// 基於已有map的建構函式
private ThreadLocalMap(ThreadLocalMap parentMap) {
// 獲取傳入map的節點陣列
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
// 構造相同長度的陣列
table = new Entry[len];
// 深拷貝傳入陣列中各個節點到當前容器陣列
// 注意這裡因為採用開放地址解決hash衝突,拷貝後的元素在陣列中的位置與原陣列不一定相同
for (int j = 0; j < len; j++) {
// 獲取陣列各個位置上的節點
Entry e = parentTable[j];
if (e != null) {
@SuppressWarnings("unchecked")
ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
if (key != null) {
//確保key為InheritableThreadLocal型別,否則丟擲UnsupportedOperationException
Object value = key.childValue(e.value);
Entry c = new Entry(key, value);
// 根據hash值和陣列長度,計算下標
int h = key.threadLocalHashCode & (len - 1);
// 這裡採用開放地址的方法解決hash衝突
// 當發生衝突時,就順延到陣列下一位,直到該位置沒有元素
while (table[h] != null)
h = nextIndex(h, len);
table[h] = c;
size++;
}
}
}
}
ThreadLocalMap-移除元素
這裡將移除元素的方法放在前面,是因為其它部分會頻繁使用過時節點的移除方法。先理解這部分內容有助於後續理解其他部分。
根據key移除容器元素的方法:
private void remove(ThreadLocal<?> key) {
// 計算索引下標
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 從下標i處開始向後尋找是否有key對應節點,直到遇到Null節點
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
// 如果遇到key對應節點,執行移除操作
if (e.get() == key) {
// 移除節點的鍵(弱引用)
e.clear();
// 移除該過時節點
expungeStaleEntry(i);
return;
}
}
}
移除過時節點的執行方法:
移除過時節點除了將該節點置為null之外,還要對該節點之後的節點進行移動,看看能不能往前找合適的空格轉移。
這種方法有點類似jvm垃圾回收演算法的標記-整理方法。都是將垃圾清除之後,將剩餘元素進行整理,變得更緊湊。這裡的整理是需要強制執行的,目的是為了保證開放地址法一定能在連續的非null節點塊中找到已有節點。(試想,如果把過時節點移除而不整理,該節點為null,將前後節點分開了。而如果後面有某個節點hash計算的下標在前面的節點塊,在查詢節點時通過開放地址會找不到該節點)。示意圖如下:
private int expungeStaleEntry(int staleSlot) {
// 獲取entyy陣列和長度
Entry[] tab = table;
int len = tab.length;
// 清除staltSlot節點的值的引用,清除節點的引用
tab[staleSlot].value = null;
tab[staleSlot] = null;
// 容器元素個數-1
size--;
// 清除staleSlot節點後的整理工作
// 將staleSlot索引後的節點計算下標往前插空移動
Entry e;
int i;
// 遍歷連續的非null節點,直到遇到null節點
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// case1:如果遍歷到的節點是過時節點,將該節點清除,容器元素數量-1
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
// case2:如果遍歷到的節點不是過時節點,重新計算下標
int h = k.threadLocalHashCode & (len - 1);
// 當下標不是當前位置時,從hash值計算的下標h處,開放地址往後順延插空
if (h != i) {
// 先將該節點置為null
tab[i] = null;
// 找到為null的節點,插入節點
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
移除所有過時節點的方法:很簡單,全域性遍歷,移除所有過時節點。
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
// 遇到過時節點,清除整理該節點所在的連續塊
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}
嘗試去掃描一些過時節點並清除節點,如果有節點被清除會返回true。這裡只執行了logn次掃描判斷,是為了在不掃描和全域性掃描之間找到一種平衡,是上面的方法的一個平衡。
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;
// 從該連續塊後第一個null節點開始
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
ThreadLocalMap-獲取元素
獲取容器元素的方法:
// 根據key快速查詢entry節點
private Entry getEntry(ThreadLocal<?> key) {
// 通過threadLocal物件(key)的hash值計算陣列下標
int i = key.threadLocalHashCode & (table.length - 1);
// 取對應下標元素
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
// 查詢不到有兩種情況:
// 1.對應下標桶位為空
// 2對應下標桶位元素不是key關聯的entry(開放地址解決hash衝突導致的)
return getEntryAfterMiss(key, i, e);
}
// 初次查詢失敗後再次查詢entry節點
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
// 獲取entry陣列及長度
Entry[] tab = table;
int len = tab.length;
// 如果e為null,說明對應下標桶位為空,找不到key對應的entry
// 如果e不為null,則用解決hash衝突時的方法(順延陣列下一位)一直找下去,直到找到或e為null
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
// 在尋找的過程中如果節點的key,即ThreadLocal已經被回收(被弱引用的物件可能會被回收)
// 則移除過時的節點,移除過時節點的方法分析見移除元素部分
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
// 沒有找到,返回null
return null;
}
ThreadLocalMap-增加和修改元素
增加和修改容器元素的方法:
這裡在根據hash值計算出下標後,由於是開放地址解決hash衝突,會順序向後遍歷直到遇到null或遇到key對應的節點。
這裡會出現三種情況:
case1:遍歷時找到了key對應節點,這時直接修改節點的值即可;
case2:遍歷中遇到了有過時的節點(key被回收的節點);
case3:遍歷沒有遇到過時的節點,也沒有找到key對應節點,說明此時應該插入新節點(用輸入鍵值構造新節點)。因為是增加新元素,所以可以容量會超過閾值。在刪除節點後容量如果超過閾值,則要進行擴容操作。
private void set(ThreadLocal<?> key, Object value) {
// 獲取陣列,計算key對應的陣列下標
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 從下標i開始,順序遍歷陣列(順著hash衝突開放地址的路徑),直到節點為null
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
// 獲取遍歷到的節點的key
ThreadLocal<?> k = e.get();
// case1:命中key,說明已存在key對應節點,修改value值即可
if (k == key) {
e.value = value;
return;
}
// case2:如果遍歷到的節點的key為null,說明該threadLocal物件已經被回收
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// case3:遍歷節點直到null都沒有找到對應key,說明map中沒有key對應entry
// 則在該位置用輸入鍵和值新建一個entry節點
tab[i] = new Entry(key, value);
int sz = ++size;
// 判斷是否清理過時節點後,在判斷是否需要擴容
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
case2:增加和修改過程中遇到已經過時的節點的處理。這裡的引數staleSlot表示key計算的下標開始往後遇到的第一個過時節點,不管map中有無key對應的節點,該位置之後一定會存入key的節點。這裡定義了一個變數slotToExpunge,其含義是左右連續非null的entry塊中第一個過時節點(記錄該位置是為了後續清除過時節點可以從slotToExpunge處開始)。示意如下:
這步操作有兩種情況:
casse2.1:從過時節點staleSlot往後查詢遇到key對應節點,則將staleSlot處節點與key對應節點交換。然後清除整理連續塊。
casse2.2:沒遇到key對應節點,說明map中不存在key對應節點,則新建一個節點填入staleSlot處。然後清除整理連續塊。
private void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot) {
// 獲取entry陣列和長度
Entry[] tab = table;
int len = tab.length;
Entry e;
// 往前移動尋找第一個過時節點(直到遇到null),如果沒找到的話說明第一個過時節點為staleslot處節點
// slotToExpunge表示連續塊中第一個過時節點
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
// 從輸入下標staleSlot向後找到第一個出現的key對應的節點或過時的節點(key被回收的節點)
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// case2.1:如果找到key對應的節點,則用staleSlot處節點和該節點交換,以保持hash表的順序(hash衝突時順序向後尋找)
// 交換後的staleSlot節點及其之前的過時節點會被清除
if (k == key) {
// 交換staleSlot處節點和key對應節點
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 更新slotToExpunge的值,使其保持連續塊中第一個過時節點的特性,方便後續清理過時節點。
if (slotToExpunge == staleSlot)
slotToExpunge = i;
// 從slotToExpunge開始清除整理連續塊
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// 如果遇到過時節點,更新slotToExpunge的值
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// case2.2:沒有找到key對應節點,增加新節點並填入staleSlot處
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// 這裡如果slotToExpunge=staleSlot,說明連續塊中只有一個過時節點,且已經被新建節點填入,就不需要再整理。
// 如果除了原staleSlot處,還有其它過時節點,從slotToExpunge開始清除整理連續塊
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
case3:增加元素後可能超過閾值導致的擴容處理
private void rehash() {
// 清除所有過時節點
expungeStaleEntries();
// 在清除所有過時節點後,如果數量超過3/4的閾值,則進行擴容處理
// setThreshold()方法非公有,threshold值一直為陣列長度的2/3,所以這裡是超過陣列長度一半就進行擴容
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];
// 如果為非null節點
if (e != null) {
ThreadLocal<?> k = e.get();
// 如果是過時節點,則將value置為null,可以使得value的實體儘快被回收
if (k == null) {
e.value = null; // Help the GC
} else {
// 如果是正常節點,計算下標,重新填入新陣列(開放地址解決hash衝突)
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
// 新陣列元素個數+1
count++;
}
}
}
// 重新設定閾值
setThreshold(newLen);
size = count;
// 將變數table指向新陣列
table = newTab;
}
ThreadLocalMap-記憶體洩露問題以及對設計的一些思考
先來聊一聊記憶體洩漏這個概念。我的理解是有一塊記憶體空間,如果不再被使用但又不能被垃圾回收器回收掉,那麼就相當於這塊記憶體少了這塊空間,即出現了記憶體洩露問題。如果記憶體洩露的空間一直在積累,那麼最終會導致可用空間一直減少,最終可能導致程式無法執行。
ThreadLocalMap中也是有可能會出現該問題的,map中entry節點的key為弱引用,如果key沒有其它強引用,是會被垃圾收集器回收的。回收之後,map中該節點的value就不會再被使用,但value又被entry節點強引用,不會被回收。這就相當於value這塊記憶體空間發生了洩露。所以能看到在原始碼中很多方法都進行了清除過時節點的操作,為的就是儘量避免記憶體洩漏。
在看原始碼時,一直在思考為什麼entry節點的鍵要採用弱引用的方式。不妨反過來思考,如果entry節點將threadLocal物件作為一個成員變數,而不是採用弱引用的方式,那麼entry節點一直對key和value保持著強引用關係,即使threadlocal物件在其它地方都不再使用,該物件也不會被回收。這就會導致entry節點永遠不會被回收(只要執行緒不終結),而且也不能主動去判斷是否切斷map中threadlocal物件的引用(不知道是否還有其它地方引用到了)。
因為map是Thread物件的一個成員變數,執行緒不終結,map是不會被回收的,如果發生了記憶體洩露的問題,可能會一直積累下去,最終導致程式發生異常。而key採用弱引用加之主動的判斷過時節點(判斷是否過時很簡單,看key是否為null即可)並進行清除處理可以最大限度的減少記憶體洩露的發生。