ThreadLocal真會記憶體洩漏?

LvLaoTou發表於2024-04-11

前言

在討論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。大家可以猜猜,控制檯會列印出物件被回收了的訊息嗎?建議大家動手試試,增加一下理解。

在告訴大家答案之前我們先來分析一下上面的一個引用關係:

Untitled

示例中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 記憶體洩露的原因,我們可以從兩方面去考慮:

  1. 刪除無用 Entry 物件。即 用完ThreadLocal後手動呼叫remove()方法。
  2. 可以讓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使用強引用,那麼引用圖就是如下

Untitled

當程式碼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方法,你還會發現秘密。

本文使用 markdown.com.cn 排版

相關文章