翻譯 | 理解Java中的內存泄漏

豬年第一篇譯文,你們多多支持!java

原文自工程師baeldung博客,傳送門git

1. 介紹

Java 的其中一個核心特色是經由內置的垃圾回收機制(GC)下的自動化內存管理。GC 默默地處理着內存分配和釋放工做所以可以處理大部份內存泄漏問題。程序員

雖然 GC 可以有效地理一大部份內存,但他不保證能處理全部內存泄漏狀況。GC 十分智能,但並不完美。即便是在謹慎的程序員所開發的應用程序下內存泄漏依舊會悄悄地出現。github

應用程序仍然會出現產生大量的多餘的對象的狀況,所以耗盡了全部關鍵的內存塊資源,有時候還會致使應用程序崩壞。web

內存泄漏是 Java 中的一個永恆的問題。在這篇文章中,咱們將會討論內存泄漏的潛在緣由,怎麼在運行時識別它們而且怎麼在應用程序中解決它們數據庫

2. 什麼是內存泄漏

內存泄漏是指這麼一種狀況,當存在對象在堆中再也不被使用,但垃圾回收器沒法從內存中移除它們而且所以變得不可被維護。緩存

內存泄漏十分很差由於它鎖住了部份內存資源而且逐漸下降系統的性能。而且若是沒法處理它,應用程序最終會耗盡全部資源最終產生一個致命的錯誤 -- java.lang.OutOfMemoryError安全

這裏有兩種不一樣類型的對象存在於堆內存中,被引用的以及未被引用的。被引用的對象是指那些在應用程序中仍然被主動使用的而未被引用的對象是指那些不在被使用的。服務器

垃圾回收器會按期清除未被引用對象,但歷來都不收集那些仍然被引用的對象。這就是內存泄漏發生的其中一個緣由:框架

內存泄漏的症狀:

  • 當應用程序持續長時間運行致使服務器性能的嚴重降低
  • 應用程序中的堆異常 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 能夠看到,堆內存仍然未被回收:

然而,在上述的程序當中,若是咱們在第二行把關鍵字 static 去掉的話,內存使用將會發生一個劇烈的變化,在 VisualVM 能夠看到:

調試點的第一部分和存在 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 的堆內存就像下圖所示:

可是,若是咱們正確地重寫 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() 方法後,相同程序的堆內存是這樣的:

另一個使用像 Hibernate 這樣的 ORM 框架的例子中,它使用 equals()hashCode() 方法分析對象並將它們保存在緩存中。

若是這些方法不被重寫發生內存泄漏的概率會變得很是大,由於 Hibernate 沒法比較對象而且會將重複的對象填充到緩存當中。

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

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

3.4. 引用外部類的內部類

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

默認狀況下,每一個非靜態內部類都有對其包含類的隱式引用。若是咱們在程序當中使用這種內部類對象,即便包含類對象超出了做用域,它仍然不會被回收。

思考有一個類中包含大量大對象的引用以及一個非靜態內部類。如今當咱們建立一個內部類對象時,內存模型是這樣的:

然而,若是咱們定義這個內部類爲靜態,如今內存模型是這樣的:

會發生這種狀況的緣由是內部類對象隱含着外部類對象的引用,從而它不能被垃圾回收所識別。匿名類一樣如此。

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

  • 若是內部類不須要訪問包含的類的成員,考慮將它定義爲靜態類

3.5. finalize() 方法致使的內存泄漏

使用 finalizer 是另外一個潛在內存泄漏問題的來源。每當類中的 finalize() 方法被重寫,那麼該類的對象不會立刻被回收。相反,它們將會延後被 GC 放到隊列當中序列化。

此外,若是用 finalize() 方法編寫的代碼不是最優的,而且 finalizer 隊列跟不上 GC 的速度的話,那麼,應用程序早晚會發生 OutOfMemoryError 異常。

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

然而,若是咱們僅僅是移除 finalize() 方法,同一個程序給出如下的響應:

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

  • 咱們應該儘可能避免序列化

3.6. 字符串

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

若是咱們讀取一個巨大的字符串對象,而且調用 intern() 方法,它就會進入到位於 PermGen (永久內存)的字符串池中,而只要咱們的應用程序運行,它就會一直呆在那裏。

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

與此想法,在一個方法中,若是咱們只是從文件中讀取字符串,而不進行 intern,PermGen 是這樣的:

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

  • 預防的最簡單的方法就是升級到最新的 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 內部的細節:

4.3. 使用引用對象避免內存泄漏

咱們也可使用 java.lang.ref 包中內置的引用對象來處理內存泄漏。使用 java.lang.ref 包,而並不會直接引用對象,使用對對象的特殊引用使得它們容易被回收。設計出的引用隊列也讓咱們瞭解到垃圾回收的執行操做。

4.4. Eclipse 的內存泄漏警告

對於 JDK1.5 或以上的項目,當遇到明顯的內存泄漏狀況時,Eclipse 都會顯示警告和錯誤。所以使用 Eclipse 開發時,咱們能夠經過查看 Problems 標籤欄,來提防內存泄漏的警告了(若是有的話):

4.5. Benchmarking

咱們經過 Benchmarking 來度量和分析 Java 代碼的性能。經過這種方法,咱們能夠比較對同一個任務的不一樣種作法之間的性能。這能夠幫助咱們選擇更好的方法去運行,也能夠節約內存消耗。

4.6. 代碼 review

最後,仍是以咱們最經典,老式的代碼遍歷方法來處理啦。

在某些狀況下,即便是一個看起來微不足道的方法,也能夠幫助咱們消除一些常見的內存泄漏問題。

5. 總結

用外行的話來講,咱們能夠把內存泄漏看成一種疾病,它經過阻塞重要的內存資源來下降應用程序的性能。和其餘全部疾病同樣,若是沒有痊癒,隨着時間推移,它可能致使致命的程序崩潰。

內存泄漏難以解決,找到它們須要對 Java 自己有很高的掌握以及知識。在處理內存泄漏時,沒有適用於全部狀況的解決方法,由於泄漏自己能夠經過各類各樣的事件發生。

然而,若是咱們採用最佳的代碼方式實踐而且按期作代碼的回顧和嚴格的代碼分析,那麼咱們能夠將應用程序中的內存泄漏風險降至最低。

像往常那樣,用於生成本文章中 VisualVM 的響應的代碼段在咱們的 Github 上能夠獲取到。

6. 譯者總結

這篇文章很詳細的講述了各類發生內存泄漏的情形以及一些簡單的解決方法,其中詳細的解決方法在做者的其餘文章中有說起,本人由於翻譯的緣由並無放到上面,有須要的讀者能夠自行到文章本體去閱讀。

並且本人由於時(TOU)間(LAN)緣由,並無把圖片中的描述翻譯過來,望各位讀者見諒。

最後祝你們新春快樂。


小喇叭

廣州蘆葦科技Java開發團隊

蘆葦科技-廣州專業互聯網軟件服務公司

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

關注咱們的公衆號,瞭解更多

想和咱們一塊兒奮鬥嗎?lagou搜索「 蘆葦科技 」或者投放簡歷到 server@talkmoney.cn 加入咱們吧

關注咱們,你的評論和點贊對咱們最大的支持

相關文章
相關標籤/搜索