翻譯 | 理解Java中的記憶體洩漏

廣州蘆葦科技Java開發團隊發表於2019-02-11

豬年第一篇譯文,大家多多支援!

原文自工程師baeldung部落格,傳送門

1. 介紹

Java 的其中一個核心特點是經由內建的垃圾回收機制(GC)下的自動化記憶體管理。GC 默默地處理著記憶體分配和釋放工作因此能夠處理大部分記憶體洩漏問題。

雖然 GC 能夠有效地理一大部分記憶體,但他不保證能處理所有記憶體洩漏情況。GC 十分智慧,但並不完美。即使是在謹慎的程式設計師所開發的應用程式下記憶體洩漏依舊會悄悄地出現。

應用程式仍然會出現產生大量的多餘的物件的情況,因此耗盡了所有關鍵的記憶體塊資源,有時候還會導致應用程式崩壞。

記憶體洩漏是 Java 中的一個永恆的問題。在這篇文章中,我們將會討論記憶體洩漏的潛在原因,怎麼在執行時識別它們並且怎麼在應用程式中解決它們

2. 什麼是記憶體洩漏

記憶體洩漏是指這麼一種情況,當存在物件在堆中不再被使用,但垃圾回收器無法從記憶體中移除它們並且因此變得不可被維護。

記憶體洩漏十分不好因為它鎖住了部分記憶體資源並且逐漸降低系統的效能。並且如果無法處理它,應用程式最終會耗盡所有資源最終產生一個致命的錯誤 -- java.lang.OutOfMemoryError

這裡有兩種不同型別的物件存在於堆記憶體中,被引用的以及未被引用的。被引用的物件是指那些在應用程式中仍然被主動使用的而未被引用的物件是指那些不在被使用的。

垃圾回收器會定期清除未被引用物件,但從來都不收集那些仍然被引用的物件。這就是記憶體洩漏發生的其中一個原因:

翻譯 | 理解Java中的記憶體洩漏

記憶體洩漏的症狀:

  • 當應用程式持續長時間執行導致伺服器效能的嚴重下降
  • 應用程式中的堆異常 OutOfMemoryError
  • 自發以及奇怪的程式崩潰
  • 程式偶然耗盡連線物件

讓我們關注下這些場景並且研究下它們為什麼會發生。

3. Java 中記憶體洩漏的型別

在任何的程式當中,記憶體洩漏能由幾種原因引起。在這節,我們來討論下最常見的一種。

3.1. 靜態欄位導致的記憶體洩漏

第一種可能導致記憶體洩漏的情況是大量使用靜態欄位。

在 Java,靜態欄位的生命週期通常和執行的應用程式的整個生命週期相匹配(除非類載入器有資格進行垃圾回收)

讓我們建立一個填充了靜態 list 的簡單 Java 程式:

public class StaticTest {
    public static List<Double> list = new ArrayList<>();
 
    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
        Log.info("Debug Point 2");
    }
 
    public static void main(String[] args) {
        Log.info("Debug Point 1");
        new StaticTest().populateList();
        Log.info("Debug Point 3");
    }
}
複製程式碼

現在如果我們在程式執行過程中分析堆記憶體,可以看到在除錯點1和2之間,正如預期所想的那樣,堆記憶體的使用增加了。

但是當我們在除錯點3跳出了 populateList() 方法,在 VisualVM 可以看到,堆記憶體仍然未被回收:

翻譯 | 理解Java中的記憶體洩漏

然而,在上述的程式當中,如果我們在第二行把關鍵字 static 去掉的話,記憶體使用將會發生一個劇烈的變化,在 VisualVM 可以看到:

翻譯 | 理解Java中的記憶體洩漏

除錯點的第一部分和存在 static 的例子差不多一樣。但這次在跳出 populateList() 之後,list 所使用的記憶體全部被回收了因為我們不再引用它了。

因此使用 static 變數時我們需要留意了。如果集合或者大物件被宣告為 static,那麼它們在應用程式的整個生命週期中都保留在記憶體中,因此鎖住了那些原本可以用在其他重要地方的記憶體。

怎麼預防這種情況發生呢?

  • 儘量減低 static 變數的使用
  • 使用單例模式時,使用延遲載入而非立即載入

3.2. 未關閉資源導致的記憶體洩漏

當我們產生新的連線或者開啟流的時候,JVM 會為它們分配記憶體,像資料庫連線、輸入流或者會話物件等等。

忘記關閉流能導致記憶體被鎖,從而它們也無法被回收。這甚至會出現在那些阻止程式執行關閉資源的語句的異常中。

不論哪種情況,資源產生的連線都會消耗掉記憶體,並且如果不處理它,會降低效能和導致 OutOfMemoryError

怎麼預防這種情況發生呢?

  • 始終使用 finally 塊來關閉資源
  • 關閉資源的程式碼塊(包括 finally 塊)自身不能帶有異常
  • 當使用 Java 7或更高版本,可以使用 try-with-resources 語法

3.3. 不當的 equals()hashCode() 實現

當定義新類的時候,一種非常常見的疏忽是沒有正確編寫 equals()hashCode() 的重寫實現方法。

HashSetHashMap 在許多操作當中使用這兩個方法,如果我們沒有合理地重寫它們,會導致潛在的記憶體洩漏問題。

讓我們以一個簡單的 Person 類為例,並且將其作為一 HashMap 中的鍵:

public class Person {
    public String name;
     
    public Person(String name) {
        this.name = name;
    }
}
複製程式碼

現在我們在 Map 當中作為鍵插入相同的 Person 物件。

請記住 Map 並不能存在相同的鍵:

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<100; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertFalse(map.size() == 1);
}
複製程式碼

這裡我們將 Person 作為鍵,由於 Map 不允許重複鍵,所以作為鍵插入的大量重複的 Person 應當不會增加記憶體的消耗。

但是由於我們沒有正確地定義 equals() 方法,重複的物件會堆積起來並且增加記憶體消耗,這就是為什麼在記憶體中能看到超過一個物件。VisualVM 的堆記憶體就像下圖所示:

翻譯 | 理解Java中的記憶體洩漏

但是,如果我們正確地重寫 equals()hashCode() 方法,那麼 Map 中只會存在一個 Person 物件。

讓我們看下 Person 類中正確的 equals()hashCode() 實現:

public class Person {
    public String name;
     
    public Person(String name) {
        this.name = name;
    }
     
    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Person)) {
            return false;
        }
        Person person = (Person) o;
        return person.name.equals(name);
    }
     
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }
}
複製程式碼

在這種情況下,下面的斷言是正確的:

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<2; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertTrue(map.size() == 1);
}
複製程式碼

在通過正確的 equals()hashCode() 方法後,相同程式的堆記憶體是這樣的:

翻譯 | 理解Java中的記憶體洩漏

另外一個使用像 Hibernate 這樣的 ORM 框架的例子中,它使用 equals()hashCode() 方法分析物件並將它們儲存在快取中。

如果這些方法不被重寫發生記憶體洩漏的機率會變得非常大,因為 Hibernate 無法比較物件並且會將重複的物件填充到快取當中。

怎麼預防這種情況發生呢?

  • 根據經驗,在定義新實體的時候,總是要重寫 equals()hashCode() 方法
  • 僅僅重寫還不夠,還需要以最佳的方式來處理它們

3.4. 引用外部類的內部類

這種情況發生在非靜態內部類(匿名類)當中。對於初始化,這些內部類總是需要一個封閉類的例項。

預設情況下,每個非靜態內部類都有對其包含類的隱式引用。如果我們在程式當中使用這種內部類物件,即使包含類物件超出了作用域,它仍然不會被回收。

思考有一個類中包含大量大物件的引用以及一個非靜態內部類。現在當我們建立一個內部類物件時,記憶體模型是這樣的:

翻譯 | 理解Java中的記憶體洩漏

然而,如果我們定義這個內部類為靜態,現在記憶體模型是這樣的:

翻譯 | 理解Java中的記憶體洩漏

會發生這種情況的原因是內部類物件隱含著外部類物件的引用,從而它不能被垃圾回收所識別。匿名類同樣如此。

怎麼預防這種情況發生呢?

  • 如果內部類不需要訪問包含的類的成員,考慮將它定義為靜態類

3.5. finalize() 方法導致的記憶體洩漏

使用 finalizer 是另一個潛在記憶體洩漏問題的來源。每當類中的 finalize() 方法被重寫,那麼該類的物件不會馬上被回收。相反,它們將會延後被 GC 放到佇列當中序列化。

此外,如果用 finalize() 方法編寫的程式碼不是最優的,並且 finalizer 佇列跟不上 GC 的速度的話,那麼,應用程式遲早會發生 OutOfMemoryError 異常。

為了演示這點,讓我們假設我們已經有一個重寫了 finalize() 方法的類並且這方法需要花費額外的一些時間來執行。當該類的大量物件被回收,VisualVM 是這樣的:

翻譯 | 理解Java中的記憶體洩漏

然而,如果我們僅僅是移除 finalize() 方法,同一個程式給出以下的響應:

翻譯 | 理解Java中的記憶體洩漏

怎麼預防這種情況發生呢?

  • 我們應該儘量避免序列化

3.6. 字串

Java 字串池發生了重大變化,當它在 Java7 中從 PermGen 轉移到 HeapSpace 時所發生的。但是對於在版本6及以下執行的程式,我們在處理大字串時應該更加註意。

如果我們讀取一個巨大的字串物件,並且呼叫 intern() 方法,它就會進入到位於 PermGen (永久記憶體)的字串池中,而只要我們的應用程式執行,它就會一直呆在那裡。

在 Java6 中本例子的 PermGen 在VisualVM 是這樣的:

翻譯 | 理解Java中的記憶體洩漏

與此想法,在一個方法中,如果我們只是從檔案中讀取字串,而不進行 intern,PermGen 是這樣的:

翻譯 | 理解Java中的記憶體洩漏

怎麼預防這種情況發生呢?

  • 預防的最簡單的方法就是升級到最新的 Java 版本,因為字串池是從 Java7 開始移動到 HeapSpace 的
  • 如果需要處理大字串,增加 PermGen 空間的大小,以避免任何潛在的outofmemoryerror 異常
-XX:MaxPermSize=512m
複製程式碼

3.7. 使用 ThreadLocals

ThreadLocals 是一種結構,它使我們能夠將狀態隔離到特定的執行緒中,從而實現執行緒安全。

當使用這種結構,每個執行緒都會持有其 ThreadLocal 變數副本的隱式引用,並且維護它們自身的副本,而不是在活動狀態的執行緒當中跨執行緒共享資源

儘管它有其優點,但是 ThreadLocal 的使用是受爭議的。因為如果使用不恰當,它會導致記憶體洩漏。Joshua Bloch 曾經評論過 ThreadLocals*:

草率地使用執行緒池加上草率地使用執行緒區域性變數,可能會導致意外的物件保留情況,這點在很多地方都被引起注意了,但把責任推給 ThreadLocal* 是沒有依據的。

ThreadLocals 導致的記憶體洩漏

一旦持有的執行緒不再活動,ThreadLocals 應當被回收。當問題就出在當 ThreadLocals 被使用在現在流行的應用伺服器上。

現在的應用伺服器是使用執行緒池去處理請求而並非建立新的執行緒來處理(例如 Apache Tomcat 的 Executor)此外,它們還使用單獨的類載入器。

由於應用伺服器二弟執行緒池使用執行緒重用的概念來工作,因此它們從來都不會被回收 — 相反,它們被重用來服務於另一個新的請求。

現在,如果任何類建立了一個 ThreadLocals 而並沒有顯式地刪除掉它,那麼即使在web應用程式停止後,物件的副本仍然保留在工作執行緒當中,從而使得物件沒有被回收。

怎麼預防這種情況發生呢?

  • ThreadLocals 不再使用時,清理它們是一個很好的實踐 — threadlocals 提供 remove() 方法,這個方法將刪除該變數中的當前執行緒。
  • 千萬不要使用 ThreadLocal.set(null) 來清除 — 它實際上並沒有做清除工作,而是會查詢與當前執行緒關聯的 Map 對映,並將鍵-值對分別設定為當前執行緒和null
  • 最好將 ThreadLocal 視為一個需要在 finally 塊中關閉的資源,以確保它始終處於關閉狀態,即使在異常情況下也需要如此:
try {
    threadLocal.set(System.nanoTime());
    //... further processing
}
finally {
    threadLocal.remove();
}
複製程式碼

4. 處理記憶體洩漏的其他策略

雖然在處理記憶體洩漏時並沒有一種萬能的解決方法,但是還是有些可以將風險降到最低的做法。

4.1. 使用剖析工具

Java 分析工具是通過應用程式監視和診斷記憶體洩漏的工具。它分析應用程式內部發生的事情 — 例如記憶體是怎麼分配的。

通過分析器,我們能夠比較不同的方法和找到使用資源的最優方法。

在第三節中我們使用 VisualVM。除此之外還有 Mission Control,JProfiler,YourKit,Java VisualVM,Netbeans Profiler 等等。

4.2. Verbose Garbage Collection

通過使用 Verbose Garbage Collection,我們可以跟蹤 GC 的詳細軌跡,為了開啟它,我們需要在 JVM 配置中新增如下內容:

-verbose:gc
複製程式碼

通過新增這個引數,我們可以看到 GC 內部的細節:

翻譯 | 理解Java中的記憶體洩漏

4.3. 使用引用物件避免記憶體洩漏

我們也可以使用 java.lang.ref 包中內建的引用物件來處理記憶體洩漏。使用 java.lang.ref 包,而並不會直接引用物件,使用對物件的特殊引用使得它們容易被回收。設計出的引用佇列也讓我們瞭解到垃圾回收的執行操作。

4.4. Eclipse 的記憶體洩漏警告

對於 JDK1.5 或以上的專案,當遇到明顯的記憶體洩漏情況時,Eclipse 都會顯示警告和錯誤。因此使用 Eclipse 開發時,我們可以通過檢視 Problems 標籤欄,來提防記憶體洩漏的警告了(如果有的話):

翻譯 | 理解Java中的記憶體洩漏

4.5. Benchmarking

我們通過 Benchmarking 來度量和分析 Java 程式碼的效能。通過這種方法,我們可以比較對同一個任務的不同種做法之間的效能。這可以幫助我們選擇更好的方法去執行,也可以節約記憶體消耗。

4.6. 程式碼 review

最後,還是以我們最經典,老式的程式碼遍歷方法來處理啦。

在某些情況下,即使是一個看起來微不足道的方法,也可以幫助我們消除一些常見的記憶體洩漏問題。

5. 總結

用外行的話來說,我們可以把記憶體洩漏當作一種疾病,它通過阻塞重要的記憶體資源來降低應用程式的效能。和其他所有疾病一樣,如果沒有痊癒,隨著時間推移,它可能導致致命的程式崩潰。

記憶體洩漏難以解決,找到它們需要對 Java 本身有很高的掌握以及知識。在處理記憶體洩漏時,沒有適用於所有情況的解決方法,因為洩漏本身可以通過各種各樣的事件發生。

然而,如果我們採用最佳的程式碼方式實踐並且定期做程式碼的回顧和嚴格的程式碼分析,那麼我們可以將應用程式中的記憶體洩漏風險降至最低。

像往常那樣,用於生成本文章中 VisualVM 的響應的程式碼段在我們的 Github 上可以獲取到。

6. 譯者總結

這篇文章很詳細的講述了各種發生記憶體洩漏的情形以及一些簡單的解決方法,其中詳細的解決方法在作者的其他文章中有提及,本人因為翻譯的原因並沒有放到上面,有需要的讀者可以自行到文章本體去閱讀。

而且本人因為時(TOU)間(LAN)原因,並沒有把圖片中的描述翻譯過來,望各位讀者見諒。

最後祝大家新春快樂。


小喇叭

廣州蘆葦科技Java開發團隊

蘆葦科技-廣州專業網際網路軟體服務公司

抓住每一處細節 ,創造每一個美好

關注我們的公眾號,瞭解更多

想和我們一起奮鬥嗎?lagou搜尋“ 蘆葦科技 ”或者投放簡歷到 server@talkmoney.cn 加入我們吧

翻譯 | 理解Java中的記憶體洩漏

關注我們,你的評論和點贊對我們最大的支援

相關文章