Java的內存泄露(Memory Leaks)

1.簡介

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

雖然 GC 有效地處理了大量內存,但它不能保證內存泄漏的萬無一失。GC 相當聰明,但並非完美無瑕。即使有經驗的開發人員寫的程序,有時也會發生內存泄露。

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

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

2.什麼是內存泄漏

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

內存泄漏是嚴重的,因爲它會阻止內存資源,並隨着時間的推移降低系統性能。如果不處理,應用程序最終將耗盡其資源,最終終止一個致命的java.lang.OutOfMemoryError

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

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

img

內存泄漏的症狀:

  • 當應用程序長時間持續運行時,性能會嚴重下降
  • 應用程序中的OutOfMemoryError heap error
  • 自發和奇怪的應用程序崩潰
  • 應用程序偶爾耗盡連接對象

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

3.Java 中內存泄漏的類型

3.1 靜態塊引起的內存泄露

可能導致潛在內存泄漏的第一個方案是大量使用靜態變量。

在 Java 中,靜態字段的生存期通常與正在運行的應用程序的整個生存期匹配(除非 ClassLoader 有資格進行垃圾回收)。

請看如下例子:

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

在程序的執行過程中,我們分析堆內存的時候,我們會發現在運行到points 1和2之間,堆內存增長很快。

但是,當我們將populateList() 方法保留在point 3 時,堆內存尚未被垃圾回收,正如我們在此 VisualVM 響應中看到的:

img

但是,在上述程序中,在第 2 行中,如果我們只是刪除static 這個單詞,那麼它將會給內存使用情況帶來急劇的變化,此 Visual VM 響應顯示:

img

調試點之前的第一部分幾乎與靜態時獲得的相同。但這次我們離開populateList() 方法後,列表的所有內存都是垃圾回收的,因爲我們沒有任何對它的任何引用。

因此,我們需要非常密切地注意靜態變量的使用。如果集合或大型對象聲明爲靜態,則它們在整個應用程序的生存期內都保留在內存中,從而阻塞了本來可以在其他地方使用的重要內存。

怎麼防止它呢?

  • 靜態變量的使用最小化
  • 使用單例時,請依賴延遲加載對象的實現,而不是一開始就加載對象。

3.2 未關閉的資源

每當我們建立新連接或打開流時,JVM 都會爲這些資源分配內存。幾個示例包括database connections, input streams, and session objects.

忘記關閉這些資源可能會阻塞內存,從而使它們無法到達 GC階段。如果異常阻止程序執行到達處理代碼以關閉這些資源的語句,則甚至可能發生這種情況。

在這兩種情況下,資源留下的開放連接會消耗內存,如果我們不處理它們,它們可能會降低性能,甚至會導致出用內存錯誤。

怎麼防止它?

  • 使用finally塊去釋放資源
  • 關閉資源的代碼不應該拋異常
  • 使用Java 7+,我們要使用try-with-resources 塊

3.3 不恰當的equals()和hashCode()的實現

定義新類時,一個很常見的錯誤是重equals() 和hashCode() 方法的重寫不對。

HashSet 和 HashMap 在許多操作中使用這些方法,如果這些方法未正確重寫,則它們可能成爲潛在內存泄漏問題的根源。

舉個例子:

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

我們使用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);
}

在這裏,我們使用人作爲Map的鍵。由於 Map 不允許重複鍵,因此我們作爲鍵插入的衆多重複的 Person 對象不應增加內存。

但是,由於我們還沒有定義正確的 equals() 方法,重複的對象堆積起來並增加內存,這就是爲什麼我們在內存中看到多個對象的原因。此 VisualVM 中的堆內存如下所示:img

如果我們把 *equals()* 和 *hashCode()* 方法正確的重寫,在Map裏其實只有一個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;
    }
}
@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);
}

運行程序,查看堆內存:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-ztlYYxjM-1574921649514)(https://www.baeldung.com/wp-content/uploads/2018/11/Afterimplementing_equals_and_hashcode.png)]

另一個示例是使用 ORM 工具(如 Hibernate),該工具使用 equals() and hashCode() 方法來分析對象並將其保存在緩存中。

如果不重寫這些方法,內存泄漏的可能性相當高,因爲 Hibernate 將無法比較對象,並且會用重複的對象填充其緩存。

怎麼防止它呢?

  • 定義新類時,要重寫equals()和hashCode()

3.4 內部類

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

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

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

img

如果把內部類聲明爲static類型的,堆內存的表現:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-3VkdLyhq-1574921649518)(https://www.baeldung.com/wp-content/uploads/2018/11/Static_Classes_That_Reference_Outer_Classes.png)]

這是因爲內部類對象隱式地保存對外部類對象的引用,從而使其成爲垃圾回收的無效候選項。匿名類也是如此。

如何防止呢?

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

3.5 finalize() 方法

finalizers 是潛在內存泄漏問題的另一個來源。每當重寫類 finalize() 方法時,該類的對象不會立即進行垃圾回收。相反,GC 會將它們排隊以進行終結,這在稍後的時間點發生。

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

爲了演示這一點,讓我們考慮我們有一個類,我們已經重寫了finalize() 方法,並且該方法需要一點時間來執行。當此類的大量對象被垃圾回收時,然後在 VisualVM 中,它看起來像:

img

如果我們移除了重寫的finalize()方法。

img

怎麼防止它呢?

  • 避免 finalizers

3.6 已處理的字符串

當 Java 字符串池從 PermGen 傳輸到 HeapSpace 時,它經歷了 Java 7 中的重大變化。但對於在版本 6 及以下版本上運行的應用程序,在使用大型字符串時應更加小心。

如果我們讀取一個巨大的 String 對象,並在該對象上調用 intern(),則它將轉到位於 PermGen(永久內存)中的字符串池,只要我們的應用程序運行,它就會一直保留在那裏。這會阻塞內存,並在我們的應用程序中創建一個主要的內存泄漏。

JVM 1.6 中此案例的 PermGen 在 VisualVM 中如下所示:

img

對比的是,我們只是從文件中讀取一個字符串,不使用intern() 方法,它在永久區的表現:

img

怎麼避免它呢?

  • 最簡單的方法就是升級Java 版本,在java7+後,String pool從永久區移到堆空間了。
  • 爲了避免可能的內存泄漏:-XX:MaxPermSize=512m

3.7 使用 ThreadLocals

ThreadLocal(在 Java 教程中詳細討論線程Local)是一種構造,它使我們能夠將狀態隔離到特定線程,從而使我們能夠實現線程安全。

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

儘管它的優點,使用ThreadLocal變量是有爭議的,因爲它們是臭名昭著的引入內存泄漏,如果使用不當。

線程局部變量應在保持線程不再處於活動狀態時進行垃圾回收。但是,當 ThreadLocals 與現代應用程序服務器一起使用時,問題就出現了。

現代應用程序服務器使用線程池來處理請求,而不是創建新的請求(例如 Apache Tomcat 中的執行器)。此外,它們還使用單獨的類加載器。

由於應用程序服務器中的線程池採用線程重用的概念,因此它們永遠不會被垃圾回收,相反,它們被重用以提供另一個請求。

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

如何避免它?

  • 在不再使用 ThreadLocals 時,最好清理它們 - ThreadLocals 提供 remove() 方法,該方法將刪除此變量的當前線程值
  • 不要使用 ThreadLocal.set(null) 來清除值 - 它實際上不會清除值,而是查找與當前線程關聯的映射,並將鍵值對分別設置爲當前線程和 null
  • 最好將 ThreadLocal 視爲需要在最終塊中關閉的資源,以確保始終關閉,即使在出現異常時也是如此:
try {
    threadLocal.set(System.nanoTime());
    //... further processing
}
finally {
    threadLocal.remove();
}

4.其他處理內存泄漏的方法

儘管在處理內存泄漏時沒有一刀切的解決方案,但可以通過一些方法將這些泄漏降至最低。

4.1 Java Profiling

Java profilers是監視和診斷應用程序內存泄漏的工具。他們分析我們應用程序中的內部發生的情況,例如,內存的分配方式。

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

在本教程的第 3 節中,我們使用了 Java VisualVM。請查看我們的 Java 探查器指南,瞭解不同類型的探查器,如任務控制、JProfiler、YourKit、Java VisualVM 和 Netbeans 探查器。

4.2 Verbose 垃圾回收

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

-verbose:gc

通過添加這個,我們能知道詳盡的垃圾回收:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-Nz5cZEv5-1574921649530)(https://www.baeldung.com/wp-content/uploads/2018/11/verbose-garbage-collection.jpg)]

4.3 使用關聯對象來避免內存泄漏

我們還可以使用 Java 中內置的引用對象來處理內存泄漏。使用 java.lang.ref 包,而不是直接引用對象,我們使用對對象的特殊引用,以便輕鬆進行垃圾回收。

引用隊列旨在讓我們瞭解垃圾回收器執行的操作。有關詳細信息,請閱讀 Java Baeldung 教程中的soft References,特別是第 4 節。

4.4 Benchmarking

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

有關基準測試的詳細信息,請前往我們的 Java 微基準測試教程。

4.5 代碼評審

最後,我們總是有經典的,老派的方式做一個簡單的代碼演練。

在某些情況下,即使是這種瑣碎的查找方法也有助於消除一些常見的內存泄漏問題。

5.結論

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

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

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

與往常一樣,本教程中描述的用於生成 VisualVM 響應的代碼段可在 GitHub 上找到。

參考代碼

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