阿里二面:談談ThreadLocal的記憶體洩漏問題?問麻了。。。。

码农Academy發表於2024-03-24

引言

ThreadLocal在Java多執行緒程式設計中扮演著重要的角色,它提供了一種執行緒區域性儲存機制,允許每個執行緒擁有獨立的變數副本,從而有效地避免了執行緒間的資料共享衝突。ThreadLocal的主要用途在於,當需要為每個執行緒維護一個獨立的上下文變數時,比如每個執行緒的事務ID、使用者登入資訊、資料庫連線等,可以減少對同步機制如synchronized關鍵字或Lock類的依賴,提高系統的執行效率和簡化程式碼邏輯。

但是我們在使用ThreadLocal時,經常因為使用不當導致記憶體洩漏。此時就需要我們去探究一下ThreadLocal在哪些場景下會出現記憶體洩露?哪些場景下不會出現記憶體洩露?出現記憶體洩露的根本原因又是什麼呢?如何避免記憶體洩露?

ThreadLocal原理

ThreadLocal的實現基於每個執行緒內部維護的一個ThreadLocalMap

public class Thread implements Runnable {
	 /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocalMapThreadLocal類的一個靜態內部類,ThreadLocal本身不能儲存資料,它在作用上更像一個工具類,ThreadLocal類提供了set(T value)get()等方法來操作ThreadLocalMap儲存資料。

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內部維護了一個Entry資料,用來儲存資料,Entry繼承了WeakReference,所以Entry的key是一個弱引用,可以被GC回收。Entry陣列中的每一個元素都是一個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;
            }
        }
}

關於弱引用的知識點,請參考:美團一面:說一說Java中的四種引用型別?

Entry陣列中Entry物件的下標位置是透過ThreadLocalthreadLocalHashCode計算出來的。

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陣列中獲取對應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物件。ThreadThreadLocalThreadLocalMap以及Entry陣列的關係如下圖:

image.png

ThreadLocal在哪些場景下不會出現記憶體洩露?

當一個物件失去所有強引用,或者它僅被弱引用、軟引用、虛引用關聯時,垃圾收集器(GC)通常都能識別並回收這些物件,從而避免記憶體洩漏的發生。當我們在手動建立執行緒時,若將變數儲存到ThreadLocal中,那麼在Thread執行緒正常執行的過程中,它會維持對內部ThreadLocalMap例項的引用。只要該Thread執行緒持續執行任務,這種引用關係將持續存在,確保ThreadLocalMap例項及其中儲存的變數不會因無引用而被GC回收。

image.png

當執行緒執行完任務並正常退出後,執行緒與內部ThreadLocalMap例項之間的強引用關係隨之斷開,這意味著執行緒不再持有ThreadLocalMap的引用。在這種情況下,失去強引用的ThreadLocalMap物件將符合垃圾收集器(GC)的回收條件,進而被自動回收。與此同時,鑑於ThreadLocalMap內部的鍵(ThreadLocal物件)是弱引用,一旦ThreadLocalMap被回收,若此時沒有其他強引用指向這些ThreadLocal物件,它們也將被GC一併回收。因此,線上程結束其生命週期後,與之相關的ThreadLocalMap及其包含的ThreadLocal物件理論上都能夠被正確清理,避免了記憶體洩漏問題。

實際應用中還需關注ThreadLocalMap中儲存的值(非鍵)是否為強引用型別,因為即便鍵(ThreadLocal物件)被回收,如果值是強引用且沒有其他途徑釋放,仍可能導致記憶體洩漏。

ThreadLocal在哪些場景下會出現記憶體洩露?

在實際專案開發中,如果為每個任務都手動建立執行緒,這是一件很耗費資源的方式,並且在阿里巴巴的開發規範中也提到,不推薦使用手動建立執行緒,推薦使用執行緒池來執行相對應的任務。那麼當我們使用執行緒池時,執行緒池中的執行緒跟ThrealLocalMap的引用關係如下:

image.png

在使用執行緒池處理任務時,每一個執行緒都會關聯一個獨立的ThreadLocalMap物件,用於儲存執行緒本地變數。由於執行緒池中的核心執行緒在完成任務後不會被銷燬,而是保持活動狀態等待接收新的任務,這意味著核心執行緒與其內部持有的ThreadLocalMap物件之間始終保持著強引用關係。因此,只要核心執行緒存活,其所對應的ThreadLocal物件和ThreadLocalMap不會被垃圾收集器(GC)自動回收,此時就會存在記憶體洩露的風險。

關於Java中的執行緒池引數以及原理,請參考:Java執行緒池最全講解

出現記憶體洩露的根本原因

由上述ThreadLocalMap的結構圖以及ThreadLocalMap的原始碼中,我們知道ThreadLocalMap中包含一個Entry陣列,而Entry陣列中的每一個元素就是Entry物件,Entry物件中儲存的Key就是ThreadLocal物件,而value就是要儲存的資料。其中,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,在引用的物件使用完畢之後,即使記憶體足夠,GC也會對其進行回收。

關於弱引用的知識點,請參考:美團一面:說一說Java中的四種引用型別?

image.png

Entry物件中的Key被GC自動回收後,對應的ThreadLocal被GC回收掉了,變成了null,但是ThreadLocal對應的value值依然被Entry引用,不能被GC自動回收。這樣就造成了記憶體洩漏的風險。
image.png

線上程池環境下使用ThreadLocal儲存資料時,記憶體洩露的風險主要源自於執行緒生命週期管理及ThreadLocalMap內部結構的設計。由於執行緒池中的核心執行緒在完成任務後會複用,每個執行緒都會維持對各自關聯的ThreadLocalMap物件的強引用,這確保了只要執行緒持續存在,其對應的ThreadLocalMap就無法被垃圾收集器(GC)自動回收。

進一步分析,ThreadLocalMap內部採用一個Entry陣列來儲存鍵值對,其中每個條目的Key是當前執行緒中對應ThreadLocal例項的弱引用,這意味著當外部不再持有該ThreadLocal例項的強引用時,Key部分能夠被GC正常回收。然而,關鍵在於Entry的Value部分,它直接或間接地持有著強引用的物件,即使Key因為弱引用特性被回收,但Value所引用的資料卻不會隨之釋放,除非明確移除或者整個ThreadLocalMap隨著執行緒結束而失效。

所以,線上程池中,如果未正確清理不再使用的ThreadLocal變數,其所持有的強引用資料將在多個任務執行過程中逐漸積累並駐留線上程的ThreadLocalMap中,從而導致潛在的記憶體洩露風險。

ThreadLocal如何避免記憶體洩漏

經過上述ThreadLocal原理以及發生記憶體洩漏的分析,我們知道防止記憶體洩漏,我們一定要在完成執行緒內的任務後,呼叫ThreadLocalremove()方法來清除當前執行緒中ThreadLocal所對應的值。其remove方法原始碼如下:

 public void remove() {
	 ThreadLocalMap m = getMap(Thread.currentThread());
	 if (m != null) {
		 m.remove(this);
	 }
 }

remove()方法中,首先根據當前執行緒獲取ThreadLocalMap型別的物件,如果不為空,則直接呼叫該物件的有參remove()方法移除value的值。ThreadLocalMapremove方法原始碼如下:

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()方法知道ThreadLocalEntry下標是透過計算ThreadLocalhashCode獲得了,而remove()方法要找到需要移除value所在Entry陣列中的下標時,也時透過當前ThreadLocal物件的hashCode獲的,然後找到它的下標之後,呼叫expungeStaleEntry將其value也置為null。我們繼續看一下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設定為null,同時會把對應的Entry物件也設定為null,並且會將所有ThreadLocal對應的value為null的Entry物件設定為null,這樣就去除了強引用,便於後續的GC進行自動垃圾回收,也就避免了記憶體洩露的問題。即呼叫完remove方法之後,ThreadLocalMap的結構圖如下:

image.png

ThreadLocal中,不僅僅是remove()方法會呼叫expungeStaleEntry()方法,在set()方法和get()方法中也可能會呼叫expungeStaleEntry()方法來清理資料。這種設計確保了即使沒有顯式呼叫remove()方法,系統也會在必要時自動清理不再使用的ThreadLocal變數佔用的記憶體資源。

需要我們特別注意的是,儘管ThreadLocal提供了remove這種機制來防止記憶體洩漏,但它並不會自動執行相關的清理操作。所以為了確保資源有效釋放並避免潛在的記憶體洩露問題,我們應當在完成對ThreadLocal物件中資料的使用後,及時呼叫其remove()方法。我們最好(也是必須)是在try-finally程式碼塊結構中,在finally塊中明確地執行remove()方法,這樣即使在處理過程中丟擲異常,也能確保ThreadLocal關聯的資料被清除,從而有利於GC回收不再使用的記憶體空間,避免記憶體洩漏。

總結

本文探討了ThreadLocal的工作原理以及其記憶體洩漏問題及解決策略。ThreadLocal透過為每個執行緒提供獨立的變數副本,實現多執行緒環境下的資料隔離。其內部透過ThreadLocalMap與當前執行緒繫結,利用弱引用管理鍵值對。但是,如果未及時清理不再使用的ThreadLocal變數,可能導致記憶體洩漏,尤其是線上程池場景下。解決辦法包括在完成任務後呼叫remove方法移除無用資料。正確理解和使用ThreadLocal能夠有效提升併發程式設計效率,但務必關注潛在的記憶體洩漏風險。

本文已收錄於我的個人部落格:碼農Academy的部落格,專注分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中介軟體、架構設計、面試題、程式設計師攻略等

相關文章