如何用Java編寫一段程式碼引發記憶體洩露

ImportNew - hejiani發表於2015-03-25

本文來自StackOverflow問答網站的一個熱門討論:如何用Java編寫一段會發生記憶體洩露的程式碼。

Q:剛才我參加了面試,面試官問我如何寫出會發生記憶體洩露的Java程式碼。這個問題我一點思路都沒有,好囧。

A1:通過以下步驟可以很容易產生記憶體洩露(程式程式碼不能訪問到某些物件,但是它們仍然儲存在記憶體中):

  1. 應用程式建立一個長時間執行的執行緒(或者使用執行緒池,會更快地發生記憶體洩露)。
  2. 執行緒通過某個類載入器(可以自定義)載入一個類。
  3. 該類分配了大塊記憶體(比如new byte[1000000]),在某個靜態變數儲存一個強引用,然後在ThreadLocal中儲存它自身的引用。分配額外的記憶體new byte[1000000]是可選的(類例項洩露已經足夠了),但是這樣會使記憶體洩露更快。
  4. 執行緒清理自定義的類或者載入該類的類載入器。
  5. 重複以上步驟。

由於沒有了對類和類載入器的引用,ThreadLocal中的儲存就不能被訪問到。ThreadLocal持有該物件的引用,它也就持有了這個類及其類載入器的引用,類載入器持有它所載入的類的所有引用,這樣GC無法回收ThreadLocal中儲存的記憶體。在很多JVM的實現中Java類和類載入器直接分配到permgen區域不執行GC,這樣導致了更嚴重的記憶體洩露。

這種洩露模式的變種之一就是如果你經常重新部署以任何形式使用了ThreadLocal的應用程式、應用容器(比如Tomcat)會很容易發生記憶體洩露(由於應用容器使用瞭如前所述的執行緒,每次重新部署應用時將使用新的類載入器)。

A2:

靜態變數引用物件

class MemorableClass {
    static final ArrayList list = new ArrayList(100);
}

呼叫長字串的String.intern()

String str=readString(); // read lengthy string any source db,textbox/jsp etc..
// This will place the string in memory pool from which you cant remove
str.intern();

未關閉已開啟流(檔案,網路等)

try {
    BufferedReader br = new BufferedReader(new FileReader(inputFile));
    ...
    ...
} catch (Exception e) {
    e.printStacktrace();
}

未關閉連線

try {
    Connection conn = ConnectionFactory.getConnection();
    ...
    ...
} catch (Exception e) {
    e.printStacktrace();
}

JVM的GC不可達區域

比如通過native方法分配的記憶體。

web應用在application範圍的物件,應用未重啟或者沒有顯式移除

getServletContext().setAttribute("SOME_MAP", map);

web應用在session範圍的物件,未失效或者沒有顯式移除

session.setAttribute("SOME_MAP", map);

不正確或者不合適的JVM選項

比如IBM JDK的noclassgc阻止了無用類的垃圾回收

A3:如果HashSet未正確實現(或者未實現)hashCode()或者equals(),會導致集合中持續增加“副本”。如果集合不能地忽略掉它應該忽略的元素,它的大小就只能持續增長,而且不能刪除這些元素。

如果你想要生成錯誤的鍵值對,可以像下面這樣做:

class BadKey {
   // no hashCode or equals();
   public final String key;
   public BadKey(String key) { this.key = key; }
}

Map map = System.getProperties();
map.put(new BadKey("key"), "value"); // Memory leak even if your threads die.

A4:除了被遺忘的監聽器,靜態引用,hashmap中key錯誤/被修改或者執行緒阻塞不能結束生命週期等典型記憶體洩露場景,下面介紹一些不太明顯的Java發生記憶體洩露的情況,主要是執行緒相關的。

  • Runtime.addShutdownHook後沒有移除,即使使用了removeShutdownHook,由於ThreadGroup類對於未啟動執行緒的bug,它可能不被回收,導致ThreadGroup發生記憶體洩露。
  • 建立但未啟動執行緒,與上面的情形相同
  • 建立繼承了ContextClassLoaderAccessControlContext的執行緒,ThreadGroupInheritedThreadLocal的使用,所有這些引用都是潛在的洩露,以及所有被類載入器載入的類和所有靜態引用等等。這對ThreadFactory介面作為重要組成元素整個j.u.c.Executor框架(java.util.concurrent)的影響非常明顯,很多開發人員沒有注意到它潛在的危險。而且很多庫都會按照請求啟動執行緒。
  • ThreadLocal快取,很多情況下不是好的做法。有很多基於ThreadLocal的簡單快取的實現,但是如果執行緒在它的期望生命週期外繼續執行ContextClassLoader將發生洩露。除非真正必要不要使用ThreadLocal快取。
  • 當ThreadGroup自身沒有執行緒但是仍然有子執行緒組時呼叫ThreadGroup.destroy()。發生記憶體洩露將導致該執行緒組不能從它的父執行緒組移除,不能列舉子執行緒組。
  • 使用WeakHashMap,value直接(間接)引用key,這是個很難發現的情形。這也適用於繼承Weak/SoftReference的類可能持有對被保護物件的強引用。
  • 使用http(s)協議的java.net.URL下載資源。KeepAliveCache在系統ThreadGroup建立新執行緒,導致當前執行緒的上下文類載入器記憶體洩露。沒有存活執行緒時執行緒在第一次請求時建立,所以很有可能發生洩露。(在Java7中已經修正了,建立執行緒的程式碼合理地移除了上下文類載入器。)
  • 使用InflaterInputStream在建構函式(比如PNGImageDecoder)中傳遞new java.util.zip.Inflater(),不呼叫inflater的end()。僅僅是new的話非常安全,但如果自己建立該類作為建構函式引數時呼叫流的close()不能關閉inflater,可能發生記憶體洩露。這並不是真正的記憶體洩露因為它會被finalizer釋放。但這消耗了很多native記憶體,導致linux的oom_killer殺掉程式。所以這給我們的教訓是:儘可能早地釋放native資源。
  • java.util.zip.Deflater也一樣,它的情況更加嚴重。好的地方可能是很少用到Deflater。如果自己建立了Deflater或者Inflater記住必須呼叫end()

相關文章