1. 前言
ThreadLocal 也是一個使用頻率較高的類,在框架中也經常見到,比如 Spring,
有關 ThreadLocal 原始碼分析的文章不少,其中有個問題常被提及:ThreadLocal 是否存在記憶體泄漏?
不少文章對此講述比較模糊,經常讓人看完腦子還是一頭霧水,我也有此困惑,因此找時間跟小伙伴討論了一番,總算對這個問題有了一定的理解,這里記錄和分享一下,希望對有同樣困惑的朋友們有所幫助,當然,若有理解不當的地方也歡迎指正,
啰嗦就到這里,下面先從 ThreadLocal 的一個應用場景開始分析吧,
2. 應用場景
ThreadLocal 的應用場景不少,這里舉個簡單的例子:單點登錄攔截,
也就是在處理一個 HTTP 請求之前,判斷用戶是否登錄:
- 若未登錄,跳轉到登錄頁面;
- 若已登錄,獲取并保存用戶的登錄資訊,
先定義一個 UserInfoHolder 類保存用戶的登錄資訊,其內部用 ThreadLocal 存盤,示例如下:
public class UserInfoHolder {
private static final ThreadLocal<Map<String, String>> USER_INFO_THREAD_LOCAL = new ThreadLocal<>();
public static void set(Map<String, String> map) {
USER_INFO_THREAD_LOCAL.set(map);
}
public static Map<String, String> get() {
return USER_INFO_THREAD_LOCAL.get();
}
public static void clear() {
USER_INFO_THREAD_LOCAL.remove();
}
// ...
}
通過 UserInfoHolder 可以存盤和獲取用戶的登錄資訊,以便在業務中使用,
Spring 專案中,如果我們想在處理一個 HTTP 請求之前或之后做些額外的處理,通常定義一個類繼承 HandlerInterceptorAdapter,然后重寫它的一些方法,舉例如下(僅供參考,省略了一些代碼):
public class LoginInterceptor extends HandlerInterceptorAdapter {
// ...
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// ...
// 請求執行前,獲取用戶登錄資訊并保存
Map<String, String> userInfoMap = getUserInfo();
UserInfoHolder.set(userInfoMap);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 請求執行后,清理掉用戶資訊
UserInfoHolder.clear();
}
}
在本例中,我們在處理一個請求之前獲取用戶的資訊,在處理完請求之后,將用戶資訊清空,應該有朋友在框架或者自己的專案中見過類似代碼,
下面我們深入 ThreadLocal 的內部,來分析這些方法做了些什么,跟記憶體泄漏又是怎么扯上關系的,
3. 原始碼剖析
3.1 類簽名
先從頭開始,也就是類簽名:
public class ThreadLocal<T> {
}
可見它就是一個普通的類,并沒有實作任何介面、也無父類繼承,
3.2 構造器
ThreadLocal 只有一個無參構造器:
public ThreadLocal() {
}
此外,JDK 1.8 引入了一個使用 lambda 運算式初始化的靜態方法 withInitial,如下:
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
return new SuppliedThreadLocal<>(supplier);
}
該方法也可以初始化一個物件,和構造器也比較接近,
3.3 ThreadLocalMap
3.3.1 主要代碼
ThreadLocalMap 是 ThreadLocal 的一個內部嵌套類,
由于 ThreadLocal 的主要操作實際都是通過 ThreadLocalMap 的方法實作的,因此先分析 ThreadLocalMap 的主要代碼:
public class ThreadLocal<T> {
// 生成 ThreadLocal 的哈希碼,用于計算在 Entry 陣列中的位置
private final int threadLocalHashCode = nextHashCode();
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
// ...
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 初始容量,必須是 2 的次冪
private static final int INITIAL_CAPACITY = 16;
// 存盤資料的陣列
private Entry[] table;
// table 中的 Entry 數量
private int size = 0;
// 擴容的閾值
private int threshold; // Default to 0
// 設定擴容閾值
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
// 第一次添加元素使用的構造器
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 的內部結構其實跟 HashMap 很類似,可以對比前面「JDK原始碼分析-HashMap(1)」對 HashMap 的分析,
二者都是「鍵-值對」構成的陣列,對哈希沖突的處理方式不同,導致了它們在結構上產生了一些區別:
- HashMap 處理哈希沖突使用的「鏈表法」,也就是當產生沖突時拉出一個鏈表,而且 JDK 1.8 進一步引入了紅黑樹進行優化,
- ThreadLocalMap 則使用了「開放尋址法」中的「線性探測」,即,當某個位置出現沖突時,從當前位置往后查找,直到找到一個空閑位置,
其它部分大體是類似的,
3.3.2 注意事項
- 弱參考
有個值得注意的地方是:ThreadLocalMap 的 Entry 繼承了 WeakReference 類,也就是弱參考型別,
跟進 Entry 的父類,可以看到 ThreadLocal 最終賦值給了 WeakReference 的父類 Reference 的 referent 屬性,即,可以認為 Entry 持有了兩個物件的參考:ThreadLocal 型別的「弱參考」和 Object 型別的「強參考」,其中 ThreadLocal 為 key,Object 為 value,如圖所示:
ThreadLocal 在某些情況可能產生的「記憶體泄漏」就跟這個「弱參考」有關,后面再展開分析,
- 尋址
Entry 的 key 是 ThreadLocal 型別的,它是如何在陣列中散列的呢?
ThreadLocal 有個 threadLocalHashCode 變數,每次創建 ThreadLocal 物件時,這個變數都會增加一個固定的值 HASH_INCREMENT,即 0x61c88647,這個數字似乎跟黃金分割、斐波那契數有關,但這不是重點,有興趣的朋友可以去深入研究下,這里我們知道它的目的就行了,與 HashMap 的 hash 演算法的目的近似,就是為了散列的更均勻,
下面分析 ThreadLocal 的主要方法實作,
3.4 主要方法
ThreadLocal 主要有三個方法:set、get 和 remove,下面分別介紹,
3.4.1 set 方法
- set 方法:新增/更新 Entry
public void set(T value) {
// 獲取當前執行緒
Thread t = Thread.currentThread();
// 從 Thread 中獲取 ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
threadLocals 是 Thread 持有的一個 ThreadLocalMap 參考,默認是 null:
public class Thread implements Runnable {
// 其他代碼...
ThreadLocal.ThreadLocalMap threadLocals = null;
}
- 執行流程
若從當前 Thread 拿到的 ThreadLocalMap 為何,表示該屬性并未初始化,執行 createMap 初始化:
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
若已存在,則呼叫 ThreadLocalMap 的 set 方法:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
// 1. 計算 key 在陣列中的下標 i
int i = key.threadLocalHashCode & (len-1);
// 1.1 若陣列下標為 i 的位置有元素
// 判斷 i 位置的 Entry 是否為空;不為空則從 i 開始向后遍歷陣列
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 索引為 i 的元素就是要查找的元素,用新值覆寫舊值,到此回傳
if (k == key) {
e.value = value;
return;
}
// 索引為 i 的元素并非要查找的元素,且該位置中 Entry 的 Key 已經是 null
// Key 為 null 表明該 Entry 已經過期了,此時用新值來替換這個位置的過期值
if (k == null) {
// 替換過期的 Entry,
replaceStaleEntry(key, value, i);
return;
}
}
// 1.2 若陣列下標為 i 的位置為空,將要存盤的元素放到 i 的位置
tab[i] = new Entry(key, value);
int sz = ++size;
// 若未清理過期的 Entry,且陣列的大小達到閾值,執行 rehash 操作
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
先總結下 set 方法主要流程:
首先根據 key 的 threadLocalHashCode 計算它的陣列下標:
- 如果陣列下標的 Entry 不為空,表示該位置已經有元素,由于可能存在哈希沖突,因此這個位置的元素可能并不是要找的元素,所以遍歷陣列去比較
- 如果找到等于當前 key 的 Entry,則用新值替換舊值,回傳,
- 如果遍歷程序中,遇到 Entry 不為空、但是 Entry 的 key 為空的情況,則會做一些清理作業,
- 如果陣列下標的 Entry 為空,直接將元素放到這里,必要時進行擴容,
- replaceStaleEntry:替換過期的值,并清理一些過期的 Entry
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
// 從 staleSlot 開始向前遍歷,若遇到過期的槽(Entry 的 key 為空),更新 slotToExpunge
// 直到 Entry 為空停止遍歷
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 相等的 Entry,更新舊值,并將二者換位置
// 目的是把它放到「應該」在的位置
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
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 (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// If key not found, put new entry in stale slot
// 若未找到 key,說明 Entry 此前并不存在,新增
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);
}
replaceStaleEntry 的主要執行流程如下:
- 從 staleSlot 向前遍歷陣列,直到 Entry 為空時停止遍歷,這一步的主要目的是查找 staleSlot 前面過期的 Entry 的陣列下標 slotToExpunge,
- 從 staleSlot 向后遍歷陣列
- 若 Entry 的 key 與給定的 key 相等,將該 Entry 與 staleSlot 下標的 Entry 互換位置,目的是為了讓新增的 Entry 放到它「應該」在的位置,
- 若找不到相等的 key,說明該 key 對應的 Entry 不在陣列中,將新值放到 staleSlot 位置,該操作其實就是處理哈希沖突的「線性探測」方法:當某個位置已被占用,向后探測下一個位置,
- 若 staleSlot 前面存在過期的 Entry,則執行清理操作,
PS: 所謂 Entry「應該」在的位置,就是根據 key 的 threadLocalHashCode 與陣列長度取余計算出來的位置,即 k.threadLocalHashCode & (len - 1) ,或者哈希沖突之后的位置,這里只是為了方便描述,
- expungeStaleEntry:清理過期的 Entry
// staleSlot 表示過期的槽位(即 Entry 陣列的下標)
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 1. 將給定位置的 Entry 置為 null
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)) {
// 獲取 Entry 的 key
ThreadLocal<?> k = e.get();
if (k == null) {
// 若 key 為 null,表示 Entry 過期,將 Entry 置空
e.value = null;
tab[i] = null;
size--;
} else {
// key 不為空,表示 Entry 未過期
// 計算 key 的位置,若 Entry 不在它「應該」在的位置,把它移到「應該」在的位置
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
- 從給定位置的下一個開始向后遍歷陣列
- 若遇到 Entry 為 null,結束遍歷
- 若遇到 key 為空的 Entry(即過期的),就將該 Entry 置空
- 若遇到 key 不為空的 Entry,而且經過計算,該 Entry 并不在它「應該」在的位置,則將其移動到它「應該」在的位置
- 回傳 staleSlot 后面的、Entry 為 null 的索引下標
- cleanSomeSlots:清理一些槽(Slot)
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];
// Entry 不為空、key 為空,即 Entry 過期
if (e != null && e.get() == null) {
n = len;
removed = true;
// 清理 i 后面連續過期的 Entry,直到 Entry 為 null,回傳該 Entry 的下標
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
該方法做了什么呢?從給定位置的下一個開始掃描陣列,若遇到 key 為空的 Entry(過期的),則清理該位置及其后面過期的槽,
值得注意的是,該方法回圈執行的次數為 log(n),由于該方法是在 set 方法內部被呼叫的,也就是新增/更新時:
- 如果不掃描和清理,set 方法執行速度很快,但是會存在一些垃圾(過期的 Entry);
- 如果每次都掃描清理,不會存在垃圾,但是插入性能會降低到 O(n),
因此,這個次數其實就一種平衡策略:Entry 陣列較小時,就少清理幾次;陣列較大時,就多清理幾次,
- rehash:調整 Entry 陣列
private void rehash() {
// 清理陣列中過期的 Entry
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
// 從頭開始清理整個 Entry 陣列
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);
}
}
該方法主要作用:
- 清理陣列中過期的 Entry
- 若清理后 Entry 的數量大于等于 threshold 的 3/4,則執行 resize 方法進行擴容
- resize 方法:Entry 陣列擴容
/**
* Double the capacity of the table.
*/
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2; // 新長度為舊長度的兩倍
Entry[] newTab = new Entry[newLen];
int count = 0;
// 遍歷舊的 Entry 陣列,將陣列中的值移到新陣列中
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
// 若 Entry 的 key 已過期,則將 Entry 清理掉
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 陣列擴容,主要流程:
- 創建一個新陣列,長度為原陣列的 2 倍;
- 從下標 0 開始遍歷舊陣列的所有元素
- 若元素已過期(key 為空),則將 value 也置空
- 將未過期的元素移到新陣列
3.4.2 get 方法
分析完了 set 方法,再看 get 方法就相對容易了不少,
- get 方法:獲取 ThreadLocal 對應的 Entry
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();
}
get 方法首先獲取當前執行緒的 ThreadLocalMap 并判斷:
- 若 Map 已存在,從 Map 中取值
- 若 Map 不存在,或者 Map 中獲取的值為空,執行 setInitialValue 方法
- setInitialValue 方法:獲取/設定初始值
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;
}
選取初始值,這個初始值默認為空(該方法是 protected,可以由子類初始化),
- 若 Thread 的 ThreadLocalMap 已初始化,則將初始值存入 Map
- 否則,創建 ThreadLocalMap
- 回傳初始值
除了初始值,其他邏輯跟 set 方法是一樣的,這里不再贅述,
PS: 可以看到初始值是惰性初始化的,
- getEntry:從 Entry 陣列中獲取給定 key 對應的 Entry
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 未命中
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; // 是要找的 key,回傳
if (k == null)
expungeStaleEntry(i); // Entry 已過期,清理 Entry
else
i = nextIndex(i, len); // 向后遍歷
e = tab[i];
}
return null;
}
3.4.3 remove 方法
- remove 方法:移除 ThreadLocal 對應的 Entry
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
這里呼叫了 ThreadLocalMap 的 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;
}
}
}
其中 e.clear 呼叫的是 Entry 的父類 Reference 的 clear 方法:
public void clear() {
this.referent = null;
}
其實就是將 Entry 的 key 置空,
remove 方法的主要執行流程如下:
- 獲取當前執行緒的 ThreadLocalMap
- 以當前 ThreadLocal 作為 key,從 Map 中查找相應的 Entry,將 Entry 的 key 置空
- 將該 ThreadLocal 對應的 Entry 置空,并向后遍歷清理 Entry 陣列,也就是 expungeStaleEntry 方法的操作,前面已經分析過了,這里不再贅述,
3.4.4 主要方法小結
ThreadLocal 的主要方法 set、get 和 remove 前面已經分析過,這里簡單做個小結,
set 方法
- 以當前 ThreadLocal 為 key、新增的 Object 為 value 組成一個 Entry,放入 ThreadLocalMap,也就是 Entry 陣列中,
- 計算 Entry 的位置后
- 若該槽為空,直接放到這里;并清理一些過期的 Entry,必要時進行擴容,
- 當遇到散列沖突時,線性探測向后查找陣列中為空的、或者已經過期的槽,用新值替換,
get 方法
- 以當前 ThreadLocal 為 key,從 Entry 陣列中查找對應 Entry 的 value
- 若 ThreadLocalMap 未初始化,則用給定初始值將其初始化
- 若 ThreadLocalMap 已初始化,從 Entry 資料查找 key
remove 方法:以當前 ThreadLocal 為 key,從 Entry 陣列清理掉對應的 Entry,并且在清理該位置后面的、過期的 Entry
方法雖少,但是稍微有點繞,除了做本身的功能,都執行了一些額外的清理操作,
分析了這幾個方法的原始碼之后,下面就來研究一下記憶體泄漏的問題,
4. 記憶體泄漏分析
首先說明一點,ThreadLocal 通常作為成員變數或靜態變數來使用(也就是共享的),比如前面應用場景中的例子,因為區域變數已經在同一條執行緒內部了,沒必要使用 ThreadLocal,
為便于理解,這里先給出了 Thread、ThreadLocal、ThreadLocalMap、Entry 這幾個類在 JVM 的記憶體示意圖:
簡單說明:
- 當一個執行緒運行時,堆疊中存在當前 Thread 的堆疊幀,它持有 ThreadLocalMap 的強參考,
- ThreadLocal 所在的類持有一個 ThreadLocal 的強參考;同時,ThreadLocalMap 中的 Entry 持有一個 ThreadLocal 的弱參考,
4.1 場景一
若方法執行完畢、執行緒正常消亡,則 Thread 的 ThreadLocalMap 參考將斷開,如圖:
以后 GC 發生時,弱參考也會斷開,整個 ThreadLocalMap 都會被回收掉,不存在記憶體泄漏,
4.2 場景二
如果是執行緒池中的執行緒呢?也就是執行緒一直存活,經過 GC 后 Entry 持有的 ThreadLocal 參考斷開,Entry 的 key 為空,value 不為空,如圖所示:
此時,如果沒有任何 remove 或者 get 等清理 Entry 陣列的動作,那么該 Entry 的 value 持有的 Object 就不會被回收掉,這樣就產生了記憶體泄漏,
這種情況其實也很容易避免,使用完執行 remove 方法就行了,
5. 小結
本文分析了 ThreadLocal 的主要方法實作,并分析了它可能存在記憶體泄漏的場景,
- ThreadLocal 主要用于當前執行緒從共享變數中保存一份「副本」,常用的一個場景就是單點登錄保存用戶的登錄資訊,
- ThreadLocal 將資料存盤在 ThreadLocalMap 中,ThreadLocalMap 是由 Entry 構成的陣列,結構有點類似 HashMap,
- ThreadLocal 使用不當可能會造成記憶體泄漏,避免記憶體泄漏的方法是在方法呼叫結束前執行 ThreadLocal 的 remove 方法,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/139417.html
標籤:Java
上一篇:Springboot啟動擴展點超詳細總結,再也不怕面試官問了
下一篇:不知道取啥標題=-=
