1. 程式人生 > >瞭解Java中的記憶體洩漏

瞭解Java中的記憶體洩漏

1. 簡介

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

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

仍然可能存在應用程式生成大量多餘物件的情況,從而耗盡關鍵記憶體資源,有時會導致整個應用程式失敗。

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

2. 什麼是記憶體洩漏

記憶體洩漏是堆中存在不再使用的物件但垃圾收集器無法從記憶體中刪除它們的情況,因此它們會被不必要地一直存在。

記憶體洩漏很糟糕,因為它會耗盡記憶體資源並降低系統性能。如果不處理,應用程式最終將耗盡其資源,最終以致命的java.lang.OutOfMemoryError終止。

堆記憶體中有兩種不同型別的物件 - 被引用和未被引用。被引用的物件是在應用程式中仍具有活動引用的物件,而未被引用的物件沒有任何的活動引用。

垃圾收集器會定期刪除未引用的物件,但它永遠不會收集仍在引用的物件。這是可能發生記憶體洩漏的地方:

記憶體洩漏的症狀

應用程式長時間連續執行時效能嚴重下降

應用程式中的OutOfMemoryError堆錯誤

自發且奇怪的應用程式崩潰

應用程式偶爾會耗盡連線物件

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

3. Java中記憶體洩漏型別

在任何應用程式中,數不清的原因可能導致記憶體洩漏。在本節中,我們將討論最常見的問題。

3.1static欄位引起的記憶體洩漏

可能導致潛在記憶體洩漏的第一種情況是大量使用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響應中看到的一樣:

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

直到除錯點的第一部分幾乎與我們在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中的堆記憶體如下所示:

但是,**如果我們正確地重寫了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()之後,堆記憶體在同一程式中如下所示:

另一個例子是當使用像hibernate這樣的ORM框架,他們使用equals()和hashCode()方法去分析物件然後將他們儲存在快取中。

如何預防呢?

根據經驗,定義新的實體時,總要重寫equals()和hashCode()方法。

只是重寫他們是不夠的,這些方法必須以最佳的方式被重寫。

有關更多資訊,請訪問我們的Generateequals()andhashCode()with Eclipse 和Guide tohashCode()in Java。

3.4引用了外部類的內部類

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

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

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

但是,如果我們只是將內部類宣告為static,那麼相同的記憶體模型如下所示:

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

如何預防呢?

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

3.5finalize()方法造成的記憶體洩漏

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

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

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

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

如何預防呢?

我們應該總是避免finalizers

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

常量字串造成的記憶體洩漏

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

如果我們讀取一個龐大的大量String物件,並在該物件上呼叫intern(),那麼它將轉到字串池,它位於PermGen(永生代)中,並且只要我們的應用程式執行就會保留在那裡。這會佔用記憶體並在我們的應用程式中造成重大記憶體洩漏。

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

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

如何預防呢?

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

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

-XX:MaxPermSize=512m

3.7 使用ThreadLocal造成的記憶體洩漏

ThreadLocal(在Introduction toThreadLocalin 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內部發生的詳細資訊:

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

我們還可以使用java中的引用物件來構建java.lang.ref包來處理記憶體洩漏。使用java.lang.ref包,我們使用物件的特殊引用,而不是直接引用物件,這些物件可以很容易地進行垃圾回收。

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

Eclipse的記憶體洩漏警告

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

4.5 基準分析

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

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

4.6 程式碼稽核

最後,我們總是採用經典懷舊方式進行簡單的程式碼稽核。

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

5 結論

通俗地說,我們可以將記憶體洩漏視為一種通過阻止重要記憶體資源來降低應用程式效能的疾病。和所有其他疾病一樣,如果不治癒,它可能導致致命的應用程式崩潰隨著時間的推移。

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

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

與往常一樣,GitHub提供了用於生成本教程中描述的VisualVM響應的程式碼片段 。