Java記憶體洩漏

時間漏斗發表於2020-12-06

Java當中的記憶體洩漏

1. 什麼是記憶體洩漏?

記憶體洩漏 :物件已經沒有被應用程式使用,但是垃圾回收器沒辦法移除它們,因為還在被引用著。
在Java中,記憶體洩漏就是存在一些被分配的物件,這些物件有下面兩個特點,首先,這些物件是可達的,即在有向圖中,存在通路可以與其相連;其次,這些物件是無用的,即程式以後不會再使用這些物件。如果物件滿足這兩個條件,這些物件就可以判定為Java中的記憶體洩漏,這些物件不會被GC所回收,然而它卻佔用記憶體。

在C++中,記憶體洩漏的範圍更大一些。有些物件被分配了記憶體空間,然後卻不可達,由於C++中沒有GC(Garbage Collection垃圾回收),這些記憶體將永遠收不回來。在Java中,這些不可達的物件都由GC負責回收,因此程式設計師不需要考慮這部分的記憶體洩露。

通過分析,我們得知,對於C++,程式設計師需要自己管理邊和頂點,而對於Java程式設計師只需要管理邊就可以了(不需要管理頂點的釋放)。通過這種方式,Java提高了程式設計的效率。
C++與Java當中的記憶體洩漏
因此,通過以上分析,我們知道在Java中也有記憶體洩漏,但範圍比C++要小一些。因為Java從語言上保證,任何物件都是可達的,所有的不可達物件都由GC管理。

對於程式設計師來說,GC基本是透明的,不可見的。雖然,我們只有幾個函式可以訪問GC,例如執行GC的函式System.gc(),但是根據Java語言規範定義, 該函式不保證JVM的垃圾收集器一定會執行。因為,不同的JVM實現者可能使用不同的演算法管理GC。通常,GC的執行緒的優先順序別較低。JVM呼叫GC的策略也有很多種,有的是記憶體使用到達一定程度時,GC才開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。但通常來說,我們不需要關心這些。除非在一些特定的場合,GC的執行影響應用程式的效能,例如對於基於Web的實時系統,如網路遊戲等,使用者不希望GC突然中斷應用程式執行而進行垃圾回收,那麼我們需要調整GC的引數,讓GC能夠通過平緩的方式釋放記憶體,例如將垃圾回收分解為一系列的小步驟執行,Sun提供的HotSpot JVM就支援這一特性。

下面給出一個 Java 記憶體洩漏的典型例子,

Vector v = new Vector(10);

for (int i = 0; i < 100; i++) {
    Object o = new Object();
    v.add(o);
    o = null;
}

在這個例子中,我們迴圈申請Object物件,並將所申請的物件放入一個 Vector 中,如果我們僅僅釋放引用本身,那麼 Vector 仍然引用該物件,所以這個物件對 GC 來說是不可回收的。因此,如果物件加入到Vector 後,還必須從 Vector 中刪除,最簡單的方法就是將 Vector 物件設定為 null。

v = null

要想理解這個定義,我們需要先了解一下物件在記憶體中的狀態。下面的這張圖就解釋了什麼是無用物件以及什麼是未被引用物件。
記憶體洩漏示意圖
上面圖中可以看出,裡面有被引用物件和未被引用物件。未被引用物件會被垃圾回收器回收,而被引用的物件卻不會。未被引用的物件當然是不再被使用的物件,因為沒有物件再引用它。然而無用物件卻不全是未被引用物件。其中還有被引用的。就是這種情況導致了記憶體洩漏。

2. 詳細Java中的記憶體洩漏

2.1 Java記憶體回收機制

不論哪種語言的記憶體分配方式,都需要返回所分配記憶體的真實地址,也就是返回一個指標到記憶體塊的首地址。Java中物件是採用new或者反射的方法建立的,這些物件的建立都是在堆(Heap)中分配的,所有物件的回收都是由Java虛擬機器通過垃圾回收機制完成的。GC為了能夠正確釋放物件,會監控每個物件的執行狀況,對他們的申請、引用、被引用、賦值等狀況進行監控,Java會使用有向圖的方法進行管理記憶體,實時監控物件是否可以達到,如果不可到達,則就將其回收,這樣也可以消除引用迴圈的問題。在Java語言中,判斷一個記憶體空間是否符合垃圾收集的標準有兩個:一個是給物件賦予了空值null,以下再沒有呼叫過另一個是給物件賦予了新值,這樣重新分配了記憶體空間。

2.2 Java記憶體洩漏引起的原因

記憶體洩漏是指無用物件(不再使用的物件)持續佔有記憶體或無用物件的記憶體得不到及時釋放,從而造成記憶體空間的浪費稱為記憶體洩漏。記憶體洩露有時不嚴重且不易察覺,這樣開發者就不知道存在記憶體洩露,但有時也會很嚴重,會提示你Out of memory。

Java記憶體洩漏的根本原因是什麼呢?長生命週期的物件持有短生命週期物件的引用就很可能發生記憶體洩漏,儘管短生命週期物件已經不再需要,但是因為長生命週期持有它的引用而導致不能被回收,這就是Java中記憶體洩漏的發生場景。

來先看看下面的例子,為什麼會發生記憶體洩漏。下面這個例子中,A物件引用B物件,A物件的生命週期(t1-t4)比B物件的生命週期(t2-t3)長的多。當B物件沒有被應用程式使用之後,A物件仍然在引用著B物件。這樣,垃圾回收器就沒辦法將B物件從記憶體中移除,從而導致記憶體問題,因為如果A引用更多這樣的物件,那將有更多的未被引用物件存在,並消耗記憶體空間。

B物件也可能會持有許多其他的物件,那這些物件同樣也不會被垃圾回收器回收。所有這些沒在使用的物件將持續的消耗之前分配的記憶體空間。

生命週期圖

記憶體洩漏的原因如下

2.2.1 靜態集合類引起記憶體洩漏

像HashMap、Vector等的使用最容易出現記憶體洩露,這些靜態變數的生命週期和應用程式一致,他們所引用的所有的物件Object也不能被釋放,因為他們也將一直被Vector等引用著。

Static Vector v = new Vector(10);

for (int i = 0; i < 100; i++) {
    Object o = new Object();
    v.add(o);
    o = null;
}

在這個例子中,迴圈申請Object 物件,並將所申請的物件放入一個Vector 中,如果僅僅釋放引用本身(o=null),那麼Vector 仍然引用該物件,所以這個物件對GC 來說是不可回收的。因此,如果物件加入到Vector 後,還必須從Vector 中刪除,最簡單的方法就是將Vector物件設定為null。

2.2.2 監聽器

在 java 程式設計中,我們都需要和監聽器打交道,通常一個應用當中會用到很多監聽器,我們會呼叫一個控制元件的諸如addXXXListener() 等方法來增加監聽器,但往往在釋放物件的時候卻沒有記住去刪除這些監聽器,從而增加了記憶體洩漏的機會。

2.2.3 各種連線

比如資料庫連線(dataSourse.getConnection()),網路連線(socket)和io連線,除非其顯式的呼叫了其close() 方法將其連線關閉,否則是不會自動被GC 回收的。對於Resultset 和Statement 物件可以不進行顯式回收,但Connection 一定要顯式回收,因為Connection 在任何時候都無法自動回收,而Connection一旦回收,Resultset 和Statement 物件就會立即為NULL。但是如果使用連線池,情況就不一樣了,除了要顯式地關閉連線,還必須顯式地關閉Resultset Statement 物件(關閉其中一個,另外一個也會關閉),否則就會造成大量的Statement 物件無法釋放,從而引起記憶體洩漏。這種情況下一般都會在try 裡面去的連線,在finally裡面釋放連線。

2.2.4 內部類和外部模組的引用

內部類的引用是比較容易遺忘的一種,而且一旦沒釋放可能導致一系列的後繼類物件沒有釋放。此外程式設計師還要小心外部模組不經意的引用,例如程式設計師A 負責A 模組,呼叫了B 模組的一個方法如:

public void registerMsg(Object b);

這種呼叫就要非常小心了,傳入了一個物件,很可能模組B就保持了對該物件的引用,這時候就需要注意模組B是否提供相應的操作去除引用。

2.2.5 單例模式

不正確使用單例模式是引起記憶體洩漏的一個常見問題,單例物件在初始化後將在 JVM 的整個生命週期中存在(以靜態變數的方式),如果單例物件持有外部的引用,那麼這個物件將不能被 JVM 正常回收,導致記憶體洩漏,考慮下面的例子:

public class A {
    public A() {
        B.getInstance().setA(this);
    }
    ...
}

//B類採用單例模式
class B{
    private A a;
    private static B instance = new B();
    
    public B(){}
    
    public static B getInstance() {
        return instance;
    }
    
    public void setA(A a) {
        this.a = a;
    }

    public A getA() {
        return a;
    }
}

3. Java 記憶體分配策略

Java 程式執行時的記憶體分配策略有三種,分別是靜態分配,棧式分配,和堆式分配,對應的,三種儲存策略使用的記憶體空間主要分別是靜態儲存區(也稱方法區)、棧區和堆區。

靜態儲存區(方法區):主要存放靜態資料、全域性 static 資料和常量。這塊記憶體在程式編譯時就已經分配好,並且在程式整個執行期間都存在。

棧區 :當方法被執行時,方法體內的區域性變數(其中包括基礎資料型別、物件的引用)都在棧上建立,並在方法執行結束時這些區域性變數所持有的記憶體將會自動被釋放。因為棧記憶體分配運算內建於處理器的指令集中,效率很高,但是分配的記憶體容量有限。

堆區 : 又稱動態記憶體分配,通常就是指在程式執行時直接 new 出來的記憶體,也就是物件的例項。這部分記憶體在不使用時將會由 Java 垃圾回收器來負責回收。

3.1 棧與堆的區別

在方法體內定義的(區域性變數)一些基本型別的變數和物件的引用變數都是在方法的棧記憶體中分配的。當在一段方法塊中定義一個變數時,Java 就會在棧中為該變數分配記憶體空間,當超過該變數的作用域後,該變數也就無效了,分配給它的記憶體空間也將被釋放掉,該記憶體空間可以被重新使用。

堆記憶體用來存放所有由 new 建立的物件(包括該物件其中的所有成員變數)和陣列。在堆中分配的記憶體,將由 Java 垃圾回收器來自動管理。在堆中產生了一個陣列或者物件後,還可以在棧中定義一個特殊的變數,這個變數的取值等於陣列或者物件在堆記憶體中的首地址,這個特殊的變數就是我們上面說的引用變數。我們可以通過這個引用變數來訪問堆中的物件或者陣列。

舉個例子:

public class Sample {
    int s1 = 0;
    Sample mSample1 = new Sample();
    
    public void method() {
        int s2 = 1;
        Sample mSample2 = new Sample();
    }
}
Sample mSample3 = new Sample();

Sample 類的區域性變數 s2 和引用變數 mSample2 都是存在於棧中,但 mSample2 指向的物件是存在於堆上的。

mSample3 指向的物件實體存放在堆上,包括這個物件的所有成員變數 s1 和 mSample1,而它自己存在於棧中。

結論:
區域性變數的基本資料型別和引用儲存於棧中,引用的物件實體儲存於堆中。—— 因為它們屬於方法中的變數,生命週期隨方法而結束。
成員變數全部儲存於堆中(包括基本資料型別,引用和引用的物件實體)—— 因為它們屬於類,類物件終究是要被new出來使用的。

3.2 Java如何管理記憶體

Java的記憶體管理就是物件的分配和釋放問題。在 Java 中,程式設計師需要通過關鍵字 new 為每個物件申請記憶體空間 (基本型別除外),所有的物件都在堆 (Heap)中分配空間。另外,物件的釋放是由 GC 決定和執行的。在 Java 中,記憶體的分配是由程式完成的,而記憶體的釋放是由 GC 完成的,這種收支兩條線的方法確實簡化了程式設計師的工作。但同時,它也加重了JVM的工作。這也是 Java 程式執行速度較慢的原因之一。因為GC 為了能夠正確釋放物件,GC 必須監控每一個物件的執行狀態,包括物件的申請、引用、被引用、賦值等,GC 都需要進行監控。

監視物件狀態是為了更加準確地、及時地釋放物件,而釋放物件的根本原則就是該物件不再被引用。

為了更好理解 GC 的工作原理,我們可以將物件考慮為有向圖的頂點,將引用關係考慮為圖的有向邊,有向邊從引用者指向被引物件。另外,每個執行緒物件可以作為一個圖的起始頂點,例如大多程式從 main 程式開始執行,那麼該圖就是以 main 程式頂點開始的一棵根樹。在這個有向圖中,根頂點可達的物件都是有效物件,GC將不回收這些物件。如果某個物件 (連通子圖)與這個根頂點不可達(注意,該圖為有向圖),那麼我們認為這個(這些)物件不再被引用,可以被 GC 回收。

以下,我們舉一個例子說明如何用有向圖表示記憶體管理。對於程式的每一個時刻,我們都有一個有向圖表示JVM的記憶體分配情況。以下右圖,就是左邊程式執行到第6行的示意圖。

public class Test {

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        Object o1 = new Object();
        Object o2 = new Object();
        o2 = o1;//此行為第6行
    }
}

有向圖

4. 如何防止記憶體洩漏的發生?

在瞭解了引起記憶體洩漏的一些原因後,應該儘可能地避免和發現記憶體洩漏。

4.1 好的編碼習慣

最基本的建議就是儘早釋放無用物件的引用,大多數程式設計師在使用臨時變數的時候,都是讓引用變數在退出活動域後,自動設定為 null 。在使用這種方式時候,必須特別注意一些複雜的物件圖,例如陣列、列、樹、圖等,這些物件之間有相互引用關係較為複雜。對於這類物件,GC 回收它們一般效率較低。如果程式允許,儘早將不用的引用物件賦為null。另外建議幾點:

在確認一個物件無用後,將其所有引用顯式的置為null;

當類從 Jpanel 或 Jdialog 或其它容器類繼承的時候,刪除該物件之前不妨呼叫它的 removeall() 方法;在設一個引用變數為 null 值之前,應注意該引用變數指向的物件是否被監聽,若有,要首先除去監聽器,然後才可以賦空值;當物件是一個 Thread 的時候,刪除該物件之前不妨呼叫它的
interrupt() 方法;記憶體檢測過程中不僅要關注自己編寫的類物件,同時也要關注一些基本型別的物件,例如:int[]、String、char[] 等等;如果有資料庫連線,使用 try…finally 結構,在 finally 中關閉 Statement 物件和連線。

4.2 好的測試工具

在開發中不能完全避免記憶體洩漏,關鍵要在發現有記憶體洩漏的時候能用好的測試工具迅速定位問題的所在。市場上已有幾種專業檢查 Java 記憶體洩漏的工具,它們的基本工作原理大同小異,都是通過監測 Java 程式執行時,所有物件的申請、釋放等動作,將記憶體管理的所有資訊進行統計、分析、視覺化。開發人員將根據這些資訊判斷程式是否有記憶體洩漏問題。這些工具包括 Optimizeit Profiler、JProbe Profiler、JinSight、Rational 公司的 Purify 等。

4.3 注意像 HashMap 、ArrayList 的集合物件

特別注意一些像 HashMap 、ArrayList 的集合物件,它們經常會引發記憶體洩漏。當它們被宣告為 static 時,它們的生命週期就會和應用程式一樣長。

4.4 注意 事件監聽 和 回撥函式

特別注意 事件監聽 和 回撥函式 。當一個監聽器在使用的時候被註冊,但不再使用之後卻未被反註冊。

“如果一個類自己管理記憶體,那開發人員就得小心記憶體洩漏問題了。” 通常一些成員變數引用其他物件,初始化的時候需要置空。

原文連結:
連結:https://www.jianshu.com/p/54b5da7c6816

相關文章