Java的記憶體管理來說,就是ThreadLocal存在無法被GC回收的記憶體。這些無法被回收的記憶體,如果隨著時間的推移,從而導致超出記憶體容量「記憶體溢出」,最終導致程式崩潰「OutOfMemoryError」。所以為了避免我們的Java程式崩潰,我們必須要避免出現記憶體泄漏的問題。 ...
前言
在討論ThreadLocal存在記憶體泄漏問題之前,需要先瞭解下麵幾個知識點:
什麼是記憶體泄漏? 什麼是ThreadLocal? 為什麼需要ThreadLocal? 數據一致性問題 如何解決數據一致性問題?
當我們瞭解了上面的知識點以後,會帶大家一起去瞭解真相。包括下麵幾個知識點:
為什麼會產生記憶體泄漏? 實戰復現問題 如何解決記憶體泄漏? 為什麼是弱引用?
只有瞭解上面的知識點,才能更好的理解以及如何解決ThreadLocal記憶體泄漏問題。下麵我們就開始帶大家一步一步的去瞭解。
什麼是記憶體泄漏?
在討論ThreadLocal存在記憶體泄漏問題之前,我覺得有必要先瞭解一下什麼是記憶體泄漏?我們為什麼要解決記憶體泄漏的問題?這裡引用一段百度百科對記憶體泄漏的解釋。
記憶體泄漏(Memory Leak)是指程式中已動態分配的堆記憶體由於某種原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式運行速度減慢甚至系統崩潰等嚴重後果。
從Java的記憶體管理來說,就是ThreadLocal存在無法被GC回收的記憶體。這些無法被回收的記憶體,如果隨著時間的推移,從而導致超出記憶體容量「記憶體溢出」,最終導致程式崩潰「OutOfMemoryError」。所以為了避免我們的Java程式崩潰,我們必須要避免出現記憶體泄漏的問題。
ThreadLocal
前面講了什麼是記憶體泄漏,為什麼要解決記憶體泄漏的問題。現在我們來講講什麼是ThreadLocal?
❝簡單來說,ThreadLocal是一個本地線程副本變數工具類。ThreadLocal讓每個線程有自己”獨立“的變數,線程之間互不影響。ThreadLocal為每個線程都創建一個副本,每個線程可以訪問自己內部的副本變數。
❞
為什麼需要ThreadLocal?
現在我們知道了什麼是ThreadLocal,接下來我們講講為什麼需要ThreadLocal在講為什麼需要ThreadLocal之前,我們需要瞭解一個問題。那就是數據一致性問題。因為ThreadLocal就是解決數據一致性問題的一種方案,只要當我們瞭解什麼是數據一致性問題後,自然就知道為什麼需要ThreadLocal了。
什麼是一致性問題?
多線程充分利用了多核CPU的能力,為我們程式提供了很高的性能。但是有時候,我們需要多個線程互相協作,這裡可能就會涉及到數據一致性的問題。 數據一致性問題指的是:發生在多個主體對同一份數據無法達成共識。
如何解決一致性問題?
「排隊」:如果兩個人對一個問題的看法不一致,那就排成一隊,一個人一個人去修改它,這樣後面一個人總是能夠得到前面一個人修改後的值,數據也就總是一致的了。Java中的互斥鎖等概念,就是利用了排隊的思想。排隊雖然能夠很好的確保數據一致性,但性能非常低。 「投票」:,投票的話,多個人可以同時去做一件決策,或者同時去修改數據,但最終誰修改成功,是用投票來決定的。這個方式很高效,但它也會產生很多問題,比如網路中斷、欺詐等等。想要通過投票達到一致性非常複雜,往往需要嚴格的數學理論來證明,還需要中間有一些“信使”不斷來來回回傳遞消息,這中間也會有一些性能的開銷。我們在分散式系統中常見的Paxos和Raft演算法,就是使用投票來解決一致性問題的。 「避免」:既然保證數據一致性很難,那我能不能通 過一些手段,去避免多個線程之間產生一致性問題呢?我們熟悉的Git就是這個實現,大家在本地分散式修改同一個文件,通過版本控制和解決衝突去解決這個問題。而ThreadLocal也是使用的這種方式。
為什麼會產生記憶體泄漏?
上面講清楚了ThreadLocal的基本含義,接下來我們一起看看ThreadLocal常用函數的源碼,只有瞭解ThreadLocal的具體實現才能更好的幫助我們理解它為什麼會產生記憶體泄漏的問題。
set()方法
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
從上面的源碼可以看出,當我們調用ThreadLocal對象的set()方法時,其實就是將ThreadLocal對象存入當前線程的ThreadLocalMap集合中,map集合的key為當前ThreadLocal對象,value為set()方法的參數。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table;
}
這是ThreadLocalMap的源碼(由於篇幅原因這裡我只取了重要的代碼),可以看到ThreadLocalMap中使用一個Entry對象來存儲數據,而Entry的key則是一個WeakReference弱引用對象。這裡我帶大家再複習一下Java對象的幾種引用。
「強引用」:java中的引用預設就是強引用,任何一個對象的賦值操作就產生了對這個對象的強引用。如: Object o = new Object()
,只要強引用關係還在,對象就永遠不會被回收。「軟引用」:java.lang.ref.SoftReference,JVM會在記憶體溢出前對其進行回收。 「弱引用」:java.lang.ref.WeakReference,不管記憶體是否夠用,下次GC一定回收。 「虛引用」:java.lang.ref.PhantomReference,也稱“幽靈引用”、“幻影引用”。虛作用是跟蹤垃圾回收器收集對象的活動,在GC的過程中,如果發現有PhantomReference,GC則會將引用放到ReferenceQueue中,由程式員自己處理,當程式員調用ReferenceQueue.pull()方法,將引用出ReferenceQueue移除之後,Reference對象會變成Inactive狀態,意味著被引用的對象可以被回收了,虛引用的唯一的目的是對象被回收時會收到一個系統通知。
實戰復現問題
上面我們已經瞭解了ThreadLocal存儲數據的set()方法,現在我們來看一段代碼,通過代碼來分析ThreadLocal為什麼會產生記憶體泄漏。
public class Test {
@Override
protected void finalize() throws Throwable {
System.err.println("對象被回收了");
}
}
@Test
void test() throws InterruptedException {
ThreadLocal<Test> local = new ThreadLocal<>();
local.set(new Test());
local = null;
System.gc();
Thread.sleep(1000000);
}
我們創建一個測試類,並重寫finalize()方法,當對象被回收時會列印消息在控制台方便我們測試觀察對象是否被回收。
從代碼可以看到,我們創建了一個ThreadLocal對象,然後往對象裡面設置了一個new Test對象,然後我們將變數local賦值為null,最後手動觸發一下gc。大家可以猜猜,控制台會列印出對象被回收了的消息嗎?建議大家動手試試,增加一下理解。
在告訴大家答案之前我們先來分析一下上面的一個引用關係:
示例中local = null這行代碼會將強引用2斷掉,這樣new ThreadLocal對象就只有一個弱引用4了,根據弱引用的特點在下次GC的時候new ThreadLocal對象就會被回收。那麼new Test對象就成了一個永遠無法訪問的對象,但是又存在一條強引用鏈thread→Thread對象→ThreadLocalMap→Entry→new Test,如果這條引用鏈一直存在就會導致new Test對象永遠不會被回收。因為現在大多時候都是使用線程池,而線程池會復用線程,就很容易導致引用鏈一直存在,從而導致new Test對象無法被回收,一旦這樣的情況隨著時間的推移而大量存在就容易引發記憶體泄漏。
如何解決記憶體泄漏?
我們已經知道了造成記憶體泄漏的原因,那麼要解決問題就很簡單了。
上面造成記憶體泄漏的第一點就是Entry的key也就是new ThreadLocal對象的強引用被斷開了,我們就可以想辦法讓這條強引用無法斷開,比如將ThreadLocal對象設置為private static 保證任何時候都能訪問new ThreadLocal對象同時避免其他地方將其賦值為null。
還有一種辦法就是想辦法將new Test對象回收,從根本上解決問題。下麵我們一起看看ThreadLocal為我們提供的方法。
remove()方法
public void remove() {
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);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// 省略代碼...感興趣可以去看看源碼
return i;
}
該方法的邏輯是,將entry里value的強引用3和key的弱引用4置為null。這樣new Test對象和Entry對象就都能被GC回收。
因此,只要調用了expungeStaleEntry()
就能將無用 Entry 回收清除掉。
但是該方法為private故無法直接調用,但是ThreadLocalMap中remove()
方法直接調用了該方法,因此只要當我們使完ThreadLocal對象後調用一下remove()方法就能避免出現記憶體泄漏了。
綜上所述:針對ThreadLocal 記憶體泄露的原因,我們可以從兩方面去考慮:
刪除無用 Entry 對象。即 用完ThreadLocal後手動調用remove()方法。 可以讓ThreadLocal對象的強引用一直存在,保證任何時候都可以訪問到 Entry的 value值。即 將ThreadLocal 變數定義為 private static。
為什麼是弱引用?
不知道大家有沒有想過一個問題,既然是弱引用導致的記憶體泄漏,那麼為什麼JDK還要使用弱引用。難道是bug嗎?大家再看一下下麵這段代碼。
@Test
void test() throws InterruptedException {
ThreadLocal<Test> local = new ThreadLocal<>();
local.set(new Test());
local = null;
System.gc();
Thread.sleep(1000000);
}
我們假設Entrykey使用強引用,那麼引用圖就是如下
當代碼local = null斷掉強引用2的時候,new ThreadLocal對象就是只存在一條強引用4,那麼由於強引用的關係GC無法回收new ThreadLocal對象。所以就造成了Entry的key和value都無法訪問無法回收了,記憶體泄漏就加倍了。
同理也不能將Entry的value設置為弱引用,因為Entry對象的value即new Test對象只有一個引用,如果使用弱引用,在GC的時候會導致new Test對象被回收,導致數據丟失。
將Entry的key設置為弱引用還有一個好處就是,當強引用2斷掉且弱引用4被GC回收後,ThreadLocal會通過key.get() == null識別出無用Entry從而將Entry的key和value置為null以便被GC回收。具體代碼如下
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;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
所以,Entry key使用弱引用並不是一個bug,而是ThreadLocal的開發人員在儘力的幫助我們避免造成記憶體泄漏。
彩蛋
@Test
void test2() throws InterruptedException {
ThreadLocal<Test> local = new ThreadLocal<>();
local.set(new Test());
local = null;
System.gc();
for (int i = 0; i < 9; i++) {
new ThreadLocal<>().get();
}
System.gc();
Thread.sleep(1000000);
}
感興趣的同學可以嘗試運行上面的代碼,你會發現驚喜的!至於結果大家自己動手去獲取吧!。下麵我們再來看一個ThreadLocal常用的方法。
get()方法
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();
}
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);
}
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;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
從上面的代碼你會驚奇的發現,get方法也會調用expungeStaleEntry()方法,當然不是每次get都會調用。邏輯大家可以去看源碼慢慢理。這裡再提一下,可以順便看看完整的set方法,你還會發現秘密。