天天看點

了解java 中記憶體洩漏

了解java 中記憶體洩漏

java的核心優勢之一是使用内置的垃圾回收機制(簡稱GC)實作自動記憶體管理。GC隐式地負責配置設定和釋放記憶體,是以能夠處理大部分記憶體洩漏問題。

雖然GC有效地處理了很大一部分記憶體,但它不能保證記憶體洩漏的解決方案是萬無一失的。GC非常智能,但并非完美無缺。即使在認真開發人員的應用程式中,記憶體洩漏也可能悄然出現。如應用程式生成大量多餘對象的情況,進而耗盡關鍵的記憶體資源,有時導緻整個應用程式失敗。

記憶體洩漏是java與生俱來的問題。本文我們探尋記憶體洩漏的潛在原因,如何在運作時識别它們,以及如何在應用程式中處理該問題。

1. 什麼事記憶體洩漏

記憶體洩漏是指存在堆中的對象不再被使用,但GC不能在記憶體中删除它們,而不必要地維護它們。

記憶體洩漏産生問題是占用記憶體資源并逐漸降低系統性能。如何不及時處理,應用程式将耗盡資源,最終導緻緻命的 java.lang.OutOfMemory錯誤。

在堆記憶體中有兩種對象——引用對象和非引用對象(可達或不可達)。引用對象是指應用程式正在使用的對象,而非引用對象是指應用程式不再使用的對象。GC負責定期地删除非引用對象,但不會回收引用對象。下圖為記憶體洩漏記憶體示意圖:

了解java 中記憶體洩漏

記憶體洩漏症狀

  • 引用運作一段時間後性能嚴重下降
  • 産生OutOfMemoryError錯誤
  • 自然地或奇怪地應用程式崩潰
  • 應用程式偶爾會耗盡連接配接對象

下面我們詳細講解這些場景以及如何應對。

2. java 中記憶體洩漏類型

應用程式産生記憶體洩漏可能有多種原因,下面我們讨論一些常見原因。

2.1. static 字段導緻記憶體洩漏

第一個能引起記憶體洩漏的場景是static變量。java中static字段的生命周期通常是整個應用程式(除非類加載器有資格進行垃圾收集).

下面建立一個簡單java程式,填充一個static的list變量:

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

如果我們應用程式執行過程中的分析堆記憶體情況,可以看到debug 1位置和2位置,堆記憶體如期望一緻保持增長。但在debug 3位置離開populateList()方法,堆記憶體沒有垃圾回收,下面是VisualVM截圖:

了解java 中記憶體洩漏

如果我們删除第二行的static關鍵字,堆記憶體會産生戲劇性變化,如圖示:

了解java 中記憶體洩漏

從開始到debug 2幾乎與static情況下一緻。但是在離開populateList()方法之後,該清單的所有記憶體都被垃圾回收,因為已經沒有對它的任何引用。

是以我們需要注意static變量的使用。如果集合或打對象被申明為static,那會保留至整個應用程式過程中,進而阻塞了本來可以在其他地方使用的重要記憶體。

如何解決

  • 最小化使用static變量
  • 使用單例模式時,使用延遲加載對象而不是急切加載的實作

2.2. 未關閉資源

當我們使用連接配接或打開流,jvm會為這些資源配置設定記憶體,如:資料庫連接配接、輸入流以及session對象。

忘記關閉這些資源會一直占用記憶體,GC無法回收。還有在關閉資源之前遇到異常也會出現這種情況。

在這兩種情況下,資源留下打開的連接配接會消耗記憶體,如果我們不處理它們,它們會降低系統性能,甚至可能導緻OutOfMemoryError異常。

如何解決

  • 總是使用finally塊關閉記憶體
  • 關閉資源的代碼塊(即使在finally塊中)也不應該有任何異常。
  • 可以使用java7中的try-with-resource塊自動關閉資源

2.3. 不正确實作equals()和hashCode()方法

當定義類時,一個很常見的疏忽是沒有正确覆寫equals()和hashCode()方法。HashSet 和 HashMap的很多操作都需要使用這兩個方法,如果沒有正确實作,可能産生潛在的記憶體洩漏錯誤。

讓我們以一個簡單的Person類為例,并将其用作HashMap中的鍵:

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

現在,我們将重複的Person對象作為key插入至Map中,Map不能包括重複的key:

@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不允許有重複key,大量重複的person對象不會增加記憶體。但因為沒有正确定義equals()方法,重複對象卻增加了記憶體,實際在記憶體中不隻一個對象,Heap Memory 圖示如下:

了解java 中記憶體洩漏

如果我們正确地覆寫了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);
}
           

正确地覆寫了equals()和hashCode()方法,堆記憶體圖示如下:

hashCode

另外一個示例是使用如Hibernate的ORM工具,其使用equals()和hashCode()方法去分析對象并儲存至緩存中。如果沒有正确覆寫equals()和hashCode()方法,記憶體洩漏的幾率很高,因為Hibernate不能正确比較對象,會把重複的對象填充至緩存中。

如何解決

  • 作為重要經驗,在定義新類時總需要覆寫equals()和hashCode()方法
  • 不僅要覆寫這些方法,還必須以最佳的方式覆寫這些方法

2.4. 内部類引用外部類

這種情況主要這對非static内部類(匿名類),這些内部類總是需要一個封閉類的執行個體。每個非靜态内部類預設都有一個隐式引用至其容器類。如果我們在應用中使用這個内部類對象,那麼即使容器類對象出了作用域,也不會被GC回收。

考慮一個類,它包含對許多大對象的引用,并且有一個非靜态的内部類。現在當我們建立一個内部類的對象時,記憶體模型看起來是這樣的:

了解java 中記憶體洩漏

如果我們申明内部類為static,那麼相同的記憶體模型為:

了解java 中記憶體洩漏

這是因為内部類對象隐式保持外部類對象的引用,是以GC不能回收,對匿名内部類也一樣。

如何解決

  • 如果内部類不需要通路外部類成員,最好定義為static内部類

2.5. finalize()方法

使用finalize() 方法也是造成記憶體洩漏的潛在原因之一。重寫類的finalize()方法時,該類的對象不會立即被垃圾回收。相反,GC将它們排隊等待稍後某個時間點執行。

此外,如果用finalize()方法編寫的代碼不是最優的,如果finalizer隊列跟不上Java垃圾收集器的速度,那麼我們的應用程式遲早會遇到OutOfMemoryError異常。

為了示範,我們定義類并重寫finalize()方法,方法内使用sleep模拟需要一定時間才能執行完成。那麼該類的當大量對象需要回收時,那麼記憶體會類似這樣:

了解java 中記憶體洩漏

如果删除finalize()方法,同樣記憶體圖示如下:

了解java 中記憶體洩漏

如何解決

  • 盡量避免覆寫finalize()方法

2.6. intern字元串

在Java 7中帶來了重大變化,Java字元串池從PermGen轉移到HeapSpace。但是對于在版本6及以下運作的應用程式,我們在處理大字元串時應該加以注意。

如果我們讀取一個巨大的字元串對象,并在該對象上調用intern(),那麼它将進入位于PermGen(永久記憶體)中的字元串池,并在應用程式運作時一直駐留。這将阻塞記憶體回收并造成主要的記憶體洩漏。

在JVM 1.6 的PermGen 記憶體圖示:

了解java 中記憶體洩漏

相反,如果從檔案中讀字元串,當不調用intern方法,那麼記憶體圖示如下:

了解java 中記憶體洩漏

如何解決

  • 最簡單方式是更新至java最新版本,從java7開始字元串池已經遷移至堆記憶體
  • 遇到大字元串對象,可以增加PermGen記憶體空間大小,避免潛在的OutOfMemoryError異常

2.7. 使用ThreadLocal

ThreadLocal能夠隔離特定線程狀态并實作線程安全。當使用ThreadLocal時,每個線程會隐式引用ThreadLocal變量的拷貝,并維護自己的拷貝,避免多個活動線程共享資源。

盡管ThreadLocal有其優點,但是它的使用是有争議的,因為如果使用不當,它會導緻記憶體洩漏。

一旦持有的線程不再是活動的,ThreadLocal變量應該被垃圾收集。但是,當ThreadLocal變量與現代應用伺服器一起使用時,問題就出現了。

現代應用程式使用線程池代替建立一個線程(如:Executor類),并且使用獨立的類加載器。由于應用程式中的線程池使用線程重用的概念,是以它們從來不被垃圾回收————相反,它們被重用來服務另一個請求。

如果類建立ThreadLocal變量,卻沒有顯示删除,那麼該對象拷貝将與工作線程一起保留至應用程式結束,則會阻止對象被回收,造成記憶體洩漏。

如何解決

  • 當不再使用ThreadLocal對象時,最好清除————ThreadLocal提供了remove方法,會删除該變量的目前線程值
  • 不要使用ThreadLocal.set(null)方法清除值————其沒有實際清除值,相反,其查找與目前線程關聯的Map,并将鍵-值對分别設定為目前線程和null
  • 更好的思路是把ThreadLocal作為資源,需要在finally塊中回收,確定即使遇到異常也會關閉:
try {
    threadLocal.set(System.nanoTime());
    //... further processing
}
finally {
    threadLocal.remove();
}
           

3. 其他政策處理記憶體洩漏

雖然在處理記憶體洩漏時沒有一種通用的解決方案,但是有一些方法可以有效處理洩漏。

3.1. 啟用性能分析

java性能分析工具可以監控并診斷應用程式的記憶體洩漏,它們分析應用程式内部發生的事情——例如,記憶體是如何配置設定的。使用分析工具可以比較不同方法并發現優化資源方法。前一節中一直使用Java VisualVM,其他工具還有如Mission Control, JProfiler, YourKit, Java VisualVM, and the Netbeans Profiler。

3.2. 開啟詳細GC參數

啟用詳細GC收集情況,可以跟蹤GC,通過增加JVM配置:

-verbose:gc
           

增加該參數,可以看到GC内部發生的詳細資訊:

了解java 中記憶體洩漏

3.3. 使用引用對象避免記憶體洩漏

我們也可以采用java中内置的引用對象處理記憶體洩漏,位于java.lang.ref包中,使用ref包中的引用而非直接引用對象,使它們更容易回收。

設計引用隊列目的是讓我們知道垃圾收集器執行的操作。

3.4. IDE記憶體洩漏警告

對于jdk1.5以上項目,遇到上述情況時IDE會報出警告(如Eclipse和idea),我們可以檢視警告内容并修正:

了解java 中記憶體洩漏

3.5. 基準測試

通過基準測試可以衡量和分析java代碼性能。針對實作相同功能的不同方法對比,可以幫助我們選擇最佳方法并節約記憶體。

3.6. 代碼審查

最後,我們總是使用經典的、老式的方法來執行簡單的代碼審查。在某些情況下,即使這個看起來微不足道的方法也可以幫助消除一些常見的記憶體洩漏問題。

4. 總結

用外行的術語來了解,可以将記憶體洩漏看作一種疾病,它通過占用重要記憶體資源,進而降低應用程式的性能。和所有其他疾病一樣,如果不能治愈,随着時間的推移,它可能會導緻緻命的程式崩潰。

記憶體洩漏很難解決,找到它們需要對Java語言相當精通和掌握。在處理記憶體洩漏時,沒有适用于所有情況的解決方案,因為洩漏可能通過各種各樣的情況發生。

然而,如果我們采用最佳實踐并定期執行嚴格的代碼測試和性能分析,那麼我們可以将應用程式中記憶體洩漏的風險降到最低。