(四)JVM成神路之深入理解虛擬機(jī)運(yùn)行時(shí)數(shù)據(jù)區(qū)與內(nèi)存溢出、內(nèi)存泄露剖析

引言

前面的文章中重點(diǎn)是對(duì)于JVM的子系統(tǒng)進(jìn)行分析,在之前已經(jīng)詳細(xì)的闡述了虛擬機(jī)的類加載子系統(tǒng)以及執(zhí)行引擎子系統(tǒng),而本篇?jiǎng)t準(zhǔn)備對(duì)于JVM運(yùn)行時(shí)的內(nèi)存區(qū)域以及JVM運(yùn)行時(shí)的內(nèi)存溢出與內(nèi)存泄露問題進(jìn)行全面剖析。

一、全面詳解JVM運(yùn)行時(shí)內(nèi)存區(qū)域

JVM在運(yùn)行Java程序時(shí),會(huì)把自身管理的內(nèi)存分為若干個(gè)不同的數(shù)據(jù)區(qū)域,這些區(qū)域各自都有各自的用途,同時(shí),不同的區(qū)域也有著不同的生命周期,有些區(qū)域隨著虛擬機(jī)的啟動(dòng)而開辟,隨著虛擬機(jī)的終止而銷毀,有的區(qū)域則是在運(yùn)行過程中不斷的創(chuàng)建與銷毀。

JVM內(nèi)存區(qū)域也被稱為JVM運(yùn)行時(shí)數(shù)據(jù)區(qū),主要包含程序計(jì)數(shù)器、虛擬機(jī)棧、本地方法棧、堆空間、元數(shù)據(jù)空間(方法區(qū))、運(yùn)行時(shí)常量池、字符串常量池、直接內(nèi)存(本地內(nèi)存)等。站在程序執(zhí)行的角度來看,總體可分為線程共享區(qū)和線程私有區(qū)兩大塊。如下圖:

JVM運(yùn)行時(shí)數(shù)據(jù)區(qū)

下面會(huì)分別從線程私有和線程共享兩個(gè)角度對(duì)JVM的每個(gè)內(nèi)存區(qū)域進(jìn)行闡述,

1.1、線程私有區(qū)

線程私有區(qū)的含義是指:對(duì)于每條線程而言,在創(chuàng)建它們時(shí),JVM都會(huì)為它們分配的區(qū)域,這些內(nèi)存區(qū)域的生命周期會(huì)隨著線程的啟動(dòng)、死亡而創(chuàng)建和銷毀。這些區(qū)域創(chuàng)建后,其他線程是不可見的,只有當(dāng)前線程自身可以訪問。

運(yùn)行時(shí)數(shù)據(jù)區(qū)中的線程私有區(qū)域主要包含:程序計(jì)數(shù)器、虛擬機(jī)棧以及本地方法棧。

1.1.1、程序計(jì)數(shù)器(Progran Counter Register)

程序計(jì)數(shù)器是JVM為每條線程開辟的一塊較小的區(qū)域,每條線程都有且只有一個(gè)程序計(jì)數(shù)器,線程之間不相互干擾。生命周期與線程一致,隨線程啟動(dòng)而生,線程銷毀而亡。同時(shí)也是JVM所有內(nèi)存區(qū)域中唯一不會(huì)發(fā)生OOM(OutOfMemoryError/內(nèi)存溢出)的區(qū)域,GC機(jī)制不會(huì)觸及的區(qū)域。

主要是作為當(dāng)前線程執(zhí)行時(shí)的字節(jié)碼行號(hào)指示器來使用的,當(dāng)線程執(zhí)行一個(gè)Java方法時(shí),記錄線程正在執(zhí)行的字節(jié)碼指令地址,當(dāng)執(zhí)行引擎處理完某個(gè)指令后,程序計(jì)數(shù)器需要進(jìn)行對(duì)應(yīng)更新,將指針改向下一條要執(zhí)行的指令地址,執(zhí)行引擎會(huì)根據(jù)PC計(jì)數(shù)器中記錄的地址進(jìn)行對(duì)應(yīng)的指令執(zhí)行。當(dāng)線程在執(zhí)行一些由C/C++編寫的Native方法時(shí),PC計(jì)數(shù)器中則為空(Undefined)。除此作用之外,也可以保證線程發(fā)生CPU時(shí)間片切換后能恢復(fù)到正確的位置執(zhí)行。

1.1.2、虛擬機(jī)棧(Stack)

虛擬機(jī)棧也被稱為Java棧,在JVM的內(nèi)存區(qū)域中,棧主要是作為運(yùn)行時(shí)執(zhí)行的單位,棧的作用是負(fù)責(zé)程序運(yùn)行時(shí)具體如何執(zhí)行、如何處理數(shù)據(jù)等工作。生命周期與線程一致,每個(gè)線程創(chuàng)建時(shí)都會(huì)為之創(chuàng)建一個(gè)虛擬機(jī)棧。

當(dāng)線程在執(zhí)行一個(gè)Java方法時(shí),都會(huì)為執(zhí)行的方法生產(chǎn)一個(gè)棧幀(Stack Frame,每個(gè)Java方法的調(diào)用到執(zhí)行結(jié)束,對(duì)應(yīng)著虛擬機(jī)棧中的一個(gè)棧幀的從入棧到出棧的過程,一個(gè)棧幀需要分配多大的內(nèi)存空間,在編譯器就已經(jīng)確定了,不會(huì)受到運(yùn)行時(shí)變量數(shù)據(jù)的大小影響。對(duì)于執(zhí)行引擎而言,它只會(huì)對(duì)位于棧頂?shù)臈兀ū环Q為當(dāng)前棧幀)進(jìn)行操作,與當(dāng)前棧幀關(guān)聯(lián)的方法被稱為當(dāng)前方法。

一個(gè)棧幀中主要包含局部變量表、操作數(shù)棧、動(dòng)態(tài)鏈接、方法出口等信息,接下來依次對(duì)它們進(jìn)行分析。

1.1.2.1、局部變量表

局部變量表是一個(gè)由槽(slot)組成的數(shù)組,用于存放當(dāng)前實(shí)例對(duì)象的引用信息、方法參數(shù)以及方法體內(nèi)定義的基本數(shù)據(jù)類型變量、對(duì)象引用以及返回地址等信息,在Class文件的方法表的Code屬性的max_locals指定了該方法所需局部變量表的最大容量。

槽(Slot):槽是局部變量表中的最小單位,規(guī)定大小為32bit,對(duì)于32bit大小的數(shù)據(jù),如int類型的變量、指針壓縮后的對(duì)象引用信息等,都會(huì)使用一個(gè)槽來存儲(chǔ)。而對(duì)于64位的數(shù)據(jù),如long、double類型的變量、未開啟指針壓縮的對(duì)象引用等數(shù)據(jù),JVM會(huì)為其分配兩個(gè)連續(xù)的槽空間進(jìn)行存儲(chǔ)。
局部變量表中每個(gè)槽位都會(huì)有個(gè)固定的索引下標(biāo)值,在執(zhí)行方法時(shí),執(zhí)行引擎會(huì)根據(jù)索引值去訪問局部變量表的指定槽位,然后將數(shù)據(jù)加載到操作數(shù)棧中進(jìn)行執(zhí)行。

局部變量表中存儲(chǔ)的數(shù)據(jù)只對(duì)于當(dāng)前方法中有效,虛擬機(jī)在執(zhí)行時(shí),依靠于操作數(shù)棧與局部變量表中存儲(chǔ)的數(shù)據(jù)完成執(zhí)行操作。方法執(zhí)行結(jié)束后,局部變量表會(huì)隨著棧幀的的出棧/銷毀而隨之銷毀。一般而言,如果當(dāng)前方法屬于構(gòu)造方法或?qū)嵗椒?,那么這些方法的局部變量表中下標(biāo)為0的槽位必然存儲(chǔ)的是this引用,也就是局部變量表中的第一個(gè)位置會(huì)被用來放當(dāng)前方法所屬的對(duì)象引用,其他的局部變量會(huì)按照順序在局部變量表中進(jìn)行存儲(chǔ)。如下圖:

局部變量表結(jié)構(gòu)

PS:值得注意的是:局部變量表中的槽位空間是可以被重復(fù)使用的,當(dāng)局部變量表的一個(gè)數(shù)據(jù)失去作用并沒有保持引用關(guān)系時(shí),虛擬機(jī)會(huì)嘗試將原本存儲(chǔ)該數(shù)據(jù)的槽位用于分配新的數(shù)據(jù),來個(gè)案例理解一下:

public void test(){
    int a = 1;
    long b = 8l;
    Object obj = new Object();
    // 模擬使用上述變量的過程....
    obj = null;
    // 繼續(xù)往下執(zhí)行......
    int c = 7;
    //.....
}

如上代碼,我們按照前面對(duì)于局部變量表的講解來初步想象出最初的局部變量表的布局,應(yīng)該是如下這個(gè)樣子的:

局部變量表初次分配

根據(jù)前面的代碼進(jìn)行執(zhí)行,經(jīng)過初次分配后的局部變量表應(yīng)該是上圖所示的情況,按照原本的邏輯來說,int類型的變量c,應(yīng)該會(huì)被分配到第六個(gè)槽位,也就是下標(biāo)索引為5的位置,但實(shí)際上因?yàn)槲覀冊(cè)谌缟螶ava程序中,對(duì)obj變量進(jìn)行了置空操作,也就代表著局部變量表中存儲(chǔ)obj這個(gè)引用的數(shù)據(jù)槽位不會(huì)再被使用,所以虛擬機(jī)會(huì)嘗試復(fù)用該槽,如下:
槽位復(fù)用

當(dāng)需要為整數(shù)型的變量c分配槽位時(shí),會(huì)直接將c分配到第五個(gè)槽位,也就是原本存儲(chǔ)obj引用指針的位置。不過值得注意一提的是:這里是直接替換掉了原本槽位的數(shù)據(jù),而不是先將原本槽位的數(shù)據(jù)移出。

局部變量表中的對(duì)象引用信息是在后續(xù)GC篇章中,一個(gè)重要的GC根節(jié)點(diǎn),一個(gè)堆中的對(duì)象只要在一個(gè)局部變量表中被直接或間接的引用著,那么GC觸發(fā)時(shí)就不會(huì)回收這個(gè)堆中對(duì)象。

同時(shí),基于性能調(diào)優(yōu)而言,在棧幀中與之關(guān)聯(lián)的最密切的部分,就是局部變量表,方法執(zhí)行時(shí),虛擬機(jī)使用局部變量表完成方法的傳遞。

1.1.2.2、操作數(shù)棧(Operand Stack)

操作數(shù)棧是一個(gè)遵循FILO先進(jìn)后出模式的棧結(jié)構(gòu),在Class文件的結(jié)構(gòu)定義中的Code屬性的max_stacks定義了執(zhí)行過程中最大的棧深度(會(huì)在編譯器就確定一個(gè)方法的最大棧深度)。在前面的篇章中曾不止一次提及過,Java虛擬機(jī)是基于棧式的虛擬機(jī),執(zhí)行引擎中的解釋器也是基于棧的工作模式,這個(gè)棧則是指操作數(shù)棧。

在執(zhí)行一個(gè)方法時(shí),首先會(huì)先創(chuàng)建一個(gè)與該方法對(duì)于的棧幀,該棧幀中的操作數(shù)棧最初是空的,在執(zhí)行過程中,會(huì)根據(jù)字節(jié)碼指令往棧中寫入(入棧)和提?。ǔ鰲#?shù)據(jù)。操作數(shù)棧的主要目的是用于保存計(jì)算過程的中間結(jié)果,同時(shí)作為計(jì)算過程中變量臨時(shí)的存儲(chǔ)空間。

與前面的局部變量表一樣,操作數(shù)棧也是一個(gè)由32bit為單位的字節(jié)數(shù)組構(gòu)成的,操作數(shù)棧中可支持存儲(chǔ)的數(shù)據(jù)類型主要有:int、long、float、double、reference、returnType等類型,對(duì)于byte、short、char類型的數(shù)據(jù)會(huì)在入棧前被轉(zhuǎn)為int類型放入棧中存儲(chǔ)。
但與局部變量表不同的是:局部變量表是通過下標(biāo)索引去訪問存儲(chǔ)的數(shù)據(jù),而操作數(shù)棧中則是通過標(biāo)準(zhǔn)的壓棧、出棧的方式完成數(shù)據(jù)訪問。

同時(shí)因?yàn)椴僮鲾?shù)棧在運(yùn)行時(shí)是位于內(nèi)存中的,頻繁的去對(duì)內(nèi)存進(jìn)行讀寫操作會(huì)影響執(zhí)行速度,所以實(shí)際在執(zhí)行過程中,虛擬機(jī)會(huì)將棧頂元素全部緩存到物理CPU的寄存器或高速緩存(L1/L2/L3)中,以此降低對(duì)內(nèi)存的讀寫次數(shù),從而提升執(zhí)行引擎的執(zhí)行效率。

還是用之前篇章中的add方法的a+b例子進(jìn)行講解,源碼與操作數(shù)棧計(jì)算過程如下圖:

操作數(shù)棧計(jì)算案例

1.1.2.3、動(dòng)態(tài)鏈接(Dynamic Linking)

虛擬機(jī)棧中的每個(gè)棧幀都包含一個(gè)指向運(yùn)行時(shí)常量池中該棧幀所屬方法的引用,持有這個(gè)引用是為了支持方法調(diào)用過程中的動(dòng)態(tài)鏈接(比如invokedynamic指令的調(diào)用)。

在Java源文件被編譯成Class文件時(shí),類中所有的變量、方法調(diào)用都會(huì)化為符號(hào)引用,然后保存在class文件的常量池中,在class文件中描述一個(gè)方法調(diào)用另一個(gè)方法時(shí),就使用常量池中指向方法的符號(hào)引用來表示的。動(dòng)態(tài)鏈接的作用就是為了將這些符號(hào)引用轉(zhuǎn)換為調(diào)用方法的直接引用。

常量池:位于編譯后生成的class字節(jié)碼文件中。
運(yùn)行時(shí)常量池:位于運(yùn)行期間的元數(shù)據(jù)空間/方法區(qū)中。

1.1.2.4、方法出口(Return Address)

一個(gè)方法當(dāng)開始被執(zhí)行引擎執(zhí)行時(shí),只有兩種情況會(huì)導(dǎo)致方法退出,一種是在執(zhí)行過程中遇到了正常返回的字節(jié)碼指令,如:ireturn、lreturn、dreturn、areturn、return,釋義如下:

  • ireturn:返回值為int、byte、char、short、boolean類型時(shí)使用該指令返回
  • lreturn:返回值為long類型時(shí)使用該指令返回
  • dreturn:返回值為double類型時(shí)使用該指令返回
  • areturn:返回值為引用類型時(shí)使用該指令返回
  • return:無返回void、類或接口初始化方法時(shí)使用該指令返回

方法正常執(zhí)行完成后退出的情況被稱為正常完成出口,一般執(zhí)行返回的字節(jié)碼指令時(shí),調(diào)用者的程序計(jì)數(shù)器會(huì)被作為返回的地址。

除開正常執(zhí)行完成后退出的情況外,還有一種情況也會(huì)導(dǎo)致方法的退出,那就是方法執(zhí)行過程中出現(xiàn)了異常,并且在方法體中沒有處理該異常(沒有try/catch),此時(shí)也會(huì)導(dǎo)致方法退出,這種情況下被稱為異常完成出口,返回地址則需要通過異常處理器表來確定。

當(dāng)一個(gè)方法執(zhí)行結(jié)束退出時(shí),會(huì)執(zhí)行如下步驟:

  • ①?gòu)?fù)原上層方法的局部變量表以及操作數(shù)棧。
  • ②如果當(dāng)前方法有返回值的情況下,把返回值壓入調(diào)用者方法棧幀的操作數(shù)棧中。
  • ③將PC計(jì)數(shù)器的地址指向改為方法下一條指令的位置,從而使得調(diào)用者正常工作。
  • PS:異常退出的情況下,是不會(huì)給上層調(diào)用者返回任何值的。
1.1.2.5、附加信息

各大廠商在實(shí)現(xiàn)JVM時(shí),會(huì)增加一些《虛擬機(jī)規(guī)范》里沒有描述的信息到棧幀中,如與調(diào)試相關(guān)的信息等,這類規(guī)范中未曾描述的信息則被稱為附加信息(不同的VM可能存在的附加信息也可能不會(huì)一致)。

1.1.2.6、虛擬機(jī)棧的特點(diǎn)與運(yùn)行原理

采用數(shù)組這種快捷有效的存儲(chǔ)方式,同時(shí)在運(yùn)行時(shí)也被放在內(nèi)存中,并且也會(huì)將操作數(shù)棧的棧頂數(shù)據(jù)放入高速緩存或寄存器中,所以從訪問速度上來看, 僅次于PC寄存器。

虛擬機(jī)棧這塊內(nèi)存區(qū)域不存在垃圾回收,但是存在OOM,在《Java虛擬機(jī)規(guī)范》中,對(duì)這個(gè)區(qū)域規(guī)定了兩種異常:

  • StackOverflowError:當(dāng)前線程請(qǐng)求的棧深度大于虛擬機(jī)棧所允許的深度時(shí)拋出該異常。
  • OutOfMemoryError:如果擴(kuò)展時(shí)無法申請(qǐng)到足夠的內(nèi)存空間會(huì)拋出OOM異常。

對(duì)于每條線程的虛擬機(jī)棧大小可以通過-Xss參數(shù)進(jìn)行調(diào)整,默認(rèn)單位為字節(jié),默認(rèn)大小為1MB/1024KB/1048576字節(jié)。

JVM運(yùn)行期間,每條線程都擁有自己獨(dú)立的虛擬機(jī)棧(線程棧),當(dāng)前線程棧中的數(shù)據(jù)以棧幀的格式進(jìn)行存儲(chǔ),當(dāng)前線程正在執(zhí)行的每一個(gè)方法都會(huì)在虛擬機(jī)棧中生成一個(gè)對(duì)應(yīng)的棧幀,如下案例:

public void a(){
    int b_result = b();
}
public int b(){
    c();
    return 9;
}
public void c(){
    // ....
}

當(dāng)一條線程執(zhí)行方法a()時(shí),它的虛擬機(jī)棧情況如下:

線程執(zhí)行a()方法時(shí)的棧結(jié)構(gòu)

對(duì)于這條線程而言,棧中的所有棧幀在同一時(shí)刻時(shí),只會(huì)存在一個(gè)活動(dòng)棧幀,也就是位于棧頂?shù)臈?,也就是我們前面所說的當(dāng)前棧幀。執(zhí)行引擎執(zhí)行時(shí),只會(huì)執(zhí)行當(dāng)前棧幀的字節(jié)碼指令,如果執(zhí)行當(dāng)前方法時(shí),在其中調(diào)用了其他方法,那么另外一個(gè)方法對(duì)應(yīng)的棧幀會(huì)被創(chuàng)建出來,放在頂端,從而成為新的當(dāng)前幀,接著執(zhí)行引擎會(huì)去執(zhí)行新幀,當(dāng)該幀執(zhí)行結(jié)束時(shí),會(huì)傳回此方法的執(zhí)行結(jié)果給前一個(gè)棧幀,也就是上層調(diào)用者,比如上述案例中a()就是b()的上層調(diào)用者,接著虛擬機(jī)會(huì)丟棄當(dāng)前棧幀,使得前一個(gè)棧幀重新成為棧頂?shù)漠?dāng)前幀。這個(gè)過程會(huì)不斷重復(fù),直至一條方法調(diào)用鏈結(jié)束或因?yàn)楫惓V袛啵艜?huì)停止。

1.1.3、本地方法棧(Native Method Stack)

本地方法棧和虛擬機(jī)棧差不多是類似的,區(qū)別在于虛擬機(jī)棧是用于執(zhí)行Java方法的,而本地方法棧則是用于執(zhí)行C所編寫的Native本地方法。在程序運(yùn)行之初,首先會(huì)在本地方法棧中登記Native本地方法,在執(zhí)行引擎執(zhí)行時(shí),保存本地方法的相關(guān)數(shù)據(jù)(參數(shù)、局部變量等)。

因?yàn)槭莄編寫的本地方法,所以本地方法庫(kù)中的Native方法會(huì)被編譯為基于本機(jī)硬件和操作系統(tǒng)的程序。本地方法執(zhí)行是在os中執(zhí)行的,并非在JVM中執(zhí)行的,所以使用的是os的程序計(jì)數(shù)器而非JVM的程序計(jì)數(shù)器,當(dāng)開始執(zhí)行一個(gè)本地方法時(shí),就會(huì)進(jìn)入不再受虛擬機(jī)限制的環(huán)境,級(jí)別與虛擬機(jī)一樣,可以直接訪問JVM的任何內(nèi)存區(qū)域,也可以直接使用CPU處理器的寄存器和本地內(nèi)存等。而本地方法棧只是存儲(chǔ)了線程要運(yùn)行這個(gè)方法的必要信息,比如出口,入口,動(dòng)態(tài)鏈接,局部變量表,操作數(shù)棧等。

不過在HotSpot虛擬機(jī)中,它將本地方法棧和虛擬機(jī)棧兩者合二為一了。

1.2、線程共享區(qū)

線程共享的含義是指:在運(yùn)行時(shí),這些區(qū)域?qū)τ诔绦蛑械乃芯€程而言都是可見的,這些區(qū)域的狀態(tài)不會(huì)因?yàn)槟骋粭l線程的死亡而發(fā)生改變,這些區(qū)域創(chuàng)建后是與JVM同級(jí)別的,伴隨JVM的生命周期共生共死。

運(yùn)行時(shí)數(shù)據(jù)區(qū)中的線程共享去主要包含:堆空間、元數(shù)據(jù)空間(方法區(qū))以及直接內(nèi)存這三大塊。

1.2.1、Java堆空間(Heap)

在Java內(nèi)存中,堆空間也是最重要的一塊區(qū)域,大部分的JVM調(diào)優(yōu)手段都是基于堆空間而進(jìn)行展開的。Java堆的作用與前面分析的Java棧不同,棧主要是作為運(yùn)行時(shí)的單位,用于臨時(shí)存儲(chǔ)運(yùn)行時(shí)需要以及產(chǎn)生的數(shù)據(jù),而Java堆是存儲(chǔ)的單位,主要解決的問題是數(shù)據(jù)存儲(chǔ)問題,重點(diǎn)關(guān)注的領(lǐng)域是數(shù)據(jù)怎么存,放哪里,怎么放等。

堆空間會(huì)在JVM啟動(dòng)時(shí)被創(chuàng)建出來,對(duì)于JVM來說,堆空間是唯一的,每個(gè)JVM只會(huì)存在一個(gè)堆空間,同時(shí)容量大小會(huì)在創(chuàng)建時(shí)就被確定,當(dāng)然,我們可以通過參數(shù)-Xms-Xmx指定堆的起始內(nèi)存大小和最大內(nèi)存大小,當(dāng)超過-Xmx參數(shù)指定的大小時(shí)則會(huì)拋出OOM

默認(rèn)情況下,如果不通過參數(shù)強(qiáng)制指定堆空間大小,那么JVM會(huì)根據(jù)當(dāng)前所在的平臺(tái)進(jìn)行自適應(yīng)調(diào)整,起始大小默認(rèn)為當(dāng)前物理機(jī)器內(nèi)存的1/64,最大大小默認(rèn)為當(dāng)前物理機(jī)器內(nèi)存的1/4。

在Java程序運(yùn)行時(shí),系統(tǒng)運(yùn)行過程中產(chǎn)生的大部分實(shí)例對(duì)象以及數(shù)組對(duì)象都會(huì)被放到堆中存儲(chǔ)。

創(chuàng)建Java堆時(shí),本質(zhì)上并不是直接在內(nèi)存中劃分了一塊完整的空間給JVM,因?yàn)樵凇禞ava虛擬機(jī)規(guī)范》中提及到:堆空間在物理上可以是不連續(xù)的,只需要邏輯上視為連續(xù)即可。所以一個(gè)JVM的堆空間在實(shí)際的機(jī)器內(nèi)存上,可能是由機(jī)器內(nèi)存中多個(gè)不同位置的空間組成的,如下圖:

堆空間組成

Java堆同時(shí)也是變化比較頻繁的區(qū)域,在不同Java版本中,堆空間也發(fā)生了不同的改變:

  • JDK7及之前:堆空間包含新生代、年老代以及永久代。
  • JDK8:堆空間包含新生代和年老代,永久代被改為元數(shù)據(jù)空間,位于堆之外。
  • JDK9:堆空間從邏輯上保留了分代的概念,但物理上本身不分代。
  • JDK11:堆空間從此以后邏輯和物理上都不分代。

本質(zhì)上來說,影響堆空間結(jié)構(gòu)的并不是Java版本的不同,Java堆結(jié)構(gòu)是跟JVM運(yùn)行時(shí)所使用的垃圾回收器息息相關(guān)的,由GC器決定了運(yùn)行時(shí)的堆空間會(huì)被劃分為何種結(jié)構(gòu)。

在JDK1.8及之前的Java版本中,幾乎所有的GC器都會(huì)把堆空間劃分為至少兩個(gè)區(qū)域:新生代和年老代,但在JDK1.9到之后的GC器中,大多數(shù)的GC器開始了不分代的路子(具體原因稍后分析)。

1.2.1.1、分代堆空間

分代的含義是指在JVM運(yùn)行過程中,堆空間是否會(huì)被分為不同的區(qū)域分別用于存儲(chǔ)不同生命周期的對(duì)象實(shí)例,JDK1.8之前的堆結(jié)構(gòu)是完全分代的,也就是指邏輯+物理上都分代,在運(yùn)行時(shí)物理內(nèi)存會(huì)被劃為幾塊不同的區(qū)域,也就是一個(gè)Eden區(qū)、兩個(gè)Survivor區(qū)(Form/To區(qū))以及一個(gè)Old區(qū),從物理內(nèi)存上來說各個(gè)區(qū)域都是完整且連續(xù)的內(nèi)存,每塊區(qū)域都用于存儲(chǔ)不同周期的對(duì)象實(shí)例,相互之間并不干擾。

1.2.1.2、不分代堆空間

到了JDK1.9時(shí),G1正式出道,成為了JVM內(nèi)嵌的默認(rèn)GC器,Java堆空間從此出現(xiàn)了不分代的概念,但不分代也分為兩種情況,一種是邏輯分代,物理不分代,另一種則是邏輯+物理都不分代。

邏輯分代,物理不分代(G1):對(duì)象分配的邏輯上還是存在分代的思想,但是物理內(nèi)存上不會(huì)再分為幾塊完整的分代空間。
邏輯+物理都不分代(ZGC):無論從對(duì)象分配的邏輯上還是物理內(nèi)存上,都不存在分代的概念。

下面簡(jiǎn)單敘述一下不同版本的堆空間結(jié)構(gòu),具體的會(huì)在GC篇章中進(jìn)行闡述。

1.2.1.3、JDK7及之前的堆空間內(nèi)存劃分

在JDK1.7及之前的JVM中,所有的GC器都是物理+邏輯都分代的,包括內(nèi)嵌的默認(rèn)GC器Parallel Scavenge(新生代)+ Parallel Old(老年代)也分代,所以一般堆空間會(huì)被劃分為三個(gè)區(qū)域:新生代、年老代以及永久代:

  • 新生代:一個(gè)Eden區(qū)、兩個(gè)Survivor區(qū)(Form/To區(qū)),比例:8:1:1
  • 年老代:一個(gè)Old區(qū)
  • 永久代:方法區(qū)

JDK7及之前的堆構(gòu)成

新生代主要用于存儲(chǔ)未達(dá)到年老代分配條件的對(duì)象,其中Eden區(qū)是專門用來存儲(chǔ)剛創(chuàng)建出來的對(duì)象實(shí)例,兩個(gè)Survivor區(qū)主要用于垃圾回收時(shí)給存活對(duì)象“避難”。
年老代主要用于存儲(chǔ)達(dá)到符合分配條件的對(duì)象實(shí)例,比如達(dá)到“年齡”的對(duì)象以及過大“體積”的大對(duì)象等。
方法區(qū)/永久代主要用于存儲(chǔ)類的元數(shù)據(jù)信息,如類描述信息、字段信息、方法信息、靜態(tài)變量信息、異常表、方法表等。

默認(rèn)情況下新生代和年老代的空間比例為1:2,新生代占1/3,年老代占2/3,當(dāng)然也可以通過參數(shù):-XX:NewRatio=x來指定比例,也可以通過-Xmn參數(shù)強(qiáng)制指定新生代的內(nèi)存最大大小,如果和前面的Ratio參數(shù)沖突了則以后者為準(zhǔn)。
新生代中,一個(gè)Eden區(qū)、兩個(gè)Survivor區(qū)(Form/To區(qū)),默認(rèn)比例為8:1:1,當(dāng)然也可以通過參數(shù)-XX:SurvivorRatio調(diào)整這個(gè)空間比例。但實(shí)際上初始情況下是6:1:1,因?yàn)镴VM存在自適應(yīng)機(jī)制,當(dāng)然也可以通過-XX:-UseAdaptiveSizePolicy參數(shù)關(guān)閉JVM的自適應(yīng)機(jī)制(不推薦)。

1.2.1.4、JDK8堆空間內(nèi)存劃分

到了JDK1.8的時(shí)候,JVM將永久代,也就是方法區(qū)整合成了元數(shù)據(jù)空間,并且將其移出了堆,將其放在堆空間外的本地內(nèi)存中。

JDK8的堆構(gòu)成

JDK1.8的時(shí)候沒啥好講的,和1.7差距不大,最大區(qū)別在于移除了方法區(qū),在本地內(nèi)存中加入了元數(shù)據(jù)空間來存儲(chǔ)之前方法區(qū)中的大部分?jǐn)?shù)據(jù)(原方法區(qū)中的數(shù)據(jù)并不是所有都被遷移到了元空間存儲(chǔ),有些數(shù)據(jù)被分散到了JVM各個(gè)區(qū)域)。除此之外,常量池在1.8的時(shí)候也被移到了堆外。

1.2.1.5、JDK9堆空間內(nèi)存劃分

到了JDK1.9時(shí),堆空間慢慢的開始了劃時(shí)代的改變,在此之前,堆空間的布局都是采用分代存儲(chǔ)的方式,無論從邏輯上還是從物理內(nèi)存上,都是分代的。但是到了Java9的時(shí)候,因?yàn)槟J(rèn)GC器改為了G1,所以堆中的內(nèi)存區(qū)域被劃為了一個(gè)個(gè)的Region區(qū)。

JDK9的內(nèi)存布局

在JDK1.9時(shí),G1將Java堆劃分為多個(gè)大小相等的獨(dú)立的Region區(qū)域,不過在HotSpot的源碼TARGET_REGION_NUMBER定義了Region區(qū)的數(shù)量限制為2048個(gè)(實(shí)際上允許超過這個(gè)值,但是超過這個(gè)數(shù)量后,堆空間會(huì)變的難以管理)。

一般Region區(qū)的大小等于堆空間的總大小除以2048,比如目前的堆空間總大小為8GB,就是8192MB/2048=4MB,那么最終每個(gè)Region區(qū)的大小為4MB,當(dāng)然也可以用參數(shù)-XX:G1HeapRegionSize強(qiáng)制指定每個(gè)Region區(qū)的大小,但是不推薦,畢竟默認(rèn)的計(jì)算方式計(jì)算出的大小是最適合管理堆空間的。
G1保留了年輕代和老年代的概念,但不再是物理隔閡了,它們都是可以不連續(xù)物理內(nèi)存來組成的Region的集合。

默認(rèn)新生代對(duì)堆內(nèi)存的初始占比是5%,如果堆大小為8GB,那么年輕代占據(jù)400MB左右的內(nèi)存,對(duì)應(yīng)大概是200個(gè)Region區(qū),可以通過-XX:G1NewSizePercent設(shè)置新生代初始占比。
在Java程序運(yùn)行中,JVM會(huì)不停的給新生代增加更多的Region區(qū),但是最多新生代的占比不會(huì)超過堆空間總大小的60%,可以通過-XX:G1MaxNewSizePercent調(diào)整(也不推薦,如果超過這個(gè)比例,年老代的空間會(huì)變的很小,容易觸發(fā)全局GC)。新生代中的Eden區(qū)和Survivor區(qū)對(duì)應(yīng)的Region區(qū)比例也跟之前一樣,默認(rèn)8:1:1,假設(shè)新生代現(xiàn)在有400個(gè)Region,那么整個(gè)新生代的占比則為Eden=320,S0/From=40,S1/To=40。

G1中的年老代晉升條件和之前的無差,達(dá)到年齡閾值的對(duì)象會(huì)被轉(zhuǎn)入年老代的Region區(qū)中,不同的是對(duì)于大對(duì)象的分配,在G1中不會(huì)讓大對(duì)象進(jìn)入年老代,在G1中由專門存放大對(duì)象的Region區(qū)叫做Humongous區(qū),如果在分配對(duì)象時(shí),判定出一個(gè)對(duì)象屬于大對(duì)象,那么則會(huì)直接將其放入Humongous區(qū)存儲(chǔ)。

在G1中,判定一個(gè)對(duì)象是否為大對(duì)象的方式為:對(duì)象大小是否超過單個(gè)普通Region區(qū)的50%,如果超過則代表當(dāng)前對(duì)象為大對(duì)象,那么該對(duì)象會(huì)被直接放入Humongous區(qū)。比如:目前是8GB的堆空間,每個(gè)Region區(qū)的大小為4MB,當(dāng)一個(gè)對(duì)象大小超過2MB時(shí)則會(huì)被判定為屬于大對(duì)象。

Humongous區(qū)存在的意義:可以避免一些“短命”的巨型對(duì)象直接進(jìn)入年老代,節(jié)約年老代的內(nèi)存空間,可以有效避免年老代因空間不足時(shí)的GC開銷。

當(dāng)堆空間發(fā)生全局GC(FullGC)時(shí),除開回收新生代和年老代之外,也會(huì)對(duì)Humongous區(qū)進(jìn)行回收。

1.2.1.6、JDK11堆空間內(nèi)存劃分

在JDK11的時(shí)候,Java又推出了一款新的垃圾回收器ZGC,它也是一款基于Region區(qū)內(nèi)存布局的GC器,這款GC器是真正意義上的不分代,無論是從邏輯上還是物理上都不分代。

JDK11的堆結(jié)構(gòu)

在ZGC中,也會(huì)把堆空間劃分為一個(gè)個(gè)的Region區(qū)域,但ZGC中的Region區(qū)不存在分代的概念,它僅僅只是簡(jiǎn)單的將所有Region區(qū)分為了大、中、小三個(gè)等級(jí):

  • 小型Region區(qū)(Small):固定大小為2MB,用于分配小于256KB的對(duì)象。
  • 中型Region區(qū)(Medium):固定大小為32MB,用于分配>=256KB ~ <=4MB的對(duì)象。
  • 大型Region區(qū)(Large):沒有固定大小,容量可以動(dòng)態(tài)變化,但是大小必須為2MB的整數(shù)倍,專門用于存放>4MB的巨型對(duì)象。但值得一提的是:每個(gè)Large區(qū)只能存放一個(gè)大對(duì)象,也就代表著你的這個(gè)大對(duì)象多大,那么這個(gè)Large區(qū)就為多大,所以一般情況下,Large區(qū)的容量要小于Medium區(qū),并且需要注意:Large區(qū)的空間是不會(huì)被重新分配的(GC篇章詳細(xì)分析)。

PS:實(shí)際上,JDK11中的ZGC并不是因?yàn)橐獟仐壏执砟疃辉O(shè)計(jì)分代的堆空間的,因?yàn)閷?shí)際上最開始分代理念被提出的本質(zhì)原因是源于「大部分對(duì)象朝生夕死」這個(gè)概念的,而實(shí)際上大部分Java程序在運(yùn)行時(shí)都符合這個(gè)現(xiàn)象,所以邏輯分代+物理不分代是堆空間最好的結(jié)構(gòu)方案。但問題在于:ZGC為何不設(shè)計(jì)出分代的堆空間結(jié)構(gòu)呢?其實(shí)本質(zhì)原因是分代實(shí)現(xiàn)起來非常麻煩且復(fù)雜,所以就先實(shí)現(xiàn)出一個(gè)比較簡(jiǎn)單可用的單代版本,后續(xù)可能會(huì)優(yōu)化改進(jìn)(但實(shí)際上能不能改進(jìn)成功還不好說,ZGC的研發(fā)團(tuán)隊(duì)負(fù)責(zé)人Per是從JRockitGC組過來的,R大在和per聊天時(shí)曾聊到過:per之前在JRockitGC器上嘗試了四五次都以失敗告終,ZGC上能不能成功還是得看未來了)。

1.2.1.7、堆總結(jié)

Java堆空間是JVM運(yùn)行時(shí)內(nèi)存區(qū)域中占比最大的一塊,此內(nèi)存區(qū)域唯一的目的就是存儲(chǔ)運(yùn)行時(shí)創(chuàng)建出的對(duì)象實(shí)例。同時(shí),隨著運(yùn)行時(shí)采用的GC器不同,Java堆也會(huì)被分為不同的結(jié)構(gòu),其中主要可分為分代和不分代的兩類結(jié)構(gòu)。相對(duì)來說,分代結(jié)構(gòu)是最適合Java對(duì)象“朝生夕死”的特性的,如果堆結(jié)構(gòu)是分代的,可以使得JVM能夠更好的管理堆內(nèi)存中的對(duì)象,包括內(nèi)存的分配以及回收。

1.2.2、本地內(nèi)存

運(yùn)行時(shí)數(shù)據(jù)區(qū)中的本地內(nèi)存主要可分為兩塊,一部分為元數(shù)據(jù)空間(原方法區(qū)),另一部分則為直接內(nèi)存。在任何一個(gè)平臺(tái)上運(yùn)行一個(gè)進(jìn)程,操作系統(tǒng)都會(huì)為其分配對(duì)應(yīng)的內(nèi)存,JVM也不例外,在啟動(dòng)時(shí)也會(huì)向操作系統(tǒng)申請(qǐng)資源分配(內(nèi)存、CPU、線程數(shù)等)。但值得注意的是:元數(shù)據(jù)空間和直接內(nèi)存這兩塊區(qū)域,并不處于OS為JVM分配的內(nèi)存中,而是直接使用物理機(jī)的內(nèi)存進(jìn)行數(shù)據(jù)存放,但是本地內(nèi)存還是會(huì)被JVM管理。

1.2.2.1、元數(shù)據(jù)空間(Metaspace)

前面曾提及過,元數(shù)據(jù)空間是之前的方法區(qū)(永久代)移過的,所以在講元數(shù)據(jù)空間之前,先聊聊JDK1.7的方法區(qū)。

方法區(qū)也就是所謂的永久代/持久代,方法區(qū)中主要存儲(chǔ)了可以通過反射機(jī)制拿到的所有數(shù)據(jù),如Class類信息、Method方法信息、Filed字段信息,方法區(qū)需要多少的空間具體會(huì)取決于JVM運(yùn)行時(shí)會(huì)加載多少類,因?yàn)榻?jīng)過類加載后的Class文件會(huì)生成類的元數(shù)據(jù),然后將其存儲(chǔ)在這塊區(qū)域。當(dāng)然,當(dāng)一個(gè)類被卸載時(shí),該類數(shù)據(jù)占用的空間也會(huì)在FullGC發(fā)生時(shí)伴隨一起釋放。
方法區(qū)主要存儲(chǔ)的數(shù)據(jù):類的元數(shù)據(jù)、VM內(nèi)部表、類的層級(jí)信息/方法信息/字段信息、方法的編譯信息和字節(jié)碼數(shù)據(jù)、靜態(tài)變量、常量池以及符號(hào)引用。
在JDK1.7時(shí),方法區(qū)的默認(rèn)最大空間為64MB,也可以通過參數(shù)-XX:MaxPermSize調(diào)整。

為什么JDK1.8時(shí)會(huì)移除方法區(qū)呢?
其實(shí)在JDK1.7的時(shí)候就已經(jīng)為1.8移除方法區(qū)在開展準(zhǔn)備工作了,在1.7的時(shí)候已經(jīng)將原本放在方法區(qū)的字符串常量池移動(dòng)到了堆中,而在1.8的時(shí)候全面移除了方法區(qū)的存在,具體原因主要有三個(gè):
①方法區(qū)不容易設(shè)置大小,給大了浪費(fèi)空間,給小了容易OOM,比如Tomcat部署多個(gè)工程,加載大量jar包就容易導(dǎo)致方法區(qū)OOM。
②垃圾回收機(jī)制對(duì)于永久代的回收效率比較低,并且為GC帶來了一些不必要的復(fù)雜度。
③為了更好的融合Sun HotSpot和BEA JRockit兩款虛擬機(jī),因?yàn)橹挥蠬otSpot中存在方法區(qū)的概念,其他的虛擬機(jī)中都不存在此概念,所以為了Oracle HotSpot更好的“前途”,所以干脆移除了方法區(qū),從而達(dá)到Sun HotSpot和BEA JRockit完美融合的目的。

OK,簡(jiǎn)單的看了一下方法區(qū)的描述之后,接著可以來看看元數(shù)據(jù)空間了。當(dāng)然,如果你想知道具體方法區(qū)中存什么,那么可以看這個(gè)。

元數(shù)據(jù)空間則是1.8移除掉方法區(qū)之后的產(chǎn)物,主要用于存放運(yùn)行時(shí)常量池和類信息,如下:

元數(shù)據(jù)空間

而之前方法區(qū)運(yùn)行時(shí)常量池中的字符串常量池則被放置在了堆中,因?yàn)樵诔绦蜻\(yùn)行過程中會(huì)隨著運(yùn)行時(shí)間的增加,字符串常量池中的字符串會(huì)越來越多,所占空間會(huì)越來越大,所以將其放在堆中的好處在于:使得字符串常量池在GC機(jī)制的范圍之內(nèi),字符串也會(huì)存在回收操作。
同時(shí)除開字符串常量池被挪動(dòng)到了堆內(nèi)之外,類的靜態(tài)變量的存儲(chǔ)也被放在了堆中。對(duì)比如下:
JDK1.6/1.7/1.8變化

1.2.2.2、直接內(nèi)存

直接內(nèi)存這塊區(qū)域不是虛擬機(jī)的內(nèi)存區(qū)域,在《Java虛擬機(jī)規(guī)范》中也沒有定義,在創(chuàng)建時(shí)會(huì)直接向操作系統(tǒng)申請(qǐng)內(nèi)存空間,屬于直接使用物理內(nèi)存的一塊區(qū)域,也被稱為“堆外空間”。

對(duì)比堆空間而言,訪問直接內(nèi)存的速度會(huì)超出堆內(nèi)存,也就是讀寫性能優(yōu)于Java堆,來源于Java的NIO庫(kù),Java的NIO可以允許Java程序直接使用本地的直接內(nèi)存存儲(chǔ)數(shù)據(jù)緩沖,因?yàn)槿绻岩恍┪募?shù)據(jù)轉(zhuǎn)為對(duì)象存儲(chǔ)在堆中時(shí),很容易導(dǎo)致堆空間負(fù)載過重而OOM。所以出于性能和穩(wěn)定性兩方面的考慮,一般對(duì)于一些讀寫頻繁的場(chǎng)景或讀取/寫出大文件時(shí)的場(chǎng)景都可以使用直接內(nèi)存進(jìn)行操作。

如果程序中需要用到直接內(nèi)存時(shí)可以通過java.nio.ByteBuffer來創(chuàng)建,調(diào)用allocateDirect方法申請(qǐng)即可,同時(shí)可以通過存在堆中的DirectByteBuffer操作直接內(nèi)存。

直接內(nèi)存的最大空間值可以通過-XX:MaxDirectMemorySize設(shè)置,如果不指定則默認(rèn)與-Xmx參數(shù)設(shè)置的空間大小一致。直接內(nèi)存屬于比較昂貴的資源,因?yàn)樾枰苯酉騉S申請(qǐng),所以分配成本較高,并且創(chuàng)建出來之后也不受JVM的直接控制,所以GC機(jī)制對(duì)于這塊區(qū)域的內(nèi)存空間難以管理,只有當(dāng)發(fā)生FullGC時(shí)才會(huì)對(duì)于這塊區(qū)域進(jìn)行回收。

同時(shí)這塊區(qū)域是也會(huì)出現(xiàn)OOM的,因?yàn)槲锢頇C(jī)的內(nèi)存終歸是有限的,受到硬件的限制,所以如果一直向操作系統(tǒng)申請(qǐng)直接內(nèi)存使用,完事后JVM的GC機(jī)制又無法有效回收使用過的內(nèi)存,可能在下一次FullGC到來之前就會(huì)將物理機(jī)分配的內(nèi)存空間申請(qǐng)耗盡,從而引發(fā)OOM。

所以一般在使用直接內(nèi)存的時(shí)候,不能將希望寄托給GC機(jī)制的全局GC來管理內(nèi)存,因此我們可以和C語言一樣,嘗試自己寫一個(gè)回收直接內(nèi)存的方法,然后使用完成后自己手動(dòng)回收申請(qǐng)的內(nèi)存,方法如下:

import java.nio.ByteBuffer;
import sun.nio.ch.DirectBuffer;
public class NonHeapGC {
  public static void clean(final ByteBuffer byteBuffer) { 
    if (byteBuffer.isDirect()) { 
      ((DirectBuffer)byteBuffer).cleaner().clean(); 
    } 
 } 
  public static void sleep(long i) { 
    try { 
       Thread.sleep(i); 
     }catch(Exception e) { 
       /*skip*/ 
     } 
  } 
  public static void main(String []args) throws Exception { 
      ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 200); 
      System.out.println("start"); 
      sleep(5000); 
      clean(buffer);//執(zhí)行垃圾回收
//     System.gc(); //執(zhí)行Full gc進(jìn)行垃圾回收
      System.out.println("end"); 
      sleep(5000); 
  } 
}

當(dāng)使用完成申請(qǐng)的內(nèi)存空間后,可以手動(dòng)調(diào)用clean()方法進(jìn)行內(nèi)存的回收釋放。

二、內(nèi)存溢出OOM(OutOfMemory)

OOM這個(gè)詞在不少篇章中都曾反復(fù)提及,它的具體含義是指OutOfMemoryError內(nèi)存溢出錯(cuò)誤。在JVM的運(yùn)行時(shí)數(shù)據(jù)區(qū)中,除開程序計(jì)數(shù)器之外,其他的區(qū)域都會(huì)存在內(nèi)存溢出的風(fēng)險(xiǎn),下面依次進(jìn)行舉例分析。

2.1、Java堆空間OOM

前面分析內(nèi)存區(qū)域時(shí)曾談到:Java堆空間是用于存儲(chǔ)對(duì)象實(shí)例和數(shù)組數(shù)據(jù)的內(nèi)存區(qū)域,同時(shí)JVM的GC機(jī)制也會(huì)重點(diǎn)對(duì)于這塊區(qū)域進(jìn)行內(nèi)存管理。但是如果內(nèi)存不足發(fā)生GC時(shí),堆中的對(duì)象都還存活,此時(shí)又沒有足夠的內(nèi)存分配新的對(duì)象實(shí)例,最終堆空間就會(huì)出現(xiàn)OOM,如下案例:

public class OOM {
    // 測(cè)試內(nèi)存溢出的對(duì)象類
    public static class OomObject{}

    /**
     *  測(cè)試Java堆空間OOM的方法
     *  JVM啟動(dòng)參數(shù):-Xms10M -Xmx10M -XX:+HeapDumpOnOutOfMemoryError
     * */
    public static void HeapOOM(){
        List<OomObject> OOMlist = new ArrayList<>();
        // 死循環(huán):反復(fù)往集合中添加對(duì)象實(shí)例
        for(;;){
            OOMlist.add(new OomObject());
        }
    }

    public static void main(String[] args){
        // 調(diào)用測(cè)試堆空間OOM的方法
        HeapOOM();
    }
}

如上案例,在程序啟動(dòng)時(shí)使用參數(shù)-Xms指定JVM堆空間的初始大小為10MB,同時(shí)為了防止內(nèi)存不足時(shí)動(dòng)態(tài)擴(kuò)容,我們也通過-Xmx指定了堆空間的最大大小為10MB,然后在HeapOOM方法中使用死循環(huán)反復(fù)往集合中添加OomObject對(duì)象實(shí)例,

-XX:+HeapDumpOnOutOfMemoryError:可以讓虛擬機(jī)在出現(xiàn)內(nèi)存溢出異常時(shí)Dump出內(nèi)存堆運(yùn)行時(shí)快照,可以使用VisualVM堆快照進(jìn)行分析(后續(xù)GC篇章會(huì)用到,本篇不做詳細(xì)介紹)。

最終程序執(zhí)行結(jié)果如下:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid16160.hprof ...
Heap dump file created [14045343 bytes in 0.092 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    .......

才能上述結(jié)果中,可以清晰的看見java.lang.OutOfMemoryError: Java heap space這一行信息,從這行信息中可以得知:目前程序執(zhí)行出現(xiàn)了內(nèi)存溢出,而溢出的區(qū)域?yàn)镴ava堆空間。

2.1.1、線上環(huán)境堆空間OOM的原因

  • ①內(nèi)存中加載數(shù)據(jù)量過于龐大導(dǎo)致OOM,如一次性從數(shù)據(jù)庫(kù)中查詢出幾千萬條數(shù)據(jù)導(dǎo)致創(chuàng)建出一個(gè)超大型的數(shù)據(jù)數(shù)組。
  • ②集合對(duì)象中存在對(duì)象的引用,使得集合中的一些失效對(duì)象無法被GC回收。
  • ③代碼中存在邏輯不正確的循環(huán)導(dǎo)致在特定情況下產(chǎn)生了大量重復(fù)的對(duì)象實(shí)例。
  • ④使用第三方依賴時(shí),第三方依賴中存在BUG,導(dǎo)致運(yùn)行時(shí)生成大量對(duì)象。
  • ⑤JVM啟動(dòng)時(shí),使用參數(shù)為其分配的堆空間過小,導(dǎo)致程序正常運(yùn)行的內(nèi)存都不足夠。
  • ⑥程序中存在無限遞歸調(diào)用,導(dǎo)致一直生成對(duì)象OOM。
  • ⑦系統(tǒng)流量超出原有的預(yù)估值,導(dǎo)致大量請(qǐng)求進(jìn)入系統(tǒng),創(chuàng)建大量對(duì)象,內(nèi)存過小OOM。
  • ⑧......

其實(shí)本質(zhì)上來說,線上環(huán)境引發(fā)Java堆OOM的原因有很多,但歸根到底就那幾個(gè):
一、程序正常運(yùn)行,堆中存活對(duì)象過多無法回收,新對(duì)象沒有內(nèi)存分配導(dǎo)致的。
二、代碼中存在不規(guī)范的語法,因代碼原因?qū)е逻\(yùn)行過程中出現(xiàn)OOM,如無限遞歸/死循環(huán)/用完后不釋放等。
三、運(yùn)行過程中出現(xiàn)了內(nèi)存泄露,泄露問題一點(diǎn)點(diǎn)將內(nèi)存蠶食掉了,導(dǎo)致最終可用內(nèi)存變得很小,從而誘發(fā)OOM。

2.1.2、線上環(huán)境堆OOM問題排查

一般而言,線上環(huán)境出現(xiàn)問題后,總會(huì)分為固定的幾個(gè)步驟,從發(fā)現(xiàn)問題出發(fā),慢慢到后續(xù)的排查問題、定位問題、解決問題、嘗試最優(yōu)解、適當(dāng)考慮拓展性,這是解決問題的一條完整鏈路。

如前面的堆空間OOM問題,從發(fā)生問題之后,首先應(yīng)該通過相關(guān)的一些JVM工具,對(duì)日志進(jìn)行dump分析,定位出可能發(fā)生該問題的幾個(gè)可疑位置,然后對(duì)這些位置依次進(jìn)行排查,最終定位到具體是由于什么原因?qū)е碌腛OM,再“對(duì)癥下藥”,堆OOM問題解決方案一般有以下幾種:

  • ①如果確定是代碼問題,則通過工具定位到具體的代碼,然后對(duì)代碼進(jìn)行改正即可。
  • ②如果確實(shí)是所分配的堆空間無法保障JVM的正常運(yùn)行了,那么應(yīng)該分配更大的堆空間。
  • ③如果是因?yàn)閮?nèi)存泄露導(dǎo)致的OOM,那么則應(yīng)該進(jìn)一步定位內(nèi)存泄露出現(xiàn)的原因,然后進(jìn)行對(duì)應(yīng)的解決。

2.1.3、GC overhead limit exceeded

在Java程序執(zhí)行過程中,如果當(dāng)JVM花費(fèi)了98%以上的時(shí)間在GC,但成功回收的內(nèi)存不足2%,并且該動(dòng)作重復(fù)五次時(shí),就會(huì)拋出java.lang.OutOfMemoryError:GC overhead limit exceeded錯(cuò)誤,這種情況就屬于分配的空間不足以支撐系統(tǒng)的正常開銷,導(dǎo)致程序耗盡了所有的內(nèi)存資源,GC機(jī)制想回收也束手無策。這種情況下一般都可以先嘗試加大堆內(nèi)存解決。

2.2、虛擬機(jī)棧和本地方法棧OOM

關(guān)于Java棧的內(nèi)存溢出主要可分為本地方法棧和虛擬機(jī)棧OOM,但在HotSpot中將兩者合一了,所以在該虛擬機(jī)中只存在虛擬機(jī)棧OOM的問題,但虛擬機(jī)棧除開會(huì)出現(xiàn)OOM外,還會(huì)出現(xiàn)另一種內(nèi)存問題:SOF,如下:

  • StackOverflowError:當(dāng)前線程請(qǐng)求的棧深度大于虛擬機(jī)棧所允許的深度時(shí)拋出該異常。
  • OutOfMemoryError:如果擴(kuò)展時(shí)無法申請(qǐng)到足夠的內(nèi)存空間會(huì)拋出OOM異常。

2.2.1、虛擬機(jī)棧SOF問題測(cè)試

先上代碼:

public class OOM {
    /**
     * 測(cè)試虛擬機(jī)棧SOF的方法
     * JVM啟動(dòng)參數(shù):-Xss128k
     */
    public static void VMStackSOF() {
        int stackLength = 1;
        stackLength++;
        VMStackSOF();
    }

    public static void main(String[] args){
        // 調(diào)用測(cè)試虛擬機(jī)棧SOF的方法
        VMStackSOF();
    }
}

如上案例中,首先使用-Xss指定了虛擬機(jī)棧的大小為128KB,然后在VMStackSOF()方法中不斷的遞歸調(diào)用自身,運(yùn)行結(jié)果如下:

Exception in thread "main" java.lang.StackOverflowError
    .........

從結(jié)果中可以很明顯的看出SOF問題,因?yàn)榍懊嫱ㄟ^參數(shù)設(shè)定了每條線程的虛擬機(jī)??臻g為128K,所以在VMStackSOF()方法的不斷遞歸下,程序最終拋出了java.lang.StackOverflowError錯(cuò)誤。在運(yùn)行過程中,一條線程在執(zhí)行一個(gè)方法時(shí),無論是棧幀太大還是虛擬機(jī)棧容量太小,當(dāng)無法分配內(nèi)存時(shí)都會(huì)拋出SOF問題。

2.2.2、虛擬機(jī)棧OOM問題測(cè)試

public class OOM {
    /**
     * 測(cè)試虛擬機(jī)棧OOM的方法
     * JVM啟動(dòng)參數(shù):-Xss1M
     */
    public static void VMStackOOM() {
        for (;;){
            new Thread(()->{
                while (1==1){}
            }).start();
        }
    }

    // ?。?!慎重運(yùn)行,大多數(shù)情況下會(huì)導(dǎo)致OS假死?。?!
    public static void main(String[] args){
        // 調(diào)用測(cè)試虛擬機(jī)棧OOM的方法
        VMStackOOM(); 
    }
}

其實(shí)Java棧的OOM是很難觀測(cè)到的,因?yàn)闂OM的條件為:如果??臻g擴(kuò)展時(shí)無法申請(qǐng)到足夠的內(nèi)存空間會(huì)拋出OOM異常。 但是這個(gè)條件在HotSpot中幾乎很難達(dá)到,因?yàn)樘摂M機(jī)棧所需的空間大小,在編譯期就已經(jīng)確定了,在運(yùn)行期間機(jī)會(huì)很少存在會(huì)發(fā)生Java棧動(dòng)態(tài)擴(kuò)容的情況,所以我們?cè)谏鲜龃a中,采用另一種方式觀測(cè)棧溢出,就是在VMStackOOM()方法中不斷的創(chuàng)建新線程并且持續(xù)保持著這些線程活躍。最終當(dāng)JVM創(chuàng)建某條線程時(shí),在為其分配虛擬機(jī)??臻g的時(shí)候,假設(shè)此時(shí)機(jī)器的內(nèi)存空間已經(jīng)被申請(qǐng)完了,那么此時(shí)就會(huì)出現(xiàn)OOM。

在上述案例中,首先使用了-Xss參數(shù)指定了虛擬機(jī)棧的大小為1MB,但是這種方式不咋靠譜,請(qǐng)慎重運(yùn)行!因?yàn)榇蠖鄶?shù)情況下會(huì)導(dǎo)致你的機(jī)器/電腦操作系統(tǒng)資源耗盡而陷入假死狀態(tài),結(jié)果運(yùn)行如下:

Exception in thread "main" java.lang.OutOfMemoryError: 
            unable to create new native thread
            ........

從上述結(jié)果中可以得知,當(dāng)一直創(chuàng)建線程時(shí)就會(huì)拋出OOM異常,但是這種并不是真正意義上的棧內(nèi)存溢出,只能從某種意義上來說,“勉強(qiáng)”可以被稱為Java棧溢出。因?yàn)槊織lJava線程在創(chuàng)建時(shí),都會(huì)向OS申請(qǐng)資源并映射到一條內(nèi)核線程上,每條Java線程都會(huì)占用一定的內(nèi)存空間,當(dāng)物理內(nèi)存耗盡,OS無法為一條新創(chuàng)建的線程分配內(nèi)存時(shí)就會(huì)出現(xiàn)這個(gè)問題。

其實(shí)如果你想在HotSpot中觀測(cè)到真正的Java棧溢出,實(shí)則還有一種辦法:

在前面論述虛擬機(jī)棧時(shí),曾提到過,虛擬機(jī)棧所需的空間的大多數(shù)情況下在編譯期間就已確定,所以基于這個(gè)準(zhǔn)則,我們幾乎很難在程序中滿足棧溢出的條件。但事情不是絕對(duì)的,我們分析過棧幀之后得知:方法的入?yún)⒃谶\(yùn)行時(shí)會(huì)放在局部變量表中存儲(chǔ),而局部變量表位于棧幀之中,棧幀位于虛擬機(jī)棧當(dāng)中,那如果我們?cè)诰帉懗绦驎r(shí),定義方法的時(shí)候,把方法的入?yún)?shù)量定義成不確定的個(gè)數(shù),這樣的話該方法對(duì)應(yīng)棧幀的所需空間大小編譯期就無法確定了,從而就會(huì)出現(xiàn)虛擬機(jī)棧在運(yùn)行期間申請(qǐng)空間的動(dòng)態(tài)擴(kuò)容情況啦。代碼如下:

/**
 * 測(cè)試虛擬機(jī)棧OOM的方法
 * JVM啟動(dòng)參數(shù):-Xss256k
 */
public static void VMStackOOM(long... l) {}

如上方法中,入?yún)⒌臄?shù)量就是不確定的,必須要等到具體調(diào)用執(zhí)行時(shí)才能確定到底會(huì)傳入多少個(gè)參數(shù)進(jìn)來,而我們此時(shí)使用-Xss指定了棧大小為256kb,一個(gè)long類型的入?yún)⑺伎臻g為8bytes,256kb=(1024*256)bytes,理論上在調(diào)用VMStackOOM()方法時(shí),往該方法中傳遞10w個(gè)long類型的入?yún)?,是肯定可以觀測(cè)到Java棧OOM的情況的。

但你問我為什么不貼執(zhí)行結(jié)果,因?yàn)闉橐粋€(gè)方法傳遞10w個(gè)參數(shù)是個(gè)大工程,有興趣的可以自己去嘗試~

2.2.3、虛擬機(jī)棧OOM原因及解決方案

虛擬機(jī)棧這塊區(qū)域出現(xiàn)OOM的原因大多數(shù)情況下就只存在兩種,一種是無限遞歸導(dǎo)致產(chǎn)生大量棧幀引發(fā)的問題,另外一種則是無限創(chuàng)建新線程導(dǎo)致耗盡了物理內(nèi)存拋出的問題。其實(shí)這兩種并不算真正意義上的虛擬機(jī)棧OOM,前者被稱為SOF問題,后者則是因?yàn)橘Y源耗盡導(dǎo)致的。

  • SOF問題:
    • 產(chǎn)生原因:一般是因?yàn)闊o限遞歸導(dǎo)致的。
    • 解決方案:優(yōu)化代碼,可以使用遞歸,但是不要產(chǎn)生無限遞歸。
  • Unable to create new native thread問題:
    • 產(chǎn)生原因:
      • ①線程數(shù)超過了操作系統(tǒng)最大線程數(shù)ulimit的限制。
      • ②線程數(shù)超過了kernel.pid_max一個(gè)進(jìn)程中規(guī)定的內(nèi)核映射數(shù)。
      • ③申請(qǐng)創(chuàng)建線程時(shí),物理機(jī)內(nèi)存被耗盡,沒有足夠內(nèi)存分配新線程。
    • 解決方案:
      • 升級(jí)硬件配置
      • 使用-Xss縮小Java棧的大小
      • 修改操作系統(tǒng)默認(rèn)參數(shù)

2.3、元數(shù)據(jù)空間和運(yùn)行時(shí)常量池OOM

元數(shù)據(jù)空間主要存儲(chǔ)類名、訪問修飾符、常量池、字段描述、方法描述等信息,對(duì)于測(cè)試元數(shù)據(jù)空間的內(nèi)存溢出基本思路是:在運(yùn)行時(shí)產(chǎn)生大量類字節(jié)碼,從而使得元數(shù)據(jù)空間內(nèi)存被耗盡,從而拋出OOM。案例如下:

public class OOM {
    // 測(cè)試內(nèi)存溢出的對(duì)象類
    public static class OomObject{}
    
    /**
     *  測(cè)試運(yùn)行時(shí)常量池OOM的方法
     * JVM啟動(dòng)參數(shù):-XX:PermSize=10M -XX:MaxPermSize=10M
     * 適用版本:JDK1.6及之前
     */
    public static void RuntimeConstantPoolOOM(){
        // 使用List保持著常量池的引用,避免Full GC回收常量池
        List<String> list = new ArrayList<>();
        // 10MB的PermSize在Integer范圍內(nèi)足夠產(chǎn)生OOM了
        int i = 0;
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }

    /**
     *  測(cè)試元數(shù)據(jù)空間OOM的方法
     *  JVM啟動(dòng)參數(shù):-XX:MetaspaceSize=10M  
     *               -XX:MaxMetaspaceSize=10M  
     *               -XX:+HeapDumpOnOutOfMemoryError
     * */
    public static void MetaSpaceOOM(String[] args){
        while (true) {
            Enhancer enhancer=new Enhancer();
            enhancer.setSuperclass(OomObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) 
                    (o, method, objects, methodProxy)
                    -> methodProxy.invokeSuper(o,args));
            enhancer.create();
        }
    }

    public static void main(String[] args){
        // 調(diào)用測(cè)試元數(shù)據(jù)空間OOM的方法
        MetaSpaceOOM(args);
    }
}

在上述案例中,使用JVM參數(shù)設(shè)定了元數(shù)據(jù)空間的大小為10MB,然后通過enhancer對(duì)象的CGLIB動(dòng)態(tài)代理生產(chǎn)大量的類字節(jié)碼文件填充元數(shù)據(jù)空間,從而最終達(dá)到OOM的效果,運(yùn)行結(jié)果如下:

java.lang.OutOfMemoryError: Metaspace
Dumping heap to java_pid13784.hprof ...
Heap dump file created [4383328 bytes in 0.026 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
        ......

從結(jié)果中可以清晰的看見元數(shù)據(jù)空間OOM的日志:java.lang.OutOfMemoryError: Metaspace。

對(duì)于運(yùn)行時(shí)常量池OOM的測(cè)試,在JDK1.6時(shí),因?yàn)樽址A砍匚挥谶\(yùn)行時(shí)常量池中,所以還比較好測(cè)試,生成大量的字符串即可。但1.7之后,字符串常量池被移入到了堆空間中,這樣就很難使得運(yùn)行時(shí)常量池再發(fā)生OOM的錯(cuò)誤了,但如果有興趣的小伙伴也可以把上述案例中的RuntimeConstantPoolOOM()方法放在1.6的環(huán)境中跑一次,也能夠觀測(cè)到運(yùn)行時(shí)常量池的內(nèi)存溢出。

2.3.1、元數(shù)據(jù)空間OOM的原因及解決方案

元數(shù)據(jù)空間溢出的原因主要存在如下幾種:

  • ①加載的類信息過多,導(dǎo)致OOM
  • ②JIT生成的熱點(diǎn)代碼過多,導(dǎo)致OOM
  • ③運(yùn)行時(shí)常量池溢出,導(dǎo)致OOM

對(duì)于這塊區(qū)域的OOM,因?yàn)槭俏挥诒镜貎?nèi)存的原因,所以一般排查掉由于cglib生成了大量的代理類這種原因?qū)е碌腛OM外,其他情況下一般都是因?yàn)榉峙涞膬?nèi)存不足以支撐運(yùn)行時(shí)產(chǎn)生的數(shù)據(jù)導(dǎo)致的,這種情況下一般通過對(duì)應(yīng)的參數(shù)調(diào)大分配的空間即可。
但如果是因?yàn)閏glib代理導(dǎo)致的OOM,那么可以開啟-XX:+CMSClassUnloadingEnabled-XX:+UseConcMarkSweepGC參數(shù),允許JVM卸載類,因?yàn)槟J(rèn)情況下,JVM是不會(huì)卸載類的,這些動(dòng)態(tài)代理生成的類生命周期很短暫,加載使用一次后可能很長(zhǎng)時(shí)間內(nèi)不會(huì)再使用它們,此時(shí)就可以讓JVM將這些類自動(dòng)卸載掉。

2.4、直接內(nèi)存OOM

前面提到過,直接內(nèi)存的空間大小可以通過-XX:MaxDirectMemorySize參數(shù)指定,案例如下:

public class OOM {
    /**
     *  測(cè)試直接內(nèi)存OOM的方法
     * JVM啟動(dòng)參數(shù):-Xmx10M -XX:MaxDirectMemorySize=10M
     */
    public static void DirectMemoryOOM(){
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = null;
        try {
            unsafe = (Unsafe) unsafeField.get(null);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        while (true) {
            // 申請(qǐng)1MB的直接內(nèi)存
            unsafe.allocateMemory(1024*1024);
        }
    }

    public static void main(String[] args){
        // 調(diào)用測(cè)試虛擬機(jī)棧OOM的方法
        DirectMemoryOOM();
    }
}

如上案例中,使用了-XX:MaxDirectMemorySize/-Xmx指定了元數(shù)據(jù)空間大小和堆最大空間大小為10MB,然后使用反射獲取到了Unsafe對(duì)象的allocateMemory()方法在不斷的申請(qǐng)1MB直接內(nèi)存,最終執(zhí)行結(jié)果如下:

Exception in thread "main" java.lang.OutOfMemoryError
    at sun.misc.Unsafe.allocateMemory(Native Method)
    .......

DirectMemoryOOM()方法中,一直在循環(huán)申請(qǐng)直接內(nèi)存使用,但是申請(qǐng)之后沒有釋放,當(dāng)申請(qǐng)到第11次時(shí),分配的直接內(nèi)存空間被耗盡,從而拋出了OOM錯(cuò)誤。

2.4.1、直接內(nèi)存OOM產(chǎn)生原因及解決方案

直接內(nèi)存OOM主要存在兩種原因,一種為申請(qǐng)后沒有合理釋放,在FullGC來臨之前耗盡了分配的所有空間,第二種則是因?yàn)樯暾?qǐng)的內(nèi)存大小超出了直接內(nèi)存的可用內(nèi)存大小。這兩種情況,前者可以盡量保證自己在使用完直接內(nèi)存后手動(dòng)回收,不要依賴JVM的GC機(jī)制管理內(nèi)存,后者則可以通過調(diào)大直接內(nèi)存的空間大小,確保有足夠的內(nèi)存使用。

三、內(nèi)存泄露(Memory Leak)

內(nèi)存泄露是指程序分配的內(nèi)存由于某些原因未釋放或無法釋放,造成系統(tǒng)內(nèi)存的浪費(fèi)。針對(duì)于Java而言,是指申請(qǐng)的內(nèi)存空間沒有被正確釋放,存儲(chǔ)在該區(qū)域的數(shù)據(jù)使用完后沒有被回收,而指向這塊區(qū)域的直接指針卻不存在了,但還有其他引用可以關(guān)聯(lián)到該區(qū)域,造成數(shù)據(jù)已經(jīng)失效,引用鏈依舊保持,GC無法回收的情況出現(xiàn),最終導(dǎo)致后續(xù)程序里這塊內(nèi)存被永遠(yuǎn)占用(不可達(dá)),內(nèi)存空間就這么一點(diǎn)點(diǎn)被蠶食,最后導(dǎo)致程序運(yùn)行緩慢、內(nèi)存耗盡的問題出現(xiàn)。

舉個(gè)例子:我開了一家POS游戲店,里面有100個(gè)位置,給每個(gè)位置上都準(zhǔn)備了一臺(tái)最新的POS游戲機(jī)。各位小伙伴按照分配的位置依次入座,每人都領(lǐng)一臺(tái)游戲機(jī)開始玩游戲,本來玩完之后是應(yīng)該將自己拿到的游戲機(jī)關(guān)機(jī)放在自己的座位上的,這樣我可以根據(jù)大家的座位號(hào)依次回收每位小伙伴的游戲機(jī),但是有幾個(gè)心懷不軌的家伙玩完之后不關(guān)機(jī),結(jié)果還順走了我的游戲機(jī)跑路了,這樣我就無法根據(jù)座位號(hào)回收這幾臺(tái)游戲機(jī)了,如此我就只剩下了九十多臺(tái)游戲機(jī)給下一次的小伙伴玩,依次類推,每次都發(fā)生幾起"順手牽羊"事件,最后導(dǎo)致我的游戲店中一臺(tái)游戲機(jī)都沒有了....

在Java中典型的內(nèi)存泄露案例是使用ThreadLocal,詳細(xì)可以參考并發(fā)編程中的ThreadLocal分析章節(jié)。除此之外,在Java程序中大量的static成員、未正確關(guān)閉連接、不正確的equals()hashCode()、引用了外部類的內(nèi)部類、非正確的重寫finalize()方法、常量字符串等原因都有可能導(dǎo)致Java應(yīng)用發(fā)生內(nèi)存泄露。

內(nèi)存泄露從發(fā)生方式的角度來看,可以大致被分為四類:

  • ①常發(fā)性內(nèi)存泄漏:這種情況是指發(fā)生內(nèi)存泄露的代碼會(huì)被多次執(zhí)行到,每次執(zhí)行都會(huì)導(dǎo)致一塊內(nèi)存區(qū)域泄露。
  • ②偶發(fā)性內(nèi)存泄漏:發(fā)生內(nèi)存泄漏的代碼只有在某些特定環(huán)境或操作過程下才會(huì)發(fā)生。
  • ③一次性內(nèi)存泄漏:發(fā)生內(nèi)存泄漏的代碼在程序執(zhí)行過程中只會(huì)被執(zhí)行一次,二次執(zhí)行時(shí)卻正常無誤。
  • ④隱式內(nèi)存泄漏:程序在運(yùn)行過程中不停的分配內(nèi)存,但是直到結(jié)束的時(shí)候才釋放內(nèi)存。

相對(duì)來說,不管是那種泄露方式在Java中都比較難碰到,因?yàn)镴ava有完善的GC機(jī)制存在,所以發(fā)生內(nèi)存泄露的幾率很小很小,尤其是在目前的Java新版本中,發(fā)生幾率幾乎為零。不過在早期的JDK版本中發(fā)生內(nèi)存泄露的幾率還是蠻大的,因?yàn)樵缙赟un HotSpot中沒有對(duì)method area進(jìn)行有效回收,從而使得Java程序在執(zhí)行過程中經(jīng)常出現(xiàn)該問題。

在程序拋出OOM問題時(shí),一般是先通過內(nèi)存映像分析工具(如Eclipse Memory Analyzer)對(duì)dump出來的堆轉(zhuǎn)存快照進(jìn)行分析,重點(diǎn)是確認(rèn)內(nèi)存中的對(duì)象是否是必要的,先分清是因?yàn)閮?nèi)存泄漏還是內(nèi)存溢出。
如果是內(nèi)存泄漏,可進(jìn)一步通過工具(如Jrockit等工具)查看泄漏對(duì)象到GC Roots的引用鏈。于是就能找到泄漏對(duì)象時(shí)通過怎樣的路徑與GC Roots相關(guān)聯(lián)并導(dǎo)致垃圾收集器無法自動(dòng)回收。

3.1、重點(diǎn):關(guān)于內(nèi)存溢出的誤區(qū)

先來看這么個(gè)說法:

“在Java中,兩個(gè)對(duì)象相互引用,保持著存活狀態(tài),從而造成引用循環(huán),導(dǎo)致GC機(jī)制無法回收該對(duì)象所占用的內(nèi)存區(qū)域,從而造成了內(nèi)存泄漏?!?/p>

上述這句話聽起來好像沒太大問題,乍一聽?zhēng)缀醮蟛糠秩硕紩?huì)認(rèn)為是正確的,但實(shí)則該說法在Java中并不成立。因?yàn)镴ava中GC判斷算法采用的是可達(dá)性分析算法,對(duì)于根不可達(dá)的對(duì)象都會(huì)判定為垃圾對(duì)象,會(huì)被統(tǒng)一回收。因此,就算在堆中有引用循環(huán)的情況出現(xiàn),也不會(huì)引發(fā)內(nèi)存泄漏問題。

3.2、內(nèi)存溢出與內(nèi)存泄漏的區(qū)別

內(nèi)存溢出: 程序分配到了10MB內(nèi)存,但運(yùn)行過程中產(chǎn)生了11MB數(shù)據(jù)寫入到該空間,這叫做內(nèi)存溢出。

舉例:一個(gè)木桶只能裝40L水,但此時(shí)往里面倒入50L水,多出來的水會(huì)從桶頂溢出。換到程序的內(nèi)存中,這種情況就被稱為內(nèi)存溢出。

內(nèi)存溢出: 你在程序中申請(qǐng)了一塊內(nèi)存,使用了之后之后不會(huì)再使用,但是沒有釋放,而JVM的GC機(jī)制也無法回收這塊區(qū)域,此時(shí)就可以被稱為內(nèi)存泄漏。好比程序中開了一個(gè)流對(duì)象,使用完成之后沒手動(dòng)關(guān)閉,GC機(jī)制也無法回收它,這種情況就是內(nèi)存溢出。

舉例:一個(gè)木桶只能裝40L水,但此刻我往里面丟塊2KG的黃金,那該水桶在之后的過程中,最多只能裝38L的水。此時(shí)這種情況換到程序的內(nèi)存中,就被稱為內(nèi)存泄漏。
(PS:不考慮物體密度的情況,舉例說明不要死磕?。?/p>

四、其他的內(nèi)存溢出問題

在前面介紹OOM時(shí),對(duì)一些常見區(qū)域的內(nèi)存溢出問題做了簡(jiǎn)單介紹,接下來會(huì)介紹幾種平時(shí)難以見到的內(nèi)存溢出情況。

4.1、Out of swap space

Out of swap space代表所有可用的虛擬內(nèi)存已被耗盡,虛擬內(nèi)存是由物理內(nèi)存和交換空間兩部分組成的,當(dāng)運(yùn)行時(shí)程序請(qǐng)求的虛擬內(nèi)存溢出時(shí)就會(huì)拋出該錯(cuò)誤。出現(xiàn)該問題的原因主要有兩個(gè),一個(gè)是地址空間不足,另一個(gè)則是物理內(nèi)存已被耗盡,解決方案一般是只能提升硬件配置。

4.2、Kill process or sacrifice child

Kill process or sacrifice child這種OOM的情況,屬于Linux操作系統(tǒng)拋出的錯(cuò)誤,當(dāng)系統(tǒng)可用內(nèi)存快耗盡時(shí),內(nèi)核的Out of Memory Killer組件會(huì)對(duì)所有進(jìn)程進(jìn)行打分,然后會(huì)嘗試殺死一些評(píng)分低的進(jìn)程,釋放它們占用的內(nèi)存空間來確保擁有足夠的內(nèi)存維護(hù)OS的運(yùn)行。
一般來說,Java程序中是不必?fù)?dān)心遇到這個(gè)問題的,因?yàn)椤按蚍帧边@一操作,會(huì)基于活躍度進(jìn)行,而Java程序部署之后,一般情況下都會(huì)處于持續(xù)運(yùn)行的狀態(tài)。

4.3、Requested array size exceeds VM limit

JVM限制了數(shù)組的最大長(zhǎng)度,該錯(cuò)誤表示程序請(qǐng)求創(chuàng)建的數(shù)組超過最大長(zhǎng)度限制。因?yàn)閿?shù)組這種數(shù)據(jù)結(jié)構(gòu),要求在分配時(shí),物理內(nèi)存必須連續(xù),所以當(dāng)分配一個(gè)巨型數(shù)組時(shí),發(fā)現(xiàn)堆空間中已經(jīng)沒有一塊這么大的連續(xù)空間,并且GC之后還是分配不下,那么就會(huì)拋出Requested array size exceeds VM limit錯(cuò)誤。

如果你在程序中,遇到了這種問題,那么一般都是需要從業(yè)務(wù)上進(jìn)行拆分,對(duì)于如此巨大的數(shù)組可以分為多次查詢,將其分割為多個(gè)不同的小數(shù)組分配即可。

五、總結(jié)

本篇主要是對(duì)于JVM的內(nèi)存區(qū)域以及每個(gè)區(qū)域運(yùn)行時(shí)會(huì)出現(xiàn)的問題進(jìn)行全面分析,對(duì)于內(nèi)存溢出和內(nèi)存泄露問題,在線上環(huán)境出現(xiàn)時(shí),排查的過程往往會(huì)比我們所描述的要復(fù)雜很多,但理清思路,清楚細(xì)節(jié)后自然可以排查掉遇到的一些問題。當(dāng)然,同時(shí)也要學(xué)會(huì)使用各種JVM工具,如Eclipse Memory Analyzer、ARMS、Arthas以及JDK自帶的一些工具等。

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

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

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