阿里二面:談談ThreadLocal的內(nèi)存泄漏問題?
引言
ThreadLocal在Java多線程編程中扮演著重要的角色,它提供了一種線程局部存儲機制,允許每個線程擁有獨立的變量副本,從而有效地避免了線程間的數(shù)據(jù)共享沖突。ThreadLocal的主要用途在于,當需要為每個線程維護一個獨立的上下文變量時,比如每個線程的事務ID、用戶登錄信息、數(shù)據(jù)庫連接等,可以減少對同步機制如synchronized關(guān)鍵字或Lock類的依賴,提高系統(tǒng)的執(zhí)行效率和簡化代碼邏輯。
但是我們在使用ThreadLocal時,經(jīng)常因為使用不當導致內(nèi)存泄漏。此時就需要我們?nèi)ヌ骄恳幌耇hreadLocal在哪些場景下會出現(xiàn)內(nèi)存泄露?哪些場景下不會出現(xiàn)內(nèi)存泄露?出現(xiàn)內(nèi)存泄露的根本原因又是什么呢?如何避免內(nèi)存泄露?
ThreadLocal原理
ThreadLocal的實現(xiàn)基于每個線程內(nèi)部維護的一個ThreadLocalMap。
public class Thread implements Runnable {
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
}
ThreadLocalMap是ThreadLocal類的一個靜態(tài)內(nèi)部類,ThreadLocal本身不能存儲數(shù)據(jù),它在作用上更像一個工具類,ThreadLocal類提供了set(T value)、get()等方法來操作ThreadLocalMap存儲數(shù)據(jù)。
public class ThreadLocal<T> {
// ...
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, 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();
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
// ...
}
而ThreadLocalMap內(nèi)部維護了一個Entry數(shù)據(jù),用來存儲數(shù)據(jù),Entry繼承了WeakReference,所以Entry的key是一個弱引用,可以被GC回收。Entry數(shù)組中的每一個元素都是一個Entry對象。每個Entry對象中存儲著一個ThreadLocal對象與其對應的value值。
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;
}
}
}
關(guān)于弱引用的知識點,請參考:
而Entry數(shù)組中Entry對象的下標位置是通過ThreadLocal的threadLocalHashCode計算出來的。
private ThreadLocalMap(ThreadLocalMap parentMap) {
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
table = new Entry[len];
for (Entry e : parentTable) {
if (e != null) {
@SuppressWarnings("unchecked")
ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
if (key != null) {
Object value = key.childValue(e.value);
Entry c = new Entry(key, value);
// 通過key的threadLocalHashCode計算下標,這個key就是ThreadLocall對象
int h = key.threadLocalHashCode & (len - 1);
while (table[h] != null)
h = nextIndex(h, len);
table[h] = c;
size++;
}
}
}
}
而從Entry數(shù)組中獲取對應key即ThreadLocal對應的value值時,也是通過key的threadLocalHashCode計算下標,從而可以快速的返回對應的Entry對象。
private Entry getEntry(ThreadLocal<?> key) {
// 通過key的threadLocalHashCode計算下標,這個key就是ThreadLocall對象
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);
}
在Thread中,可以存儲多個ThreadLocal對象。Thread、ThreadLocal、ThreadLocalMap以及Entry數(shù)組的關(guān)系如下圖:
圖片
ThreadLocal在哪些場景下不會出現(xiàn)內(nèi)存泄露?
當一個對象失去所有強引用,或者它僅被弱引用、軟引用、虛引用關(guān)聯(lián)時,垃圾收集器(GC)通常都能識別并回收這些對象,從而避免內(nèi)存泄漏的發(fā)生。當我們在手動創(chuàng)建線程時,若將變量存儲到ThreadLocal中,那么在Thread線程正常運行的過程中,它會維持對內(nèi)部ThreadLocalMap實例的引用。只要該Thread線程持續(xù)執(zhí)行任務,這種引用關(guān)系將持續(xù)存在,確保ThreadLocalMap實例及其中存儲的變量不會因無引用而被GC回收。
圖片
當線程執(zhí)行完任務并正常退出后,線程與內(nèi)部ThreadLocalMap實例之間的強引用關(guān)系隨之斷開,這意味著線程不再持有ThreadLocalMap的引用。在這種情況下,失去強引用的ThreadLocalMap對象將符合垃圾收集器(GC)的回收條件,進而被自動回收。與此同時,鑒于ThreadLocalMap內(nèi)部的鍵(ThreadLocal對象)是弱引用,一旦ThreadLocalMap被回收,若此時沒有其他強引用指向這些ThreadLocal對象,它們也將被GC一并回收。因此,在線程結(jié)束其生命周期后,與之相關(guān)的ThreadLocalMap及其包含的ThreadLocal對象理論上都能夠被正確清理,避免了內(nèi)存泄漏問題。
實際應用中還需關(guān)注ThreadLocalMap中存儲的值(非鍵)是否為強引用類型,因為即便鍵(ThreadLocal對象)被回收,如果值是強引用且沒有其他途徑釋放,仍可能導致內(nèi)存泄漏。
ThreadLocal在哪些場景下會出現(xiàn)內(nèi)存泄露?
在實際項目開發(fā)中,如果為每個任務都手動創(chuàng)建線程,這是一件很耗費資源的方式,并且在阿里巴巴的開發(fā)規(guī)范中也提到,不推薦使用手動創(chuàng)建線程,推薦使用線程池來執(zhí)行相對應的任務。那么當我們使用線程池時,線程池中的線程跟ThrealLocalMap的引用關(guān)系如下:
圖片
在使用線程池處理任務時,每一個線程都會關(guān)聯(lián)一個獨立的ThreadLocalMap對象,用于存儲線程本地變量。由于線程池中的核心線程在完成任務后不會被銷毀,而是保持活動狀態(tài)等待接收新的任務,這意味著核心線程與其內(nèi)部持有的ThreadLocalMap對象之間始終保持著強引用關(guān)系。因此,只要核心線程存活,其所對應的ThreadLocal對象和ThreadLocalMap不會被垃圾收集器(GC)自動回收,此時就會存在內(nèi)存泄露的風險。
出現(xiàn)內(nèi)存泄露的根本原因
由上述ThreadLocalMap的結(jié)構(gòu)圖以及ThreadLocalMap的源碼中,我們知道ThreadLocalMap中包含一個Entry數(shù)組,而Entry數(shù)組中的每一個元素就是Entry對象,Entry對象中存儲的Key就是ThreadLocal對象,而value就是要存儲的數(shù)據(jù)。其中,Entry對象中的Key屬于弱引用。
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;
}
}
}
而對于弱引用WeakReference,在引用的對象使用完畢之后,即使內(nèi)存足夠,GC也會對其進行回收。
關(guān)于弱引用的知識點,請參考:
圖片
當Entry對象中的Key被GC自動回收后,對應的ThreadLocal被GC回收掉了,變成了null,但是ThreadLocal對應的value值依然被Entry引用,不能被GC自動回收。這樣就造成了內(nèi)存泄漏的風險。
圖片
在線程池環(huán)境下使用ThreadLocal存儲數(shù)據(jù)時,內(nèi)存泄露的風險主要源自于線程生命周期管理及ThreadLocalMap內(nèi)部結(jié)構(gòu)的設(shè)計。由于線程池中的核心線程在完成任務后會復用,每個線程都會維持對各自關(guān)聯(lián)的ThreadLocalMap對象的強引用,這確保了只要線程持續(xù)存在,其對應的ThreadLocalMap就無法被垃圾收集器(GC)自動回收。
進一步分析,ThreadLocalMap內(nèi)部采用一個Entry數(shù)組來保存鍵值對,其中每個條目的Key是當前線程中對應ThreadLocal實例的弱引用,這意味著當外部不再持有該ThreadLocal實例的強引用時,Key部分能夠被GC正常回收。然而,關(guān)鍵在于Entry的Value部分,它直接或間接地持有著強引用的對象,即使Key因為弱引用特性被回收,但Value所引用的數(shù)據(jù)卻不會隨之釋放,除非明確移除或者整個ThreadLocalMap隨著線程結(jié)束而失效。
所以,在線程池中,如果未正確清理不再使用的ThreadLocal變量,其所持有的強引用數(shù)據(jù)將在多個任務執(zhí)行過程中逐漸積累并駐留在線程的ThreadLocalMap中,從而導致潛在的內(nèi)存泄露風險。
ThreadLocal如何避免內(nèi)存泄漏
經(jīng)過上述ThreadLocal原理以及發(fā)生內(nèi)存泄漏的分析,我們知道防止內(nèi)存泄漏,我們一定要在完成線程內(nèi)的任務后,調(diào)用ThreadLocal的remove()方法來清除當前線程中ThreadLocal所對應的值。其remove方法源碼如下:
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}
在remove()方法中,首先根據(jù)當前線程獲取ThreadLocalMap類型的對象,如果不為空,則直接調(diào)用該對象的有參remove()方法移除value的值。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;
}
}
}
由上述ThreadLocalMap中的set()方法知道ThreadLocal中Entry下標是通過計算ThreadLocal的hashCode獲得了,而remove()方法要找到需要移除value所在Entry數(shù)組中的下標時,也時通過當前ThreadLocal對象的hashCode獲的,然后找到它的下標之后,調(diào)用expungeStaleEntry將其value也置為null。我們繼續(xù)看一下expungeStaleEntry方法的源碼:
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;
}
在expungeStaleEntry()方法中,會將ThreadLocal為null對應的value設(shè)置為null,同時會把對應的Entry對象也設(shè)置為null,并且會將所有ThreadLocal對應的value為null的Entry對象設(shè)置為null,這樣就去除了強引用,便于后續(xù)的GC進行自動垃圾回收,也就避免了內(nèi)存泄露的問題。即調(diào)用完remove方法之后,ThreadLocalMap的結(jié)構(gòu)圖如下:
圖片
在ThreadLocal中,不僅僅是remove()方法會調(diào)用expungeStaleEntry()方法,在set()方法和get()方法中也可能會調(diào)用expungeStaleEntry()方法來清理數(shù)據(jù)。這種設(shè)計確保了即使沒有顯式調(diào)用remove()方法,系統(tǒng)也會在必要時自動清理不再使用的ThreadLocal變量占用的內(nèi)存資源。
需要我們特別注意的是,盡管ThreadLocal提供了remove這種機制來防止內(nèi)存泄漏,但它并不會自動執(zhí)行相關(guān)的清理操作。所以為了確保資源有效釋放并避免潛在的內(nèi)存泄露問題,我們應當在完成對ThreadLocal對象中數(shù)據(jù)的使用后,及時調(diào)用其remove()方法。我們最好(也是必須)是在try-finally代碼塊結(jié)構(gòu)中,在finally塊中明確地執(zhí)行remove()方法,這樣即使在處理過程中拋出異常,也能確保ThreadLocal關(guān)聯(lián)的數(shù)據(jù)被清除,從而有利于GC回收不再使用的內(nèi)存空間,避免內(nèi)存泄漏。
總結(jié)
本文探討了ThreadLocal的工作原理以及其內(nèi)存泄漏問題及解決策略。ThreadLocal通過為每個線程提供獨立的變量副本,實現(xiàn)多線程環(huán)境下的數(shù)據(jù)隔離。其內(nèi)部通過ThreadLocalMap與當前線程綁定,利用弱引用管理鍵值對。但是,如果未及時清理不再使用的ThreadLocal變量,可能導致內(nèi)存泄漏,尤其是在線程池場景下。解決辦法包括在完成任務后調(diào)用remove方法移除無用數(shù)據(jù)。正確理解和使用ThreadLocal能夠有效提升并發(fā)編程效率,但務必關(guān)注潛在的內(nèi)存泄漏風險。