內存泄露的原因找到了,罪魁禍首居然是Java TheadLocal
本文轉載自微信公眾號「愛笑的架構師」,作者雷架 。轉載本文請聯系愛笑的架構師公眾號。
ThreadLocal使用不規范,師傅兩行淚
組內來了一個實習生,看這小伙子春光滿面、精神抖擻、頭發微少,我心頭一喜:絕對是個潛力股。于是我找經理申請親自來帶他,為了幫助小伙子快速成長,我給他分了一個需求,這不需求剛上線幾天就出網上問題了😭后臺監控服務發現內存一直在緩慢上升,初步懷疑是內存泄露。
把實習生的PR都找出來仔細review,果然發現問題了。由于公司內部代碼是保密的,這里簡單寫一個demo還原場景(忽略代碼風格問題)。
- public class ThreadPoolDemo {
- private static final ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>());
- public static void main(String[] args) throws InterruptedException {
- for (int i = 0; i < 100; ++i) {
- poolExecutor.execute(new Runnable() {
- @Override
- public void run() {
- ThreadLocal<BigObject> threadLocal = new ThreadLocal<>();
- threadLocal.set(new BigObject());
- // 其他業務代碼
- }
- });
- Thread.sleep(1000);
- }
- }
- static class BigObject {
- // 100M
- private byte[] bytes = new byte[100 * 1024 * 1024];
- }
- }
代碼分析:
- 創建一個核心線程數和最大線程數都為10的線程池,保證線程池里一直會有10個線程在運行。
- 使用for循環向線程池中提交了100個任務。
- 定義了一個ThreadLocal類型的變量,Value類型是大對象。
- 每個任務會向threadLocal變量里塞一個大對象,然后執行其他業務邏輯。
- 由于沒有調用線程池的shutdown方法,線程池里的線程還是會在運行。
乍一看這代碼好像沒有什么問題,那為什么會導致服務GC后內存還高居不下呢?
代碼中給threadLocal賦值了一個大的對象,但是執行完業務邏輯后沒有調用remove方法,最后導致線程池中10個線程的threadLocals變量中包含的大對象沒有被釋放掉,出現了內存泄露。
大家說說這樣的實習生還能留不?
ThreadLocal的value值存在哪里?
實習生說他以為線程任務結束了threadLocal賦值的對象會被JVM垃圾回收,很疑惑為什么會出現內存泄露。作為師傅我肯定要給他把原理講透呀。
ThreadLocal類提供set/get方法存儲和獲取value值,但實際上ThreadLocal類并不存儲value值,真正存儲是靠ThreadLocalMap這個類,ThreadLocalMap是ThreadLocal的一個靜態內部類,它的key是ThreadLocal實例對象,value是任意Object對象。
ThreadLocalMap類的定義
- static class ThreadLocalMap {
- // 定義一個table數組,存儲多個threadLocal對象及其value值
- private Entry[] table;
- ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
- table = new Entry[INITIAL_CAPACITY];
- int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
- table[i] = new Entry(firstKey, firstValue);
- size = 1;
- setThreshold(INITIAL_CAPACITY);
- }
- // 定義一個Entry類,key是一個弱引用的ThreadLocal對象
- // value是任意對象
- static class Entry extends WeakReference<ThreadLocal<?>> {
- /** The value associated with this ThreadLocal. */
- Object value;
- Entry(ThreadLocal<?> k, Object v) {
- super(k);
- value = v;
- }
- }
- // 省略其他
- }
進一步分析ThreadLocal類的代碼,看set和get方法如何與ThreadLocalMap靜態內部類關聯上。
ThreadLocal類set方法
- 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);
- }
- ThreadLocalMap getMap(Thread t) {
- return t.threadLocals;
- }
- void createMap(Thread t, T firstValue) {
- t.threadLocals = new ThreadLocalMap(this, firstValue);
- }
- // 省略其他方法
- }
set的邏輯比較簡單,就是獲取當前線程的ThreadLocalMap,然后往map里添加KV,K是當前ThreadLocal實例,V是我們傳入的value。這里需要注意一下,map的獲取是需要從Thread類對象里面取,看一下Thread類的定義。
- public class Thread implements Runnable {
- ThreadLocal.ThreadLocalMap threadLocals = null;
- //省略其他
- }
Thread類維護了一個ThreadLocalMap的變量引用。
ThreadLocal類get方法
get獲取當前線程的對應的私有變量,是之前set或者通過initialValue的值,代碼如下:
- class ThreadLocal<T> {
- public T get() {
- Thread t = Thread.currentThread();
- ThreadLocalMap map = getMap(t);
- if (map != null) {
- ThreadLocalMap.Entry e = map.getEntry(this);
- if (e != null)
- return (T)e.value;
- }
- return setInitialValue();
- }
- }
代碼邏輯分析:
- 獲取當前線程的ThreadLocalMap實例;
- 如果不為空,以當前ThreadLocal實例為key獲取value;
- 如果ThreadLocalMap為空或者根據當前ThreadLocal實例獲取的value為空,則執行setInitialValue();
ThreadLocal相關類的關系總結
看了上面的分析是不是對Thread,ThreadLocal,ThreadLocalMap,Entry這幾個類之間的關系有點暈了,沒關系我專門畫了一個UML類圖來總結(忽略UML標準語法)。
ThreadLocal相關類的關系
- 每個線程是一個Thread實例,其內部維護一個threadLocals的實例成員,其類型是ThreadLocal.ThreadLocalMap。
- 通過實例化ThreadLocal實例,我們可以對當前運行的線程設置一些線程私有的變量,通過調用ThreadLocal的set和get方法存取。
- ThreadLocal本身并不是一個容器,我們存取的value實際上存儲在ThreadLocalMap中,ThreadLocal只是作為TheadLocalMap的key。
- 每個線程實例都對應一個TheadLocalMap實例,我們可以在同一個線程里實例化很多個ThreadLocal來存儲很多種類型的值,這些ThreadLocal實例分別作為key,對應各自的value,最終存儲在Entry table數組中。
- 當調用ThreadLocal的set/get進行賦值/取值操作時,首先獲取當前線程的ThreadLocalMap實例,然后就像操作一個普通的map一樣,進行put和get。
ThreadLocal內存模型原理
經過上面的分析我們對ThreadLocal相關的類設計已經非常清楚了,下面通過一張圖更加深入理解一下ThreadLocal的內存存儲。
ThreadLocal內存模型
圖中左邊是棧,右邊是堆。線程的一些局部變量和引用使用的內存屬于Stack(棧)區,而普通的對象是存儲在Heap(堆)區。
- 線程運行時,我們定義的TheadLocal對象被初始化,存儲在Heap,同時線程運行的棧區保存了指向該實例的引用,也就是圖中的ThreadLocalRef。
- 當ThreadLocal的set/get被調用時,虛擬機會根據當前線程的引用也就是CurrentThreadRef找到其對應在堆區的實例,然后查看其對用的TheadLocalMap實例是否被創建,如果沒有,則創建并初始化。
- Map實例化之后,也就拿到了該ThreadLocalMap的句柄,那么就可以將當前ThreadLocal對象作為key,進行存取操作。
- 圖中的虛線,表示key對應ThreadLocal實例的引用是個弱引用。
強引用弱引用的概念
ThreadLocalMap的key是一個弱引用類型,源代碼如下:
- static class ThreadLocalMap {
- // 定義一個Entry類,key是一個弱引用的ThreadLocal對象
- // value是任意對象
- static class Entry extends WeakReference<ThreadLocal<?>> {
- /** The value associated with this ThreadLocal. */
- Object value;
- Entry(ThreadLocal<?> k, Object v) {
- super(k);
- value = v;
- }
- }
- // 省略其他
- }
下面解釋一下常見的幾種引用概念。
強引用
一直活著:類似“Object obj=new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象實例。
弱引用
回收就會死亡:被弱引用關聯的對象實例只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象實例。在JDK 1.2之后,提供了WeakReference類來實現弱引用。
軟引用
有一次活的機會:軟引用關聯著的對象,在系統將要發生內存溢出異常之前,將會把這些對象實例列進回收范圍之中進行第二次回收。如果這次回收還沒有足夠的內存,才會拋出內存溢出異常。在JDK 1.2之后,提供了SoftReference類來實現軟引用。
虛引用
也稱為幽靈引用或者幻影引用,它是最弱的一種引用關系。一個對象實例是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的就是能在這個對象實例被收集器回收時收到一個系統通知。在JDK 1.2之后,提供了PhantomReference類來實現虛引用。
內存泄露是不是弱引用的鍋?
從表面上看內存泄漏的根源在于使用了弱引用,但是另一個問題也同樣值得思考:為什么ThreadLocalMap使用弱引用而不是強引用?
翻看官網文檔的說法:
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.
為了處理非常大和長期的用途,哈希表條目使用weakreference作為鍵。
分兩種情況討論:
(1)key 使用強引用
引用ThreadLocal的對象被回收了,但是ThreadLocalMap還持有ThreadLocal的強引用,如果沒有手動刪除,ThreadLocal不會被回收,導致Entry內存泄漏。
(2)key 使用弱引
引用ThreadLocal的對象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使沒有手動刪除,ThreadLocal也會被回收。value在下一次ThreadLocalMap調用set、get、remove的時候會被清除。
比較兩種情況,我們可以發現:由于ThreadLocalMap的生命周期跟Thread一樣長,如果都沒有手動刪除對應key,都會導致內存泄漏,但是使用弱引用可以多一層保障:弱引用ThreadLocal被清理后key為null,對應的value在下一次ThreadLocalMap調用set、get、remove的時候可能會被清除。
因此,ThreadLocal內存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一樣長,如果沒有手動刪除對應key就會導致內存泄漏,而不是因為弱引用。
ThreadLocal最佳實踐
通過前面幾小節我們分析了ThreadLocal的類設計以及內存模型,同時也重點分析了發生內存泄露的條件和特定場景。最后結合項目中的經驗給出建議使用ThreadLocal的場景:
- 當需要存儲線程私有變量的時候。
- 當需要實現線程安全的變量時。
- 當需要減少線程資源競爭的時候。
綜合上面的分析,我們可以理解ThreadLocal內存泄漏的前因后果,那么怎么避免內存泄漏呢?
答案就是:每次使用完ThreadLocal,建議調用它的remove()方法,清除數據。
另外需要強調的是并不是所有使用ThreadLocal的地方,都要在最后remove(),因為他們的生命周期可能是需要和項目的生存周期一樣長的,所以要進行恰當的選擇,以免出現業務邏輯錯誤!