天天看點

大型網際網路系統如何排查JAVA記憶體溢出、洩露的情況?

作者:IT技術範

最近公司系統經常會出現記憶體溢出和當機的情況,我公司的系統是分布式架構的部署,按照業務架構進行拆分多個微服務,其中有一個核心業務架構伺服器經常出現卡死和記憶體溢出情況。通過對記憶體基礎知識的了解,下面帶大家了解一下JAVA記憶體的基本知識。

一、記憶體溢出

1、簡述

java doc 中對 Out Of Memory Error 的解釋是,沒有空閑記憶體,并且垃圾收集器也無法提供更多記憶體。

JVM 提供的記憶體管理機制和自動垃圾回收極大的解放了使用者對于記憶體的管理,由于 GC(垃圾回收)一直在發展,所有一般情況下,除非應用程式占用的記憶體增長速度非常快,造成垃圾回收已經跟不上記憶體消耗的速度,否則不太容易出現記憶體洩漏和記憶體溢出問題。但是基本不會出現并不等于不會出現,是以掌握 Java 記憶體模型原理和學會分析出現的記憶體溢出或記憶體洩漏仍然十分重要。

大多數情況下,GC 會進行各種年齡段的垃圾回收,實在不行了就放大招,來一次獨占式的 Full GC 操作,這時候會回收大量的記憶體,供應用程式繼續使用。

在抛出 OutofMemoryError 之前,通常垃圾收集器會被觸發,盡其所能去清理出空間。例如:在引用機制分析中,涉及到 JVM 會去嘗試回收軟引用指向的對象等。在 java.nio.BIts.reserveMemory() 方法中,System.gc() 會被調用,以清理空間。

當然,也不是在任何情況下垃圾收集器都會被觸發的。比如,配置設定了一個超大對象,類似一個超大數組超過堆的最大值,JVM 可以判斷出垃圾收集并不能解決這個問題,是以直接抛出 OutofMemoryError。

大型網際網路系統如何排查JAVA記憶體溢出、洩露的情況?

2、記憶體溢出的常見情形

不同的記憶體溢出錯誤可能會發生在記憶體模型的不同區域,是以,需要根據出現錯誤的代碼具體分析來找出可能導緻錯誤發生的地方,并想辦法進行解決。

2.1、棧記憶體溢出(StackOverflowError)

棧記憶體可以分為虛拟機棧(VM Stack)和本地方法棧(Native Method Stack),除了它們分别用于執行 Java 方法(位元組碼)和本地方法,其餘部分原理是類似的。

以虛拟機棧為例說明,Java 虛拟機棧是線程私有的,當線程中方法被排程時,虛拟機會建立用于儲存局部變量表、操作數棧、動态連接配接和方法出口等資訊的棧幀(Stack Frame)。

具體來說,當線程執行某個方法時,JVM 會建立棧幀并壓棧,此時剛壓棧的棧幀就成為了目前棧幀。如果該方法進行遞歸調用時,JVM 每次都會将儲存了目前方法資料的棧幀壓棧,每次棧幀中的資料都是對目前方法資料的一份拷貝。如果遞歸的次數足夠多,多到棧中棧幀所使用的記憶體超出了棧記憶體的最大容量,此時 JVM 就會抛出 StackOverflowError。 總之,不論是因為棧幀太大還是棧記憶體太小,當新的棧幀記憶體無法被配置設定時,JVM 就會抛出 StackOverFlowError。

  • 優化方案:

可以通過設定 JVM 啟動參數 -Xss 參數來改變棧記憶體大小。

注:配置設定給棧的記憶體并不是越大越好,因為棧記憶體越大,線程多,留給堆的空間就不多了,容易抛出OOM。

JVM的預設參數一般情況沒有問題(包括遞歸)。

遞歸調用要控制好遞歸的層級,不要太高,超過棧的深度。

遞歸調用要防止形成死循環,否則就會出現棧記憶體溢出。

2.2、堆記憶體溢出(OutOfMemoryError:java heap space)

堆記憶體的唯一作用就是存放數組和對象執行個體,即通過 new 指令建立的對象,包括數組和引用類型。 堆記憶體溢出又分為兩種情況:

Java 虛拟機的堆記憶體設定不夠 如果堆的大小不合理(沒有顯式指定 JVM 堆大小或者指定數值偏小),對象所需記憶體太大,建立對象時配置設定空間,JVM 就會抛出 OutOfMemoryError:java heap space 異常。

  • 優化方案:

如果要處理比較可觀的資料量,可以通過修改 JVM 啟動參數 -Xms 、-Xmx 來調整。使用壓力測試來調整這兩個參數達到最優值。

盡量避免大的對象的申請,例如檔案上傳,大批量從資料庫中擷取等。

盡量分塊或者分批處理,有助于系統的正常穩定的執行。

盡量提高一次請求的執行速度,垃圾回收越早越好。

否則,大量的并發來了的時候,再來新的請求就無法配置設定記憶體了,就容易造成系統的雪崩。

2.3、堆記憶體洩露最終導緻堆記憶體溢出

當堆中一些對象不再被引用但垃圾回收器無法識别時,這些未使用的對象就會在堆記憶體空間中無限期存在,不斷的堆積就會造成記憶體洩漏。不停的堆積最終會觸發 java . lang.OutOfMemoryError。

  • 優化方案:如果發生了記憶體洩漏,則可以先找出導緻洩漏發生的對象是如何被 GC ROOT 引用起來的,然後通過分析引用鍊找到發生洩漏的地方,進行代碼優化。

2.4、永久代溢出(OutOfMemoryError:PermGen sapce)

對于老版本的 oracle JDK,因為永久代的大小是有限的,并且 JVM 對永久代垃圾回收(例如常量池回收、解除安裝不再需要的類型)非常不積極,是以當不斷添加新類型的時候,永久代出現 OutOfMemoryError 也非常多見,尤其是在運作時存在大量動态類型生成的場合;類似 intern 字元串緩存占用太多空間,也會導緻 OOM 問題,對應的異常資訊,會标記出來和永久代相關:“java.lang.OutOfMemoryError:PermGen space"。

随着中繼資料區的引入,方法區記憶體已經不再那麼窘迫,是以相應的 OOM 有所改觀,出現 OOM,異常資訊則變成了:“java.lang.OutofMemoryError:Metaspace"。

元空間記憶體溢出(OutOfMemoryError: Metaspace)

元空間的溢出,系統會抛出 java.lang.OutOfMemoryError: Metaspace 出現這個異常的問題的原因是系統的代碼非常多或引用的第三方包非常多或者通過動态代碼生成類加載等方法,導緻元空間的記憶體占用很大。

  • 優化方案:

預設情況下,元空間的大小僅受本地記憶體限制。

但是為了整機的性能,盡量還是要對該項進行設定,優化參數配置,以免造成整機的服務停機。

慎重引用第三方包 對第三方包,一定要慎重選擇,不需要的包就去掉。 這樣既有助于提高編譯打包的速度,也有助于提高遠端部署的速度。

關注動态生成類的架構 對于使用大量動态生成類的架構,要做好壓力測試,驗證動态生成的類是否超出記憶體的需求會抛出異常。

2.5、直接記憶體溢出

如果直接或間接(很多 java NIO,例如在 netty 的架構中被封裝為其他的方法)使用了 ByteBuffer 中的 allocateDirect() 方法,而又不做 clear 的時候,就會抛出 java.lang.OutOfMemoryError: Direct buffer memory 異常。

如果經常有類似的操作,可以考慮設定 JVM 參數:-XX:MaxDirectMemorySize,并及時 clear 記憶體。 建立本地線程記憶體溢出 除了堆以外的區域,無法為線程配置設定一塊記憶體區域了(線程基本隻占用堆以外的記憶體區域),要麼是記憶體本身就不夠,要麼堆的空間設定得太大了,導緻了剩餘的記憶體已經不多了,而由于線程本身要占用記憶體,是以就不夠用了。

  • 優化方案:

首先檢查作業系統是否有線程數的限制,如果使用 shell 也無法建立線程,就需要調整系統的最大可支援的檔案數。日常開發中盡量保證線程最大數的可控制的,不要随意使用可以無限制增長的線程池。

2.6、數組超限記憶體溢出

JVM 在為數組配置設定記憶體之前,會執行特定平台的檢查:配置設定的資料結構是否在此平台是可尋址的。 一般來說 java 對應用程式所能配置設定數組最大大小是有限制的,隻不過不同的平台限制有所不同,但通常在1到21億個元素之間。當應用程式試圖配置設定大于 Java 虛拟機可以支援的數組時會報 Requested array size exceeds VM limit 錯誤。

不過這個錯誤一般少見的,主要是由于 Java 數組的索引是 int 類型。 Java 中的最大正整數為 2 ^ 31 - 1 = 2,147,483,647。 并且平台特定的限制可以非常接近這個數字,例如:Jdk1.8 可以初始化數組的長度高達 2,147,483,645(Integer.MAX_VALUE-2)。若是在将數組的長度再增加 1 達到 nteger.MAX_VALUE-1 ,就會出現 OutOfMemoryError 了。

優化方案:數組長度要在平台允許的長度範圍之内。

2.8、超出交換區記憶體溢出

在 Java 應用程式啟動過程中,可以通過 -Xmx 和其他類似的啟動參數限制指定的所需的記憶體。而當 JVM 所請求的總記憶體大于可用實體記憶體的情況下,作業系統開始将内容從記憶體轉換為硬碟。

當應用程式向 JVM native heap 請求配置設定記憶體失敗并且 native heap 也即将耗盡時, JVM 會抛出Out of swap space 錯誤, 錯誤消息中包含配置設定失敗的大小(以位元組為機關)和請求失敗的原因。

優化方案:

增加系統交換區的大小。 但如果使用了交換區,性能會大大降低,不建議采用這種方式。

生産環境盡量避免最大記憶體超過系統的實體記憶體。其次,去掉系統交換區,隻使用系統的記憶體,保證應用的性能。

2.9、系統殺死程序記憶體溢出

作業系統是建立在程序的概念之上,這些程序在核心中作業,其中有一個非常特殊的程序,稱為“記憶體殺手(Out of memory killer)”。當核心檢測到系統記憶體不足時,OOM killer 被激活,檢查目前誰占用記憶體最多然後将該程序殺掉。

一般 Out of memory:Kill process or sacrifice child 報錯會在當可用虛拟記憶體(包括交換空間)消耗到讓整個作業系統面臨風險記憶體不足時,會被觸發。在這種情況下,OOM Killer 會選擇“流氓程序”并殺死它。

優化方案:

增加交換空間的方式可以緩解 Java heap space 異常,但還是建議最好的方案就是更新系統記憶體,讓 java 應用有足夠的記憶體可用,就不會出現這種問題。

二、記憶體洩漏(memory leak)

1、簡述

也稱作“存儲滲漏”。 嚴格來說,隻有對象不會再被程式用到了,但是 GC 又不能回收它們的情況,才叫記憶體洩漏。 但實際情況很多時候一些不太好的實踐(或疏忽)會導緻對象的生命周期變得很長甚至導緻 OOM,也可以叫做寬泛意義上的“記憶體洩漏”。

盡管記憶體洩漏并不會立刻引起程式崩潰,但是一旦發生記憶體洩漏,程式中的可用記憶體就會被逐漸蠶食,直至耗盡所有記憶體,最終出現 OutOfMemory 異常,導緻程式崩潰。

注意:這裡的可用記憶體并不是指實體記憶體,而是指虛拟記憶體大小,這個虛拟記憶體大小取決于磁盤交換區設定的大小。 Java 使用可達性分析算法,最上面的資料不可達,就是需要被回收的。 後期有一些對象不用了,按道理應該斷開引用,但是存在一些鍊沒有斷開,進而導緻沒有辦法被回收

可達性分析算法

可達性分析算法:判斷對象是否是不再使用的對象,本質都是判斷一個對象是否還被引用。那麼對于這種情況下,由于代碼的實作不同就會出現很多種記憶體洩漏問題(讓 JVM 誤以為此對象還在引用中,無法回收,造成記憶體洩漏)。

舉例說明:

  • 對象 X 引用對象 Y,X 的生命周期比 Y 的生命周期長;
  • 那麼當 Y 生命周期結束的時候,X 依然引用着 Y,這時候,垃圾回收期是不會回收對象 Y 的;
  • 如果對象 X 還引用着生命周期比較短的 A、B、C,對象 A 又引用着對象 a、b、c,這樣就可能造成大量無用的對象不能被回收,進而占據了記憶體資源,造成記憶體洩漏,直到記憶體溢出。

2、Java 中記憶體洩漏的 8 種情況

2.1、靜态集合類,如 HashMap、LinkedList 等等。

如果這些容器為靜态的,那麼它們的生命周期與 JVM 程式一緻,則容器中的對象在程式結束之前将不能被釋放,進而造成記憶體洩漏。 簡而言之,長生命周期的對象持有短生命周期對象的引用,盡管短生命周期的對象不再使用,但是因為長生命周期對象持有它的引用而導緻不能被回收。

大型網際網路系統如何排查JAVA記憶體溢出、洩露的情況?

2、單例模式 單例模式,和靜态集合導緻記憶體洩露的原因類似,因為單例的靜态特性,它的生命周期和 JVM 的生命周期一樣長,是以如果單例對象如果持有外部對象的引用,那麼這個外部對象也不會被回收,那麼就會造成記憶體洩漏。

3、内部類持有外部類的引用 在 Java 中内部類的定義與使用一般為成員内部類與匿名内部類,他們的對象都會隐式持有外部類對象的引用,影響外部類對象的回收。 可以通過反編譯可以來驗證這個理論:

java 代碼

public class Outer {
    private String name;
    class Inner{
        private String test;
    }
}           

反編譯後的代碼

class Outer$Inner {
    private String test;
    final Outer this$0;
    Outer$Inner() {
        this.this$0 = Outer.this;
        super();
    }
}
           

可以清楚的發現,内部類的屬性中有這個外部類,并且在内部類的構造函數中有這個外部類屬性的初始化。 如果一個外部類的執行個體對象的方法傳回了一個内部類的執行個體對象,而這個内部類對象被長期引用了,那麼即使那個外部類執行個體對象不再被使用,但由于内部類持有外部類的執行個體對象引用,這個外部類對象将不會被垃圾回收,這也會造成記憶體洩漏。

4、各種連接配接,如資料庫連接配接、網絡連接配接和 IO 連接配接等 在對資料庫進行操作的過程中,首先需要建立與資料庫的連接配接,當不再使用時,需要調用 close 方法來釋放與資料庫的連接配接。隻有連接配接被關閉後,垃圾回收器才會回收對應的對象。 否則,如果在通路資料庫的過程中,**對 Connection、Statement 或 ResultSet 不顯性地關閉,将會造成大量的對象無法被回收,**進而引起記憶體洩漏。

5、變量不合理的作用域

一般而言,一個變量的定義的作用範圍大于其使用範圍,很有可能會造成記憶體洩漏。另一方面,如果沒有及時地把對象設定為 null,很有可能導緻記憶體洩漏的發生。

class Outer$Inner {
    private String test;
    final Outer this$0;
    Outer$Inner() {
        this.this$0 = Outer.this;
        super();
    }
}           

如上面這個僞代碼,通過 readFromNet 方法把接受的消息儲存在變量 msg 中,然後調用 saveDB 方法把 msg 的内容儲存到資料庫中,此時 msg 已經就沒用了,由于 msg 的生命周期與對象的生命周期相同,此時 msg 還不能回收,是以造成了記憶體洩漏。

大型網際網路系統如何排查JAVA記憶體溢出、洩露的情況?

優化方案:

方案1:這個 msg 變量可以放在方法内部,當方法使用完,那麼 msg 的生命周期也就結束,就可以回收了。

方案2:在使用完 msg 後,把 msg 設定為 null,這樣垃圾回收器也會回收 msg 的記憶體空間。

6、對象緩存洩漏 一旦把對象引用放入到緩存中,就很容易遺忘。

比如:代碼中會加載一個表中的資料到緩存(記憶體)中,測試環境隻有幾百條資料,但是生産環境則可能會有幾百萬的資料。 優化方案:可以使用 WeakHashMap 代表緩存,此種 Map 的特點是,當除了自身有對 key 的引用外,此 key 沒有其他引用那麼此 map 會自動丢棄此值。

/**
 * 示範記憶體洩漏
 */
public class MapTest {
    static Map wMap = new WeakHashMap();
    static Map map = new HashMap();

    public static void main(String[] args) {
        init();
        testWeakHashMap();
        testHashMap();
    }

    public static void init() {
        String ref1 = new String("obejct1");
        String ref2 = new String("obejct2");
        String ref3 = new String("obejct3");
        String ref4 = new String("obejct4");
        wMap.put(ref1, "cacheObject1");
        wMap.put(ref2, "cacheObject2");
        map.put(ref3, "cacheObject3");
        map.put(ref4, "cacheObject4");
        System.out.println("String引用ref1,ref2,ref3,ref4 消失");

    }

    public static void testWeakHashMap() {

        System.out.println("WeakHashMap GC之前");
        for (Object o : wMap.entrySet()) {
            System.out.println(o);
        }
        try {
            System.gc();
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("WeakHashMap GC之後");
        for (Object o : wMap.entrySet()) {
            System.out.println(o);
        }
    }

    public static void testHashMap() {
        System.out.println("HashMap GC之前");
        for (Object o : map.entrySet()) {
            System.out.println(o);
        }
        try {
            System.gc();
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("HashMap GC之後");
        for (Object o : map.entrySet()) {
            System.out.println(o);
        }
    }

}
/**
 * 結果
 * String引用ref1,ref2,ref3,ref4 消失
 * WeakHashMap GC之前
 * obejct2=cacheObject2
 * obejct1=cacheObject1
 * WeakHashMap GC之後
 * HashMap GC之前
 * obejct4=cacheObject4
 * obejct3=cacheObject3
 * Disconnected from the target VM, address: '127.0.0.1:51628', transport: 'socket'
 * HashMap GC之後
 * obejct4=cacheObject4
 * obejct3=cacheObject3
 **/           

上面代碼示範 WeakHashMap 如何自動釋放緩存對象:當 init 函數執行完成後,局部變量字元串引用 weakd1,weakd2,d1,d2 都會消失,此時隻有靜态 map 中儲存中對字元串對象的引用,可以看到,調用 gc 之後,HashMap 的沒有被回收,而 WeakHashMap 裡面的緩存被回收了。

7、監聽器和回調

記憶體洩漏另一個常見來源是監聽器和其他回調,如果用戶端在實作的 API 中注冊回調,卻沒有顯式的取消,那麼就會積聚。 需要確定回調立即被當作垃圾回收的最佳方法是隻儲存它的弱引用,例如将它們儲存成為 WeakHashMap 中的鍵。

總結

在系統排查故障時,首先需要了解産生的基本原理,不然無從下手,無從排查,多學習基本支援,多了解各個方面的JAVA知識,希望多關注此頭條号,分享更多的JAVA知識。

繼續閱讀