深入理解Java中的內(nèi)存泄漏(譯)

原文地址

1. 介紹

使用內(nèi)建的垃圾收集器(或者是短暫的GC)來進行內(nèi)存自動管理是使用Java的核心好處之一,GC機制在后臺自動進行內(nèi)存分配和回收,因此能夠?qū)Υ蟛糠謨?nèi)存泄漏的情況進行處理。

盡管GC機制能夠高效地管理一部分內(nèi)存,但并不意味著它能簡化內(nèi)存泄漏的處理。GC很智能, 但并不是萬能的。就處是一個細致的開發(fā)者寫的應(yīng)用也有可能出現(xiàn)內(nèi)存泄漏。

有些情況下應(yīng)用程序會產(chǎn)生大量的多余對象,導(dǎo)致占用了很多內(nèi)存資源,甚至?xí)?dǎo)致應(yīng)用程序的崩潰。

內(nèi)存在Java中是一個真正的問題,在本篇教程中我們可以看到產(chǎn)生內(nèi)存泄漏的場景,怎樣在運行時發(fā)現(xiàn)它們以及怎么在程序中去處理這些問題。

2. 什么是內(nèi)存泄漏

內(nèi)存泄漏就是某些場景下有些對象在堆中不再被用到,但是垃圾收集器并不能回收它們,因此它們沒有被合理的管理。

內(nèi)存泄漏不僅會占用內(nèi)存資源而且隨著時間推移還會影響程序的性能,如果對其不采取任何措施,最終會耗盡系統(tǒng)資源產(chǎn)生 java.lang.OutOfMemoryError異常導(dǎo)致程序終止。

在堆內(nèi)存中有被引用和無引用兩種類型的對象,被引用的對象在程序中會有有效的引用指向,無引用對象則沒有。

垃圾回收器能夠回收階段性地回收沒有被引用的對象,但是并不會回收那些被引用的資源, 這也是內(nèi)存泄漏出現(xiàn)的根本原因。

image-20190131193807625

內(nèi)存泄漏的表現(xiàn)

  • 應(yīng)用在長期運行期間出現(xiàn)嚴重的性能降級
  • 應(yīng)用中出現(xiàn)OutOfMemoryError堆內(nèi)存錯誤
  • 自發(fā)或者是莫名其妙的應(yīng)用崩潰
  • 應(yīng)用偶爾性出現(xiàn)對象連接被占滿

接下來讓我們具體看看這些場景以及如何去應(yīng)對。

3. Java中內(nèi)存泄漏的類型

在任何應(yīng)用中,內(nèi)存泄漏的出現(xiàn)都有若干的可能。這里我們會討論經(jīng)常會出現(xiàn)的場景。

3.1 靜態(tài)字段導(dǎo)致的內(nèi)存泄漏

第一個常見出現(xiàn)內(nèi)存泄漏的場景就是大量使用靜態(tài)變量。

Java中靜態(tài)字段會有和運行中應(yīng)用程序同樣長的生命周期(除非是類加載器被垃圾回收器回收)。

下面這段代碼就用了一個靜態(tài)的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");
    }
}

現(xiàn)在如果我們在這段程序運行時分析堆內(nèi)存的使用情況,我們會發(fā)現(xiàn)在points 1和points 2之間堆內(nèi)存的使用會增加。

然后執(zhí)行完方法populateList運行到Points 3是使用的內(nèi)存并沒有被回收,通過VisualVM可以看到下圖:

image-20190131195332463

然而如果我們?nèi)サ羯隙未a中第2行的static修飾關(guān)鍵字,內(nèi)存使用情況會出現(xiàn)很大的改觀,通過VisualVM可以看到:

image-20190131195537792

通過兩幅圖的對比我們可以看到代碼的前半部分執(zhí)行情況基本一樣,但是去掉static關(guān)鍵字后當程序執(zhí)行完populateList方法,list占用的內(nèi)存由于沒有任何引用因此全部被垃圾回收器回收了。

因此我們對使用靜態(tài)變量應(yīng)要非常小心。如果集合或者大對象被static關(guān)鍵字修飾,那么在應(yīng)用的整個生命周期中它們都會被保存在內(nèi)存中,導(dǎo)致占用了其它地方需要用到的寶貴內(nèi)存。

那么如何避免這種情況?

  • 減少靜態(tài)變量的使用
  • 當使用單例時,采用懶加載來迭代提前加載

3.2 沒有被關(guān)閉的資源

當我們打開一個連接或者是創(chuàng)建一個流是地,JVM會給這些資源分配內(nèi)存。例如數(shù)據(jù)庫的連接,輸入i流和session對象。

如果忘了關(guān)閉這些資源將會一直占用系統(tǒng)內(nèi)存,導(dǎo)致GC不能回收這些對象。甚至在現(xiàn)在異常時也會出現(xiàn),因為程序會因為拋出異常直接跳過關(guān)閉資源的代碼。

在某些場景下,打開的資源連接會占用著內(nèi)存,如果我們不對其進行處理,會嚴重影響性能甚至導(dǎo)致OutOfMemoryError異常。

那么如何避免這種情況:

  • 必須使用finally塊來關(guān)閉資源
  • 關(guān)閉資源的代碼塊(即使是finally代碼塊)自身不能拋出任何異常
  • 如果使用Java 7 以一的版本,可以使用try-with-resources代碼塊

3.3 對equals和hashCode進行了不恰當?shù)膶崿F(xiàn)

當我們定義一個新類時,一種常見的問題就是不恰當?shù)刂貙慹quals和hashCode方法。

對HashSet和HashMap的許多操作都會用到這些方法,如果我們不正常地重寫了它們,也有可能導(dǎo)致內(nèi)存泄漏。

那么我們以Person類作為示例,并且將其作為HashMap的一個key:

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

接下來往以Person作為key的Map中插入重復(fù)的Person對象

注意Map中并不能有重復(fù)的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作為Map的key,由于Map不允許重復(fù)的key,因此重復(fù)的我們插入重復(fù)的Person不應(yīng)該增加內(nèi)存的占用。

但是因為我們沒有定義合適的equals方法,這些重復(fù)的對象累積起來導(dǎo)致內(nèi)存的增加,因此我們在內(nèi)存中不只看到一個對象。VisualVM的堆內(nèi)存使用情況如下:

image-20190131202229689

然而如果我們對equals和hashCode方法進行了恰當?shù)闹貙懀敲丛贛ap中只會存在一個Person對象。

那么接下來就看看恰當?shù)膃quals和hashCode重寫應(yīng)該是怎樣的:

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代碼進行了合適的重寫后,同樣的程序執(zhí)行堆內(nèi)存占用情況會如下:

image-20190131202639392

另外一個例子就是使用ORM工具如Hibernate,會用equals和hashCode方法來分析對象

并且將其保存在緩存中。

如果沒有對這些方法進行恰當?shù)闹貙懢秃苡锌赡軐?dǎo)致內(nèi)存泄漏,因為Hibernate就不能區(qū)別這些對象從而在緩存中保存了重復(fù)的對象。

那么如何避免這種情況?

  • 當定義一個實體類時,第一件事就是要重寫equals的hashCode方法
  • 不僅要重寫,而且要進行恰當?shù)闹貙?/li>

更多的信息可以閱讀教程 Generate equals() and hashCode() with EclipseGuide to hashCode() in Java.

3.4 內(nèi)部類引用外部類

在非靜態(tài)類(匿名類)中會出現(xiàn)這種情況。在初始化時,這些內(nèi)部類總是需要一個完整類的實例。

在默認情況下,非靜態(tài)內(nèi)部類對其外部類會有隱式的引。當在應(yīng)用程序中用這些內(nèi)存類時,即使引用外部類的對象已經(jīng)失效了,但并不會被垃圾回收。

當一個類引用了有許多大對象并且有一個非靜態(tài)內(nèi)部類,就算只是創(chuàng)建一個內(nèi)部類,內(nèi)存占用情況會如下:

image-20190131204345455

然而我們只是需要將這個內(nèi)部類聲明為靜態(tài)的內(nèi)存占用情況就會如下:

image-20190131204509626

出現(xiàn)這種情況的原因是因為內(nèi)部類持有對外部類的引用, 從而導(dǎo)致垃圾回收器不能回收外部類。在匿名類中同樣會出現(xiàn)這樣的情況。

那么如何避免這種情況:

  • 如果內(nèi)部類不需要用到外部類的成員對象,考慮將其改為靜態(tài)類

3.5 使用finalize方法

當使用finalize方法時也會產(chǎn)生內(nèi)存泄漏。任何時候當對象的finalize方法被調(diào)用時,垃圾回收器并不會立即回收它而是將其放入到回收隊列,等到合適時機才回收。

除此之外,如果重寫的finalize方法并不是最佳導(dǎo)致finalizer隊列跟不上垃圾回收器的速度,或早或遲會導(dǎo)致程序出現(xiàn)OutOfMemoryError。

為了演示這種情況,我們可以重寫一個對象的finalize方法并且在該方法的執(zhí)行需要一定的時間。當大量持有該類的對象被垃圾回收時,在VisualVM中表現(xiàn)如下:

image-20190131205545337

然而當我們?nèi)サ糁貙懙膄inalize方法后同樣的程序表現(xiàn)如下:

image-20190131205637710

那么如何避免這種情況?

  • 盡量避免重寫finalize方法

更多的信息可能閱讀Guide to the finalize Method in Java

3.6 Interned 字符串

在Java7中Java 字符串常量池從永久代移到了堆空間中,但是對時使用java6以及更低版本的應(yīng)用來說,我們在使用大量字符串時要非常小心。

當我們讀取大量的長字符串并且調(diào)用intern方法,那么這些字符串就會被放到永久代的字符串常量池,只要程序在運行它將會一直存在。這將占用很多內(nèi)存并且導(dǎo)致內(nèi)存泄漏。

在java1.6中永久代的使用情況通過VisualVM觀察如下:

image-20190131210408433

相對這種情況,如果我們只是從一個文件中讀取字符串而不調(diào)用intern方法,那么永久代的使用情況就是這樣:

image-20190131210548663

那么如何避免這種情況?

  • 最簡單的方法就是將java升級到7及以上的版本,因為將字符串常量池移到了堆區(qū)
  • 如果使用了大量了字符串,那么就可以通過增加永久代的大小來避免OutOfMemoryErrors

-XX:MaxPermSize=512m

3.7 使用本地線程變量

通過使用ThreadLocal (更多可閱讀 Introduction to ThreadLocal in Java 教程) 本地線程變量可以對線程進行隔離從而達到線程安全的目的。

當使用本地線程變量時,每個線程在存活期間都會持有一份對該變量拷貝的引用并且會自己維護這份拷貝,而不是在多線程之間共享。

盡管使用ThreadLocal有如此大的好處,但是卻有很多爭論的,因為如果使用不當很容易導(dǎo)致內(nèi)存泄漏。Joshua Bloch對ThreadLocal的使用作過如下評論:

在許多地方都寫到,過于分散的線程池使用和過于分散的本地線程變量使用會導(dǎo)致意想不到對象存留。但歸罪于本地線程亦是是莫須有的罪名。

ThreadLocal中的內(nèi)存泄漏

當線程不再存活時,那么它引用的ThreadLocal對象也會被垃圾回收。但是當今應(yīng)用服務(wù)器的使用導(dǎo)致ThreadLocal的使用出現(xiàn)了問題。

當今服務(wù)器應(yīng)用通過使用線程池來傳遞請求而不是創(chuàng)建新的線程(比如Apache Tomcat服務(wù)器就是使用Executor框架)。此外,它們使用獨立的類加載器。

由于線程池采取的是線程復(fù)用的理念,因此它們從不會被垃圾回收,而是被其它的請求復(fù)用。

這種情況下如果任何一個類創(chuàng)建了一個ThreadLocal變量但沒有顯示移除它,那么這個對象的拷貝就會一直被工作線程持有直到應(yīng)用程序終止,導(dǎo)致對應(yīng)不能被垃圾回收器回收。

那么如何避免這種情況?

  • ThreadLocal提供了remove方法,該方法會移除當前線程對它的拷貝。所以養(yǎng)成當ThreadLocal對象不再使用就清除它的好習(xí)慣。

  • 不要使用 ThreadLocal.set(null) 來清理值。因為它不會清理這個合二為一而是將ThreadLocalMap中的kv分別設(shè)置為空

  • 更好的解決辦法可以考慮將ThreadLocal作為一個資源對象將釋放代碼放在finally塊中從而保證總是能被回收,即使發(fā)生異常:

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

4. 處理內(nèi)存泄漏的其它策略

盡管對處理內(nèi)存泄漏沒有能用的方法,但不是有一些方式可以減少這些泄漏。

4.1 啟用分析器

Java分析器就是一些監(jiān)控和診斷應(yīng)用內(nèi)存泄漏的工具。通過它可以分析我們應(yīng)用程序內(nèi)存的運行情況, 比如說內(nèi)存的分配。

通過使用分析器,我們可以對比不同的情形從而對資源進行最佳的使用。

在本文第3部分中我們使用了Java VisualVM.可以閱讀 Guide to Java Profilers這篇文章來學(xué)更多的分析器,如Mission Control, JProfiler, YourKit, Java VisualVM, 和the Netbeans Profiler.

4.2 打印詳細的垃圾回收情況

通過打印增援回收情況, 我們可以追蹤GC的具體情況。通過使用如下JVM參數(shù)即可:

-verbose:gc

加上這個參數(shù)后,我們就可以看到GC的具體情況:

image-20190131215507630

4.3 使用引用對象來避免內(nèi)存泄漏

通過使用java.lang.ref 包中的一些類而不是直接引用對象,使用不同的引用類型讓它們能更好的被垃圾回收。引用隊列的設(shè)計就是讓我們知道我們引用的對象是否被回收了,更多信息可以閱讀Soft References in Java

4.4 Eclipse內(nèi)存泄漏的警告

對于使用JDK 1.5及以上版本的應(yīng)用, Eclipse在我們程序出現(xiàn)明顯的內(nèi)存泄漏時會顯示警告和錯誤。因此,當使用Eclipse進行開發(fā)時我們要多關(guān)注"Problems"標簽頁并且對內(nèi)存泄漏警告(如果有的話)更加警惕。

image-20190131215937615

4.5 Benchmarking

通過執(zhí)行benchmark我們可以衡量和分析Java代碼的性能。這種方式我們可以對同一任務(wù)的不同實現(xiàn)進行對比, 從而幫助我們選擇更好的方式并且節(jié)約內(nèi)存。

可以閱讀Microbenchmarking with Java 教程獲取關(guān)于benchmarking的更多資料。

5. 總結(jié)

用外行人的話來說,我們可以將內(nèi)存泄漏當作占用重要內(nèi)存資源從而導(dǎo)致應(yīng)用程序性能下降的一種疾病,像其它疾病一樣,如果沒有治愈它,那么隨著時間推移會導(dǎo)致應(yīng)用以崩潰而失敗。

使用Java時找到并解決內(nèi)存泄漏需要很高的技巧和豐富的經(jīng)驗,在很多情況下都會現(xiàn)在泄漏,因此并沒有一種通過的方法來處理內(nèi)存泄漏。

然而,如果通過采用分析等手段以使用最佳的方式和執(zhí)行嚴格的代碼測試,我們就可以減少應(yīng)用中的內(nèi)存泄漏。

一如既往,本文中產(chǎn)生VisualVM效果圖的代碼在GitHub都可以找到。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 第二部分 自動內(nèi)存管理機制 第二章 java內(nèi)存異常與內(nèi)存溢出異常 運行數(shù)據(jù)區(qū)域 程序計數(shù)器:當前線程所執(zhí)行的字節(jié)...
    小明oh閱讀 1,275評論 0 2
  • 九種基本數(shù)據(jù)類型的大小,以及他們的封裝類。(1)九種基本數(shù)據(jù)類型和封裝類 (2)自動裝箱和自動拆箱 什么是自動裝箱...
    關(guān)瑋琳linSir閱讀 2,049評論 0 47
  • 一、運行時數(shù)據(jù)區(qū)域 Java虛擬機管理的內(nèi)存包括幾個運行時數(shù)據(jù)內(nèi)存:方法區(qū)、虛擬機棧、本地方法棧、堆、程序計數(shù)器,...
    加油小杜閱讀 1,584評論 1 15
  • 一、運行時數(shù)據(jù)區(qū)域 Java虛擬機管理的內(nèi)存包括幾個運行時數(shù)據(jù)內(nèi)存:方法區(qū)、虛擬機棧、本地方法棧、堆、程序計數(shù)器,...
    luhanlin閱讀 602評論 0 0
  • 什么是真正的善?讓人舒服是嗎?當然不能怎么舒服怎么來吧。有些善令人當下想當不舒服。否則,文殊菩薩沒必要以大威德金剛...
    嘉媽閱讀 288評論 0 2

友情鏈接更多精彩內(nèi)容