瞭解Java中的內存泄漏 轉

原文鏈接:https://www.baeldung.com/java-memory-leaks

作者:baeldung

譯者:thornhill

1. 簡介

Java的核心優勢之一是在內置垃圾收集器(簡稱GC)的幫助下實現自動內存管理。GC隱含地負責分配和釋放內存,因此能夠處理大多數內存泄漏問題。

雖然GC有效地處理了大部分內存,但它並不能成爲保證內存泄漏的萬無一失的解決方案。GC很聰明,但並不完美。即使在盡職盡責的開發人員的應用程序中,內存仍然可能會泄漏。

仍然可能存在應用程序生成大量多餘對象的情況,從而耗盡關鍵內存資源,有時會導致整個應用程序失敗。

內存泄漏是Java中的一個真實存在的問題。在本教程中,我們將瞭解內存泄漏的潛在原因是什麼,如何在運行時識別它們,以及如何在我們的應用程序中處理它們

2. 什麼是內存泄漏

內存泄漏是堆中存在不再使用的對象但垃圾收集器無法從內存中刪除它們的情況,因此它們會被不必要地一直存在。

內存泄漏很糟糕,因爲它會耗盡內存資源並降低系統性能。如果不處理,應用程序最終將耗盡其資源,最終以致命的java.lang.OutOfMemoryError終止。

堆內存中有兩種不同類型的對象 - 被引用和未被引用。被引用的對象是在應用程序中仍具有活動引用的對象,而未被引用的對象沒有任何的活動引用。

垃圾收集器會定期刪除未引用的對象,但它永遠不會收集仍在引用的對象。這是可能發生內存泄漏的地方:img

內存泄漏的症狀

  • 應用程序長時間連續運行時性能嚴重下降
  • 應用程序中的OutOfMemoryError堆錯誤
  • 自發且奇怪的應用程序崩潰
  • 應用程序偶爾會耗盡連接對象

讓我們仔細看看其中一些場景以及如何處理它們。

3. Java中內存泄漏類型

在任何應用程序中,數不清的原因可能導致內存泄漏。在本節中,我們將討論最常見的問題。

3.1 static字段引起的內存泄漏

可能導致潛在內存泄漏的第一種情況是大量使用static(靜態)變量。

在Java中,靜態字段通常擁有與整個應用程序相匹配的生命週期(除非ClassLoader複合垃圾回收的條件)。

讓我們創建一個填充靜態列表的簡單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響應中看到的一樣:

img

但是,在上面的程序中,在第2行中,如果我們只刪除關鍵字 static,那麼它將對內存使用量帶來巨大的變化,這個Visual VM響應顯示: img

直到調試點的第一部分幾乎與我們在static情況下獲得的部分相同 。但這次當我們離開populateList()方法,列表中所有的內存都被垃圾回收掉了,因爲我們沒有任何對他的引用

因此,我們需要非常關注static(靜態)變量的使用。如果集合或大對象被聲明爲static,那麼它們將在應用程序的整個生命週期中保留在內存中,從而阻止可能在其他地方使用的重要內存。

如何預防呢?

  • 最大限度地減少靜態變量的使用
  • 使用單例時,依賴於延遲加載對象而不是立即加載的方式

3.2 未關閉的資源導致的內存泄漏

每當我們創建連接或打開一個流時,JVM都會爲這些資源分配內存。例如數據庫連接,輸入流或者會話對象。

忘記關閉這些資源會導致持續佔有內存,從而使他們無法GC。如果異常阻止程序執行到達處理關閉這些資源的代碼,則甚至可能發生這種情況。

在任一種情況下,資源留下的開放連接都會消耗內存,如果我們不處理他們,他們可能會降低性能,甚至可能導致OutOfMemoryError

如何預防呢?

  • 始終使用finally塊來關閉資源
  • 關閉資源的代碼(甚至在 finally塊中)本身不應該有任何異常
  • 使用Java 7+時,我們可以使用try -with-resources

3.3 不正確的equals()hashCode()實現

在定義新類時,一個非常常見的疏忽是不爲equals()hashCode()方法編寫適當的重寫方法。

HashSet 和 HashMap 在許多操作中使用這些方法,如果它們沒有被正確覆蓋,那麼它們可能成爲潛在的內存泄漏問題的來源。

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

public class Person {
    public String name;

    public Person(String name) {
        this.name = name;
    }
}

現在我們將重複的Person對象插入到使用此鍵的Map中。

請記住,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中的堆內存如下所示:img

但是,**如果我們正確地重寫了equals() 和hashCode()方法,那麼在這個Map中只會存在一個Person對象。

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

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;
    }
}

在這種情況下,下面的斷言將會是true:

@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()之後,堆內存在同一程序中如下所示:

img

另一個例子是當使用像hibernate這樣的ORM框架,他們使用equals()hashCode()方法去分析對象然後將他們保存在緩存中。

如何預防呢?

  • 根據經驗,定義新的實體時,總要重寫equals()hashCode()方法。
  • 只是重寫他們是不夠的,這些方法必須以最佳的方式被重寫。

有關更多信息,請訪問我們的 Generate equals() and hashCode() with Eclipse 和Guide to hashCode() in Java

3.4引用了外部類的內部類

這種情況發生在非靜態內部類(匿名類)的情況下。對於初始化,這些內部類總是需要外部類的實例。

默認情況下,每個非靜態內部類都包含對其包含類的隱式引用。如果我們在應用程序中使用這個內部類'對象,那麼即使在我們的包含類'對象超出範圍之後,它也不會被垃圾收集

考慮一個類,它包含對大量龐大對象的引用,並具有非靜態內部類。現在,當我們創建一個內部類的對象時,內存模型如下所示:

img

但是,如果我們只是將內部類聲明爲static,那麼相同的內存模型如下所示:

img

發生這種情況是因爲內部類對象隱式地保存對外部類對象的引用,從而使其成爲垃圾收集的無效候選者。在匿名類的情況下也是如此。

如何預防呢?

  • 如果內部類不需要訪問包含的類成員,請考慮將其轉換爲靜態類

3.5finalize()方法造成的內存泄漏

使用finalizers是潛在的內存泄漏問題的另一個來源。每當重寫類的 finalize()方法時,該類的對象不會立即被垃圾收集。相反,GC將它們排隊等待最終確定,這將在稍後的時間點發生。

另外,如果用finalize()方法編寫的代碼不是最佳的,並且終結器隊列無法跟上Java垃圾收集器,那麼遲早,我們的應用程序註定要遇到 OutOfMemoryError

爲了證明這一點,讓我們考慮一下我們已經覆蓋了 finalize()方法的類,並且該方法需要一些時間來執行。當這個類的大量對象被垃圾收集時,那麼在VisualVM中,它看起來像:

img

但是,如果我們只刪除重寫的finalize()方法,那麼同一程序會給出以下響應:

img

如何預防呢?

  • 我們應該總是避免finalizers

有關finalize()的更多詳細信息,請閱讀我們的 Guide to the finalize Method in Java 第3節(避免終結器) 。

常量字符串造成的內存泄漏

Java String池Java 7時經歷了在從永生代(PermGen)轉移到堆空間(HeapSpace)的重大變化。但是對於在版本6及更低版本上運行的應用程序,在使用大型字符串時我們應該更加專心。

如果我們讀取一個龐大的大量String對象,並在該對象上調用intern(),那麼它將轉到字符串池,它位於PermGen(永生代)中,並且只要我們的應用程序運行就會保留在那裏。這會佔用內存並在我們的應用程序中造成重大內存泄漏。

JVM 1.6中這種情況的PermGen在VisualVM中看起來像這樣:

img

與此相反,在一個方法中,如果我們只是從文件中讀取一個字符串而不是intern(),那麼PermGen看起來像:

img

如何預防呢?

  • 解決此問題的最簡單方法是升級到最新的Java版本,因爲String池從Java版本7開始轉移到HeapSpace

  • 如果處理大型字符串,請增加PermGen空間的大小以避免任何潛在的OutOfMemoryErrors

    -XX:MaxPermSize=512m
    

3.7 使用ThreadLocal造成的內存泄漏

ThreadLocal (在Introduction to ThreadLocal in Java 中詳細介紹),是一種能將狀態隔離到特定線程,從而保證我們實現線程安全的結構。

使用此結構時,每個線程只要處於存活狀態即可將保留對其ThreadLocal變量副本的隱式引用,並且將保留其自己的副本,而不是跨多個線程共享資源。

儘管有其優點,ThreadLocal 變量的使用仍存在爭議,因爲如果使用不當,它們會因引入內存泄漏而臭名昭着。 Joshua Bloch once commented on thread local usage

“Sloppy use of thread pools in combination with sloppy use of thread locals can cause unintended object retention, as has been noted in many places. But placing the blame on thread locals is unwarranted.”

"隨意的在線程池中使用ThreadLocal會保留很多意外的對象。但把責任歸咎於ThreadLocal是沒有根據的 "

ThreadLocal中的內存泄漏

一旦保持線程不再存在,ThreadLocals應該被垃圾收集。但是當ThreadLocals與現代應用程序服務器一起使用時,問題就出現了。

現代應用程序服務器使用線程池來處理請求而不是創建新請求(例如在Apache Tomcat的情況下爲Executor)。此外,他們還使用單獨的類加載器。

由於應用程序服務器中的線程池在線程重用的概念上工作,因此它們永遠不會被垃圾收集 - 相反,它們會被重用來處理另一個請求。

現在,如果任何類創建 ThreadLocal 變量但未顯式刪除它,則即使在Web應用程序停止後,該對象的副本仍將保留在工作線程中,從而防止對象被垃圾回收。

如何預防呢?

  • 在不再使用ThreadLocals時清理ThreadLocals是一個很好的做法- ThreadLocals提供了 remove())方法,該方法刪除了此變量的當前線程值

  • 不要使用 ThreadLocal.set(null) 來清除該值 - 它實際上不會清除該值,而是查找與當前線程關聯的Map並將鍵值對設置爲當前線程並分別爲null

  • 最好將 ThreadLocal 視爲需要在finally塊中關閉的資源,以 確保它始終關閉,即使在異常的情況下:

    try {
        threadLocal.set(System.nanoTime());
        //... further processing
    }
    finally {
        threadLocal.remove();
    }
    

4. 處理內存泄漏的其他策略

雖然在處理內存泄漏時沒有一個通用的解決方案,但有一些方法可以最大限度地減少這些泄漏。

4.1 使用Profiling工具

Java分析器是通過應用程序監視和診斷內存泄漏的工具。他們分析我們的應用程序內部發生了什麼 - 例如,如何分配內存。

使用分析器,我們可以比較不同的方法,並找到我們可以最佳地使用我們的資源的領域。

我們在本教程的第3部分中使用了Java VisualVM。請查看我們的 Java Profilers指南, 瞭解不同類型的分析器,如Mission Control,JProfiler,YourKit,Java VisualVM和Netbeans Profiler。

4.2 詳細垃圾回收

通過啓用詳細垃圾收集,我們將跟蹤GC的詳細跟蹤。要啓用此功能,我們需要將以下內容添加到JVM配置中:

通過添加此參數,我們可以看到GC內部發生的詳細信息:

img

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

我們還可以使用java中的引用對象來構建java.lang.ref包來處理內存泄漏。使用java.lang.ref包,我們使用對象的特殊引用,而不是直接引用對象,這些對象可以很容易地進行垃圾回收。

引用隊列旨在讓我們瞭解垃圾收集器執行的操作。有關更多信息,請閱讀Baeldung的 Soft References in Java ,特別是第4節。

Eclipse的內存泄漏警告

對於JDK 1.5及更高版本的項目,Eclipse會在遇到明顯的內存泄漏情況時顯示警告和錯誤。因此,在Eclipse中開發時,我們可以定期訪問“問題”選項卡,並對內存泄漏警告(如果有)更加警惕:

img

4.5 基準分析

我們可以通過執行基準來測量和分析Java代碼的性能。這樣,我們可以比較替代方法的性能來完成相同的任務。這可以幫助我們選擇更好的方法,並可以幫助我們節約內存。

有關基準測試的更多信息,請訪問我們的 Microbenchmarking with Java 教程。

4.6 代碼審覈

最後,我們總是採用經典懷舊方式進行簡單的代碼審覈。

在某些情況下,即使是這種微不足道的方法也可以幫助消除一些常見的內存泄漏問題。

5 結論

通俗地說,我們可以將內存泄漏視爲一種通過阻止重要內存資源來降低應用程序性能的疾病。和所有其他疾病一樣,如果不治癒,它可能導致致命的應用程序崩潰隨着時間的推移。

內存泄漏很難解決,找到它們需要通過Java語言進行復雜的掌握和命令。在處理內存泄漏時,沒有一個通用的解決方案,因爲泄漏可能通過各種各樣的事件發生。

但是,如果我們採用最佳實踐並定期執行嚴格的代碼演練和分析,那麼我們可以最大程度地降低應用程序中內存泄漏的風險。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章