Java記憶體洩漏解決之道

banq發表於2018-11-05

Java的核心優勢之一是在內建垃圾收集器(簡稱GC)的幫助下實現自動記憶體管理。GC隱式地負責分配和釋放記憶體,因此能夠處理大多數記憶體洩漏問題。
雖然GC有效地處理了大部分記憶體,但它並不能保證記憶體洩漏的萬無一失的解決方案。GC很聰明,但並不完美。即使在盡職盡責的開發人員的應用程式中,記憶體洩漏仍然可能會洩漏。
仍然可能存在應用程式生成大量多餘物件的情況,從而耗盡關鍵記憶體資源,有時會導致整個應用程式失敗。
記憶體洩漏是Java中的一個真正問題。在本教程中,我們將瞭解記憶體洩漏的潛在原因是什麼,如何在執行時識別它們,以及如何在我們的應用程式中處理它們。

什麼是記憶體洩漏
記憶體洩漏是堆中存在不再使用的物件但垃圾收集器無法從記憶體中刪除它們的情況,因此它們會被不必要地維護。
記憶體洩漏很糟糕,因為它會阻止記憶體資源並降低系統效能。如果不處理,應用程式最終將耗盡其資源,最終以致命的java.lang.OutOfMemoryError終止。
堆記憶體中有兩種不同型別的物件 - 引用和未引用。引用的物件是在應用程式中仍具有活動引用的物件,而未引用的物件沒有任何活動引用。
垃圾收集器會定期刪除未引用的物件,但它永遠不會收集仍在引用的物件。
記憶體洩漏的症狀

  • 應用程式長時間連續執行時效能嚴重下降
  • 應用程式中的OutOfMemoryError堆錯誤
  • 自發和奇怪的應用程式崩潰
  • 應用程式偶爾會耗盡資料庫連線池物件

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

Java中的記憶體洩漏型別
在任何應用程式中,由於多種原因都可能發生記憶體洩漏:

1. 靜態欄位
可能導致潛在記憶體洩漏的第一種情況是大量使用靜態變數。
在Java中,靜態欄位的生命週期通常與正在執行的應用程式的整個生命週期相匹配(除非ClassLoader符合垃圾回收的條件)。
讓我們建立一個填充靜態 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之間,正如預期的那樣,堆記憶體增加了。
但是當我們離開populateList()所在的除錯點3時,堆記憶體還沒有被垃圾收集。
在上面的程式中,在第2行中,如果我們只刪除關鍵字  static,這次我們離開  populateList()  方法之後,列表的所有記憶體都被垃圾收集,因為我們沒有任何對它的引用。
如何預防呢?
  • 最大限度地減少靜態變數的使用
  • 使用單例時,依賴於延遲載入物件而不是急切載入的實現


2. 未關閉的連線池資源
每當我們建立新連線或開啟流時,JVM都會為這些資源分配記憶體。一些示例包括資料庫連線,輸入流和會話物件。
忘記關閉這些資源可以阻止記憶體,從而使它們遠離GC的範圍。如果異常阻止程式執行到達處理程式碼以關閉這些資源的語句,則甚至可能發生這種情況。
在任何一種情況下,資源留下的開放連線都會消耗記憶體,如果我們不處理它們,它們可能會降低效能,甚至可能導致OutOfMemoryError。
如何預防呢?

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


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作為key,由於Map不允許重複鍵,因此我們作為鍵插入的眾多重複Person物件不應增加記憶體。
但是由於我們沒有定義正確的equals()方法,重複的物件會堆積並增加記憶體,這就是我們在記憶體中看到多個物件的原因。
如果我們正確地重寫了  equals()  和hashCode()方法,那麼在這個Map中只會存在一個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);
}


另一個例子是使用像Hibernate這樣的ORM工具,它使用equals()  和hashCode()方法來分析物件並將它們儲存在快取中。
如果不覆蓋這些方法,則記憶體洩漏的可能性非常高,因為Hibernate將無法比較物件並將使用重複物件填充其快取。
如何預防呢?
  • 根據經驗,在定義新實體時,始終覆蓋equals()和hashCode()方法
  • 它不僅僅足以覆蓋,但這些方法也必須以最佳方式被覆蓋


4.引用外類的內部類
這種情況發生在非靜態內部類(匿名類)的情況下。對於初始化,這些內部類總是需要封閉類的例項。
預設情況下,每個非靜態內部類都包含對其包含類的隱式引用。如果我們在應用程式中使用這個內部類'物件,那麼即使在我們的包含類'物件超出範圍之後,它也不會被垃圾收集。
因為內部類物件隱式地儲存對外部類物件的引用,從而使其成為垃圾收集的無效候選者。在匿名類的情況下也是如此。
如何預防呢?

  • 如果內部類不需要訪問當前包含這個內部類的父類的成員時,請考慮將其轉換為靜態類


5. finalize()方法
是潛在的記憶體洩漏問題的另一個來源。每當重寫類的  finalize()方法時,該類的物件不會立即被垃圾收集。相反,GC將它們排隊等待最終確定,在稍後的時間點才會傳送GC。
如果用finalize()方法編寫的程式碼不是最佳的,並且finalize佇列無法跟上Java垃圾收集器,那麼遲早,我們的應用程式註定要遇到  OutOfMemoryError。
如何預防呢?

  • 我們應該總是避免使用finalize方法


6. 內部字串
Java 7的重大變化:Java String池在從PermGen轉移到HeapSpace了。但是對於在版本6及更低版本上執行的應用程式,在使用大型字串時我們應該更加專心。
如果我們讀取一個龐大的大量String物件,並在該物件上呼叫intern(),那麼它將轉到字串池,它位於PermGen(永久記憶體)中,並且只要我們的應用程式執行就會保留在那裡。這會阻止記憶體收集並在我們的應用程式中造成重大記憶體洩漏。
如何預防呢?

  • 解決此問題的最簡單方法是升級到最新的Java版本,因為String池從Java版本7開始轉移到HeapSpace
  • 如果處理大型字串,請增加PermGen空間的大小以避免任何潛在的OutOfMemoryErrors:
    -XX:MaxPermSize=512m


7. 使用ThreadLocal
ThreadLocal使我們能夠將狀態隔離到特定執行緒,從而允許我們實現執行緒安全。
使用此構造時,  每個執行緒將保留對其ThreadLocal變數副本的隱式引用,並且將保留其自己的副本,而不是跨多個執行緒共享資源,只要該執行緒處於活動狀態即可。
儘管有其優點,ThreadLocal  變數的使用仍存在爭議,因為如果使用不當,它們會因引入記憶體洩漏而臭名昭著。Joshua Bloch  曾評論執行緒本地用法

“如果在許多地方已經注意到,使用執行緒池的粗糙使用與ThreadLocal的粗略使用會導致意外的物件保留。但把責任歸咎於ThreadLocal是沒有根據的。“

記憶體洩漏與ThreadLocals
一旦保持執行緒不再存在,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();
    }
    

處理記憶體洩漏的其他策略
雖然在處理記憶體洩漏時沒有一個通用的解決方案,但有一些方法可以最大限度地減少這些洩漏。
1. 啟用分析
Java分析器如Java VisualVM是透過應用程式監視和診斷記憶體洩漏的工具。他們分析我們的應用程式內部發生了什麼 - 例如,如何分配記憶體。
使用分析器,我們可以比較不同的方法,並找到我們可以最佳地使用我們的資源的領域。

2. 增強垃圾收集
透過啟用詳細垃圾收集,我們將跟蹤GC的詳細跟蹤。要啟用此功能,我們需要將以下內容新增到JVM配置中:
-verbose:gc
透過新增此引數,我們可以看到GC內部發生的詳細資訊。

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

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

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

6. 程式碼評審
最後,我們總是採用經典的老式方式進行簡單的程式碼演練。
在某些情況下,即使是這種微不足道的方法也可以幫助消除一些常見的記憶體洩漏問題。

相關文章