為什么大家都說 ThreadLocal 存在內存泄漏的風險?
01、背景介紹
在 Java web 項目中,想必很多的同學對ThreadLocal這個類并不陌生,它最常用的應用場景就是用來做對象的跨層傳遞,避免多次傳遞,打破層次之間的約束。
比如下面這個HttpServletRequest參數傳遞的簡單例子!
public class RequestLocal {
/**
* 線程本地變量
*/
private static ThreadLocal<HttpServletRequest> local = new ThreadLocal<>();
/**
* 存儲請求對象
* @param request
*/
public static void set(HttpServletRequest request){
local.set(request);
}
/**
* 獲取請求對象
* @return
*/
public static HttpServletRequest get(){
return local.get();
}
/**
* 移除請求對象
* @return
*/
public static void remove(){
local.remove();
}
}
public class MyServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 存儲請求對象變量
RequestLocal.set(req);
try {
// 業務邏輯...
} finally {
// 請求完畢之后,移除請求對象變量
RequestLocal.remove();
}
}
}
// 在需要的地方,通過 RequestLocal 類獲取HttpServletRequest對象
HttpServletRequest request = RequestLocal.get();
看完以上示例,相信大家對ThreadLocal的使用已經有了大致的認識。
當然ThreadLocal的作用還不僅限如此,作為 Java 多線程模塊的一部分,ThreadLocal也經常被一些面試官作為知識點用來提問,因此只有理解透徹了,回答才能更加游刃有余。
下面我們從ThreadLocal類的源碼解析到使用方式做一次總結,如果有不正之處,請多多諒解,并歡迎批評指出。
02、源碼剖析
ThreadLocal類,也經常被叫做線程本地變量,也有的叫做本地線程變量,意思其實差不多,通俗的解釋:ThreadLocal作用是為變量在每個線程中創建一個副本,這樣每個線程就可以訪問自己內部的副本變量;同時,該變量對其他線程而言是封閉且隔離的。
字面的意思很容易理解,但是實際上ThreadLocal類的實現原理還有點復雜。
打開ThreadLocal類,它總共有 4 個public方法,內容如下!
方法 | 描述 |
public void set(T value) | 設置當前線程變量 |
public T get() | 獲取當前線程變量 |
public void remove() | 移除當前線程設置的變量 |
public static ThreadLocal withInitial(Supplier supplier) | 自定義初始化當前線程的默認值 |
其中使用最多的就是set()、get()和remove()方法,至于withInitial()方法,一般在ThreadLocal對象初始化的時候,給定一個默認值,例如下面這個例子!
// 給所有線程初始化一個變量 1
private static ThreadLocal<Integer> localInt = ThreadLocal.withInitial(() -> 1);
下面我們重點來剖析以上三個方法的源碼,最后總結如何正確的使用。
以下源碼解析均基于jdk1.8。
2.1、set 方法
打開ThreadLocal類,set()方法的源碼如下!
public void set(T value) {
// 首先獲取當前線程對象
Thread t = Thread.currentThread();
// 獲取當前線程中的變量 ThreadLocal.ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 如果不為空,就設置值
if (map != null)
map.set(this, value);
else
// 如果為空,初始化一個ThreadLocalMap變量,其中key為當前的threadlocal變量
createMap(t, value);
}
我們繼續看看createMap()方法的源碼,內容如下!
void createMap(Thread t, T firstValue) {
// 初始化一個 ThreadLocalMap 對象,并賦予給 Thread 對象
// 可以發現,其實 ThreadLocalMap 是 Thread 類的一個屬性變量
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// INITIAL_CAPACITY 變量的初始值為 16
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
從上面的源碼上你會發現,通過ThreadLocal類設置的變量,最終保存在每個線程自己的ThreadLocal.ThreadLocalMap對象中,其中key是當前線程的ThreadLocal變量,value就是我們設置的變量。
基于這點,可以得出一個結論:每個線程設置的變量只有自己可見,其它線程無法訪問,因為這個變量是線程自己獨有的屬性。
從上面的源碼也可以看出,真正負責存儲value變量的是Entry靜態類,并且這個類繼承了一個WeakReference類。稍有不同的是,Entry靜態類中的key是一個弱引用類型對象,而value是一個強引用類型對象。這樣設計的好處在于,弱引用的對象更容易被 GC 回收,當ThreadLocal對象不再被其他對象使用時,可以被垃圾回收器自動回收,避免可能的內存泄漏。關于這一點,我們在下文再詳細的介紹。
最后我們再來看看map.set(this, value)這個方法的源碼邏輯,內容如下!
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
// 根據hash和位運算,計算出數組中的存儲位置
int i = key.threadLocalHashCode & (len-1);
// 循環遍歷檢查計算出來的位置上是否被占用
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
// 進入循環體內,說明當前位置已經被占用了
ThreadLocal<?> k = e.get();
// 如果key相同,直接進行覆蓋
if (k == key) {
e.value = value;
return;
}
// 如果key為空,說明key被回收了,重新覆蓋
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 當沒有被占用,循環結束之后,取最后計算的空位,進行存儲
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
private static int nextIndex(int i, int len) {
// 下標依次自增
return ((i + 1 < len) ? i + 1 : 0);
}
從上面的源碼分析可以看出,ThreadLocalMap和HashMap,雖然都是鍵值對的方式存儲數據,當在數組中存儲數據的下表沖突時,存儲數據的方式有很大的不同。jdk1.8種的HashMap采用的是鏈表法和紅黑樹來解決下表沖突,當
ThreadLocalMap采用的是開放尋址法來解決hash沖突,簡單的說就是當hash出來的存儲位置相同但key不一樣時,會繼續尋找下一個存儲位置,直到找到空位來存儲數據。
圖片
而jdk1.7中的HashMap采用的是鏈表法來解決hash沖突,當hash出來的存儲位置相同但key不一樣時,會將變量通過鏈表的方式掛在數組節點上。
為了實現更高的讀寫效率,jdk1.8中的HashMap就更為復雜了,當沖突的鏈表長度超過 8 時,鏈表會轉變成紅黑樹,在此不做過多的講解,有興趣的同學可以翻看關于HashMap的源碼分析文章。
一路分析下來,是不是感覺set()方法還是挺復雜的,總結下來set()大致的邏輯有以下幾個步驟:
- 1.首先獲取當前線程對象,檢查當前線程中的ThreadLocalMap是否存在
- 2.如果不存在,就給線程創建一個ThreadLocal.ThreadLocalMap對象
- 3.如果存在,就設置值,存儲過程中如果存在 hash 沖突時,采用開放尋址法,重新找一個空位進行存儲
2.2、get 方法
了解完set()方法之后,get()方法就更容易了,get()方法的源碼如下!
public T get() {
// 獲取當前線程對象
Thread t = Thread.currentThread();
// 從當前線程對象中獲取 ThreadLocalMap 對象
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(this)這個方法,源碼如下!
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 如果找到key,直接返回
if (e != null && e.get() == key)
return e;
else
// 如果找不到,就嘗試清理,如果你總是訪問存在的key,那么這個清理永遠不會進來
return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
// e指的是entry ,也就是一個弱引用
ThreadLocal<?> k = e.get();
// 如果找到了,就返回
if (k == key)
return e;
if (k == null)
// 如果key為null,說明已經被回收了,同時將value設置為null,以便進行回收
expungeStaleEntry(i);
else
// 如果key不是要找的那個,那說明有hash沖突,繼續找下一個entry
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
從上面的源碼可以看出,get()方法邏輯,總共有以下幾個步驟:
- 1.首先獲取當前線程對象,從當前線程對象中獲取 ThreadLocalMap 對象
- 2.然后判斷ThreadLocalMap是否存在,如果存在,就嘗試去獲取最終的value
- 3.如果不存在,就重新初始化默認值,以便清理舊的value值
其中expungeStaleEntry()方法是真正用于清理value值的,setInitialValue()方法也具備清理舊的value變量作用。
從上面的代碼可以看出,ThreadLocal為了清楚value變量,花了不少的心思,其實本質都是為了防止ThreadLocal出現可能的內存泄漏。
2.3、remove 方法
我們再來看看remove()方法,源碼如下!
public void remove() {
// 獲取當前線程里面的 ThreadLocalMap 對象
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);
// 循環遍歷目標key,然后將key和value都設置為null
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
// 清理value值
expungeStaleEntry(i);
return;
}
}
}
remove()方法邏輯比較簡單,首先獲取當前線程的ThreadLocalMap對象,然后循環遍歷key,將目標key以及對應的value都設置為null。
從以上的源碼剖析中,可以得出一個結論:不管是set()、get()還是remove(),其實都會主動清理無效的value數據,因此實際開發過程中,沒有必要過于擔心內存泄漏的問題。
03、為什么要用 WeakReference?
另外細心的同學可能會發現,ThreadLocal中真正負責存儲key和value變量的是Entry靜態類,并且它繼承了一個WeakReference類。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
關于WeakReference類,我們在上文只是簡單的說了一下,可能有的同學不太清楚,這個再次簡要的介紹一下。
了解過WeakHashMap類的同學,可能對WeakReference有印象,它表示當前對象為弱引用類型。
在 Java 中,對象有四種引用類型,分別是:強引用、軟引用、弱引用和虛引用,級別從高依次到低。
不同引用類型的對象,GC 回收的方式也不一樣,對于強引用類型,不會被垃圾收集器回收,即使當內存不足時,另可拋異常也不會主動回收,防止程序出現異常,通常我們自定義的類,初始化的對象都是強引用類型;對于軟引用類型的對象,當不存在外部強引用的時候,GC 會在內存不足的時候,進行回收;對于弱引用類型的對象,當不存在外部強引用的時候,GC 掃描到時會進行回收;對于虛引用,GC 會在任何時候都可能進行回收。
下面我們看一個簡單的示例,更容易直觀的了解它。
public static void main(String[] args) {
Map weakHashMap = new WeakHashMap();
//向weakHashMap中添加4個元素
for (int i = 0; i < 3; i++) {
weakHashMap.put("key-"+i, "value-"+ i);
}
//輸出添加的元素
System.out.println("數組長度:"+weakHashMap.size() + ",輸出結果:" + weakHashMap);
//主動觸發一次GC
System.gc();
//再輸出添加的元素
System.out.println("數組長度:"+weakHashMap.size() + ",輸出結果:" + weakHashMap);
}
輸出結果:
數組長度:3,輸出結果:{key-2=value-2, key-1=value-1, key-0=value-0}
數組長度:3,輸出結果:{}
以上存儲的弱引用對象,與外部對象沒有強關聯,當主動調用 GC 回收器的時候,再次查詢WeakHashMap里面的數據的時候,弱引用對象收回,所以內容為空。其中WeakHashMap類底層使用的數據存儲對象,也是繼承了WeakReference。
采用WeakReference這種弱引用的方式,當不存在外部強引用的時候,就會被垃圾收集器自動回收掉,減小內存空間壓力。
需要注意的是,Entry靜態類中僅僅只是key被設計成弱引用類型,value依然是強引用類型。
回歸正題,為什么ThreadLocalMap類中的Entry靜態類中的key需要被設計成弱引用類型?
我們先看一張Entry對象的依賴圖!
圖片
如上圖所示,Entry持有ThreadLocal對象的引用,如果沒有設置引用類型,這個引用鏈就全是強引用,當線程沒有結束時,它持有的強引用,包括遞歸下去的所有強引用都不會被垃圾回收器回收;只有當線程生命周期結束時,才會被回收。
哪怕顯式的設置threadLocal = null,它也無法被垃圾收集器回收,因為Entry和key存在強關聯!
如果Entry中的key設置成弱引用,當threadLocal = null時,key就可以被垃圾收集器回收,進一步減少內存使用空間。
但是也僅僅只是回收key,不能回收value,如果這個線程運行時間非常長,又沒有調用set()、get()或者remove()方法,隨著線程數的增多可能會有內存溢出的風險。
因此在實際的使用中,想要徹底回收value,使用完之后可以顯式調用一下remove()方法。
04、應用介紹
通過以上的源碼分析,相信大家對ThreadLocal類已經有了一些認識,它主要的作用是在線程內實現變量的傳遞,每個線程只能看到自己設定的變量。
我們可以看一個簡單的示例!
public static void main(String[] args) {
ThreadLocal threadLocal = new ThreadLocal();
threadLocal.set("main");
for (int i = 0; i < 5; i++) {
final int j = i;
new Thread(new Runnable() {
@Override
public void run() {
// 設置變量
threadLocal.set(String.valueOf(j));
// 獲取設置的變量
System.out.println("thread name:" + Thread.currentThread().getName() + ", 內容:" + threadLocal.get());
}
}).start();
}
System.out.println("thread name:" + Thread.currentThread().getName() + ", 內容:" + threadLocal.get());
}
輸出結果:
thread name:Thread-0, 內容:0
thread name:Thread-1, 內容:1
thread name:Thread-2, 內容:2
thread name:Thread-3, 內容:3
thread name:main, 內容:main
thread name:Thread-4, 內容:4
從運行結果上可以很清晰的看出,每個線程只能看到自己設置的變量,其它線程不可見。
ThreadLocal可以實現線程之間的數據隔離,在實際的業務開發中,使用非常廣泛,例如文章開頭介紹的HttpServletRequest參數的上下文傳遞。
05、小結
最后我們來總結一下,ThreadLocal類經常被叫做線程本地變量,它確保每個線程的ThreadLocal變量都是各自獨立的,其它線程無法訪問,實現線程之間數據隔離的效果。
ThreadLocal適合在一個線程的處理流程中實現參數上下文的傳遞,避免同一個參數在所有的方法中傳遞。
使用ThreadLocal時,如果當前線程中的變量已經使用完畢并且永久不在使用,推薦手動調用移除remove()方法,可以采用try ... finally結構,并在finally中清除變量,防止存在潛在的內存溢出風險。
06、參考
1、https://www.cnblogs.com/xrq730/p/4854813.html
2、https://www.cnblogs.com/xrq730/p/4854820.html
3、https://zhuanlan.zhihu.com/p/102744180