ThreadLocal的記憶體洩漏問題
在最近一個專案中,在專案釋出之後,發現系統中有記憶體洩漏問題。表象是堆記憶體隨著系統的執行時間緩慢增長,一直沒有辦法通過gc來回收,最終於導致堆記憶體耗盡,記憶體溢位。開始是懷疑ThreadLocal的問題,因為在專案中,大量使用了執行緒的ThreadLocal儲存執行緒上下文資訊,在正常情況下,線上程開始的時候設定執行緒變數,線上程結束的時候,需要清除執行緒上下文資訊,如果執行緒變數沒有清除,會導致執行緒中儲存的物件無法釋放。
從這個正常的情況來看,假設沒有清除執行緒上下文變數,那麼線上程結束的時候(執行緒銷燬),執行緒上下文變數所佔用的記憶體會隨著執行緒的銷燬而被回收。至少從程式設計者角度來看,應該如此。實際情況下是怎麼樣,需要進行測試。
但是對於web型別的應用,為了避免產生大量的執行緒產生堆疊溢位(預設情況下一個執行緒會分配512K的棧空間),都會採用執行緒池的設計方案,對大量請求進行負載均衡。所以實際應用中,一般都會是執行緒池的設計,處理業務的執行緒數一般都在200以下,即使所有的執行緒變數都沒有清理,那麼理論上會出現執行緒保持的變數最大數是200,如果執行緒變數所指示的物件佔用比較少(小於10K),200個執行緒最多隻有2M(200*10K)的記憶體無法進行回收(因為執行緒池執行緒是複用的,每次使用之前,都會從新設定新的執行緒變數,那麼老的執行緒變數所指示的物件沒有被任何物件引用,會自動被垃圾回收,只有最後一次執行緒被使用的情況下,才無法進行回收)。
以上只是理論上的分析,那麼實際情況下如何了,我寫了一段程式碼進行實驗。
- 硬體配置:
處理器名稱: Intel Core i7 2.3 GHz 4核
記憶體: 16 GB
- 軟體配置
作業系統:OS X 10.8.2
java版本:”1.7.0_04-ea”
- JVM配置
-Xms128M -Xmx512M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -Xloggc:gc.log
測試程式碼:Test.java
import java.io.BufferedReader; import java.io.InputStreamReader;import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class Test { public static void main(String[] args) throws Exception { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); int testCase= Integer.parseInt(br.readLine()); br.close(); switch(testCase){ // 測試情況1. 無執行緒池,執行緒不休眠,並且清除thread_local 裡面的執行緒變數;測試結果:無記憶體溢位 case 1 :testWithThread(true, 0); break; // 測試情況2. 無執行緒池,執行緒不休眠,沒有清除thread_local 裡面的執行緒變數;測試結果:無記憶體溢位 case 2 :testWithThread(false, 0); break; // 測試情況3. 無執行緒池,執行緒休眠1000毫秒,清除thread_local裡面的執行緒的執行緒變數;測試結果:無記憶體溢位,但是新生代記憶體整體使用高 case 3 :testWithThread(false, 1000); break; // 測試情況4. 無執行緒池,執行緒永久休眠(設定最大值),清除thread_local裡面的執行緒的執行緒變數;測試結果:無記憶體溢位 case 4 :testWithThread(true, Integer.MAX_VALUE); break; // 測試情況5. 有執行緒池,執行緒池大小50,執行緒不休眠,並且清除thread_local 裡面的執行緒變數;測試結果:無記憶體溢位 case 5 :testWithThreadPool(50,true,0); break; // 測試情況6. 有執行緒池,執行緒池大小50,執行緒不休眠,沒有清除thread_local 裡面的執行緒變數;測試結果:無記憶體溢位 case 6 :testWithThreadPool(50,false,0); break; // 測試情況7. 有執行緒池,執行緒池大小50,執行緒無限休眠,並且清除thread_local 裡面的執行緒變數;測試結果:無記憶體溢位 case 7 :testWithThreadPool(50,true,Integer.MAX_VALUE); break; // 測試情況8. 有執行緒池,執行緒池大小1000,執行緒無限休眠,並且清除thread_local 裡面的執行緒變數;測試結果:無記憶體溢位 case 8 :testWithThreadPool(1000,true,Integer.MAX_VALUE); break; default :break; } } public static void testWithThread(boolean clearThreadLocal, long sleepTime) { while (true) { try { Thread.sleep(100); new Thread(new TestTask(clearThreadLocal, sleepTime)).start(); } catch (Exception e) { e.printStackTrace(); } } } public static void testWithThreadPool(int poolSize,boolean clearThreadLocal, long sleepTime) { ExecutorService service = Executors.newFixedThreadPool(poolSize); while (true) { try { Thread.sleep(100); service.execute(new TestTask(clearThreadLocal, sleepTime)); } catch (Exception e) { e.printStackTrace(); } } } public static final byte[] allocateMem() { // 這裡分配一個1M的物件 byte[] b = new byte[1024 * 1024]; return b; } static class TestTask implements Runnable { /** 是否清除上下文引數變數 */ private boolean clearThreadLocal; /** 執行緒休眠時間 */ private long sleepTime; public TestTask(boolean clearThreadLocal, long sleepTime) { this.clearThreadLocal = clearThreadLocal; this.sleepTime = sleepTime; } public void run() { try { ThreadLocalHolder.set(allocateMem()); try { // 大於0的時候才休眠,否則不休眠 if (sleepTime > 0) { Thread.sleep(sleepTime); } } catch (InterruptedException e) { } } finally { if (clearThreadLocal) { ThreadLocalHolder.clear(); } } } } }
ThreadLocalHolder.java
public class ThreadLocalHolder { public static final ThreadLocal<Object> threadLocal = new ThreadLocal<Object>(); public static final void set(byte [] b){ threadLocal.set(b); } public static final void clear(){ threadLocal.set(null); } }
- 測試結果分析:
無執行緒池的情況:測試用例1-4
下面是測試用例1 的垃圾回收日誌
下面是測試用例2 的垃圾回收日誌
對比分析測試用例1 和 測試用例2 的GC日誌,發現基本上都差不多,說明是否清楚執行緒上下文變數不影響垃圾回收,對於無執行緒池的情況下,不會造成記憶體洩露
對於測試用例3,由於業務執行緒sleep 一秒鐘,會導致業務系統中有產生大量的阻塞執行緒,理論上新生代記憶體會比較高,但是會保持到一定的範圍,不會緩慢增長,導致記憶體溢位,通過分析了測試用例3的gc日誌,發現符合理論上的分析,下面是測試用例3的垃圾回收日誌
通過上述日誌分析,發現老年代產生了一次垃圾回收,可能是開始大量執行緒休眠導致記憶體無法釋放,這一部分執行緒持有的執行緒變數會在重新喚醒之後執行結束被回收,新生代的記憶體記憶體一直維持在4112K,也就是4個執行緒持有的執行緒變數。
對於測試用例4,由於執行緒一直sleep,無法對執行緒變數進行釋放,導致了記憶體溢位。
有執行緒池的情況:測試用例5-8
對於測試用例5,開設了50個工作執行緒,每次使用執行緒完成之後,都會清除執行緒變數,垃圾回收日誌和測試用例1以及測試用例2一樣。
對於測試用例6,也開設了50個執行緒,但是使用完成之後,沒有清除執行緒上下文,理論上會有50M記憶體無法進行回收,通過垃圾回收日誌,符合我們的語氣,下面是測試用例6的垃圾回收日誌
通過日誌分析,發現老年代回收比較頻繁,主要是因為50個執行緒持有的50M空間一直無法徹底進行回收,而新生代空間不夠(我們設定的是128M記憶體,新生代大概36M左右)。所有整體記憶體的使用量肯定一直在50M之上。
對於測試用例7,由於工作執行緒最多50個,即使執行緒一直休眠,再短時間內也不會導致記憶體溢位,長時間的情況下會出現記憶體溢位,這主要是因為任務佇列空間沒有限制,和有沒有清除執行緒上下文變數沒有關係,如果我們使用的有限佇列,就不會出現這個問題。
對於測試用例8,由於工作執行緒有1000個,導致至少1000M的堆空間被使用,由於我們設定的最大堆是512M,導致結果溢位。系統的堆空間會從開始的128M逐步增長到512M,最後導致溢位,從gc日誌來看,也符合理論上的判斷。由於gc日誌比較大,就不在貼出來了。
所以從上面的測試情況來看,線上上下文變數是否導致記憶體洩露,是需要區分情況的,如果執行緒變數所佔的空間的比較小,小於10K,是不會出現記憶體洩露的,導致記憶體溢位的。如果執行緒變數所佔的空間比較大,大於1M的情況下,出現的記憶體洩露和記憶體溢位的情況比較大。以上只是jdk1.7版本情況下的分析,個人認為jdk1.6版本的情況和1.7應該差不多,不會有太大的差別。
———————–下面是對ThreadLocal的分析————————————-
對於ThreadLocal的概念,很多人都是比較模糊的,只知道是執行緒本地變數,而具體這個本地變數是什麼含義,有什麼作用,如何使用等很多java開發工程師都不知道如何進行使用。從JDK的對ThreadLocal的解釋來看
該類提供了執行緒區域性 (thread-local) 變數。這些變數不同於它們的普通對應物,因為訪問某個變數(通過其 get 或 set 方法)的每個執行緒都有自己的區域性變數, 它獨立於變數的初始化副本。ThreadLocal 例項通常是類中的 private static 欄位,它們希望將狀態與某一個執行緒(例如,使用者 ID 或事務 ID)相關聯。 |
ThreadLocal有一個ThreadLocalMap靜態內部類,你可以簡單理解為一個MAP,這個‘Map’為每個執行緒複製一個變數的‘拷貝’儲存其中。每一個內部執行緒都有一個ThreadLocalMap物件。
當執行緒呼叫ThreadLocal.set(T object)方法設定變數時,首先獲取當前執行緒引用,然後獲取執行緒內部的ThreadLocalMap物件,設定map的key值為threadLocal物件,value為引數中的object。
當執行緒呼叫ThreadLocal.get()方法獲取變數時,首先獲取當前執行緒引用,以threadLocal物件為key去獲取響應的ThreadLocalMap,如果此‘Map’不存在則初始化一個,否則返回其中的變數。
也就是說每個執行緒內部的 ThreadLocalMap物件中的key儲存的threadLocal物件的引用,從ThreadLocalMap的原始碼來看,對threadLocal的物件的引用是WeakReference,也就是弱引用。
下面一張圖描述這三者的整體關係
對於一個正常的Map來說,我們一般會呼叫Map.clear方法來清空map,這樣map裡面的所有物件就會釋放。呼叫map.remove(key)方法,會移除key對應的物件整個entry,這樣key和value 就不會任何物件引用,被java虛擬機器回收。
而Thread物件裡面的ThreadLocalMap裡面的key是ThreadLocal的物件的弱引用,如果ThreadLocal物件會回收,那麼ThreadLocalMap就無法移除其對應的value,那麼value物件就無法被回收,導致記憶體洩露。但是如果thread執行結束,整個執行緒物件被回收,那麼value所引用的物件也就會被垃圾回收。
什麼情況下 ThreadLocal物件會被回收了,典型的就是ThreadLocal物件作為區域性物件來使用或者每次使用的時候都new了一個物件。所以一般情況下,ThreadLocal物件都是static的,確保不會被垃圾回收以及任何時候執行緒都能夠訪問到這個物件。
寫了下面一段程式碼進行測試,發現兩個方法都沒有導致記憶體溢位,對於沒有使用執行緒池的方法來說,因為每次執行緒執行完就退出了,Map裡面引用的所有物件都會被垃圾回收,所以沒有關係,但是為什麼執行緒池的方案也沒有導致記憶體溢位了,主要原因是ThreadLocal.set方法的實現,會做一個將Key== null 的元素清理掉的工作。導致執行緒之前由於ThreadLocal物件回收之後,ThreadLocalMap中的value 也會被回收,可見設計者也注意到這個地方可能出現記憶體洩露,為了防止這種情況發生,從而清空ThreadLocalMap中null為空的元素。
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ThreadLocalLeakTest { public static void main(String[] args) { // 如果控制執行緒池的大小為50,不會導致記憶體溢位 testWithThreadPool(50); // 也不會導致記憶體洩露 testWithThread(); } static class TestTask implements Runnable { public void run() { ThreadLocal tl = new ThreadLocal(); // 確保threadLocal為區域性物件,在退出run方法之後,沒有任何強引用,可以被垃圾回收 tl.set(allocateMem()); } } public static void testWithThreadPool(int poolSize) { ExecutorService service = Executors.newFixedThreadPool(poolSize); while (true) { try { Thread.sleep(100); service.execute(new TestTask()); } catch (Exception e) { e.printStackTrace(); } } } public static void testWithThread() { try { Thread.sleep(100); } catch (InterruptedException e) { } new Thread(new TestTask()).start(); } public static final byte[] allocateMem() { // 這裡分配一個1M的物件 byte[] b = new byte[1024 * 1024 * 1]; return b; } }