(四)JVM成神路之深入理解虛擬機運行時數據區與內存溢出、內存泄露剖析

引言

前面的文章中重點是對于JVM的子系統進行分析,在之前已經詳細的闡述了虛擬機的類加載子系統以及執行引擎子系統,而本篇則準備對于JVM運行時的內存區域以及JVM運行時的內存溢出與內存泄露問題進行全面剖析。

一、全面詳解JVM運行時內存區域

JVM在運行Java程序時,會把自身管理的內存分為若干個不同的數據區域,這些區域各自都有各自的用途,同時,不同的區域也有著不同的生命周期,有些區域隨著虛擬機的啟動而開辟,隨著虛擬機的終止而銷毀,有的區域則是在運行過程中不斷的創建與銷毀。

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

JVM運行時數據區

下面會分別從線程私有和線程共享兩個角度對JVM的每個內存區域進行闡述,

1.1、線程私有區

線程私有區的含義是指:對于每條線程而言,在創建它們時,JVM都會為它們分配的區域,這些內存區域的生命周期會隨著線程的啟動、死亡而創建和銷毀。這些區域創建后,其他線程是不可見的,只有當前線程自身可以訪問。

運行時數據區中的線程私有區域主要包含:程序計數器、虛擬機棧以及本地方法棧。

1.1.1、程序計數器(Progran Counter Register)

程序計數器是JVM為每條線程開辟的一塊較小的區域,每條線程都有且只有一個程序計數器,線程之間不相互干擾。生命周期與線程一致,隨線程啟動而生,線程銷毀而亡。同時也是JVM所有內存區域中唯一不會發生OOM(OutOfMemoryError/內存溢出)的區域,GC機制不會觸及的區域。

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

1.1.2、虛擬機棧(Stack)

虛擬機棧也被稱為Java棧,在JVM的內存區域中,棧主要是作為運行時執行的單位,棧的作用是負責程序運行時具體如何執行、如何處理數據等工作。生命周期與線程一致,每個線程創建時都會為之創建一個虛擬機棧。

當線程在執行一個Java方法時,都會為執行的方法生產一個棧幀(Stack Frame,每個Java方法的調用到執行結束,對應著虛擬機棧中的一個棧幀的從入棧到出棧的過程,一個棧幀需要分配多大的內存空間,在編譯器就已經確定了,不會受到運行時變量數據的大小影響。對于執行引擎而言,它只會對位于棧頂的棧幀元素(被稱為當前棧幀)進行操作,與當前棧幀關聯的方法被稱為當前方法。

一個棧幀中主要包含局部變量表、操作數棧、動態鏈接、方法出口等信息,接下來依次對它們進行分析。

1.1.2.1、局部變量表

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

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

局部變量表中存儲的數據只對于當前方法中有效,虛擬機在執行時,依靠于操作數棧與局部變量表中存儲的數據完成執行操作。方法執行結束后,局部變量表會隨著棧幀的的出棧/銷毀而隨之銷毀。一般而言,如果當前方法屬于構造方法或實例方法,那么這些方法的局部變量表中下標為0的槽位必然存儲的是this引用,也就是局部變量表中的第一個位置會被用來放當前方法所屬的對象引用,其他的局部變量會按照順序在局部變量表中進行存儲。如下圖:

局部變量表結構

PS:值得注意的是:局部變量表中的槽位空間是可以被重復使用的,當局部變量表的一個數據失去作用并沒有保持引用關系時,虛擬機會嘗試將原本存儲該數據的槽位用于分配新的數據,來個案例理解一下:

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

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

局部變量表初次分配

根據前面的代碼進行執行,經過初次分配后的局部變量表應該是上圖所示的情況,按照原本的邏輯來說,int類型的變量c,應該會被分配到第六個槽位,也就是下標索引為5的位置,但實際上因為我們在如上Java程序中,對obj變量進行了置空操作,也就代表著局部變量表中存儲obj這個引用的數據槽位不會再被使用,所以虛擬機會嘗試復用該槽,如下:
槽位復用

當需要為整數型的變量c分配槽位時,會直接將c分配到第五個槽位,也就是原本存儲obj引用指針的位置。不過值得注意一提的是:這里是直接替換掉了原本槽位的數據,而不是先將原本槽位的數據移出。

局部變量表中的對象引用信息是在后續GC篇章中,一個重要的GC根節點,一個堆中的對象只要在一個局部變量表中被直接或間接的引用著,那么GC觸發時就不會回收這個堆中對象。

同時,基于性能調優而言,在棧幀中與之關聯的最密切的部分,就是局部變量表,方法執行時,虛擬機使用局部變量表完成方法的傳遞。

1.1.2.2、操作數棧(Operand Stack)

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

在執行一個方法時,首先會先創建一個與該方法對于的棧幀,該棧幀中的操作數棧最初是空的,在執行過程中,會根據字節碼指令往棧中寫入(入棧)和提取(出棧)數據。操作數棧的主要目的是用于保存計算過程的中間結果,同時作為計算過程中變量臨時的存儲空間。

與前面的局部變量表一樣,操作數棧也是一個由32bit為單位的字節數組構成的,操作數棧中可支持存儲的數據類型主要有:int、long、float、double、reference、returnType等類型,對于byte、short、char類型的數據會在入棧前被轉為int類型放入棧中存儲。
但與局部變量表不同的是:局部變量表是通過下標索引去訪問存儲的數據,而操作數棧中則是通過標準的壓棧、出棧的方式完成數據訪問。

同時因為操作數棧在運行時是位于內存中的,頻繁的去對內存進行讀寫操作會影響執行速度,所以實際在執行過程中,虛擬機會將棧頂元素全部緩存到物理CPU的寄存器或高速緩存(L1/L2/L3)中,以此降低對內存的讀寫次數,從而提升執行引擎的執行效率。

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

操作數棧計算案例

1.1.2.3、動態鏈接(Dynamic Linking)

虛擬機棧中的每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態鏈接(比如invokedynamic指令的調用)。

在Java源文件被編譯成Class文件時,類中所有的變量、方法調用都會化為符號引用,然后保存在class文件的常量池中,在class文件中描述一個方法調用另一個方法時,就使用常量池中指向方法的符號引用來表示的。動態鏈接的作用就是為了將這些符號引用轉換為調用方法的直接引用。

常量池:位于編譯后生成的class字節碼文件中。
運行時常量池:位于運行期間的元數據空間/方法區中。

1.1.2.4、方法出口(Return Address)

一個方法當開始被執行引擎執行時,只有兩種情況會導致方法退出,一種是在執行過程中遇到了正常返回的字節碼指令,如:ireturn、lreturn、dreturn、areturn、return,釋義如下:

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

方法正常執行完成后退出的情況被稱為正常完成出口,一般執行返回的字節碼指令時,調用者的程序計數器會被作為返回的地址。

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

當一個方法執行結束退出時,會執行如下步驟:

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

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

1.1.2.6、虛擬機棧的特點與運行原理

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

虛擬機棧這塊內存區域不存在垃圾回收,但是存在OOM,在《Java虛擬機規范》中,對這個區域規定了兩種異常:

  • StackOverflowError:當前線程請求的棧深度大于虛擬機棧所允許的深度時拋出該異常。
  • OutOfMemoryError:如果擴展時無法申請到足夠的內存空間會拋出OOM異常。

對于每條線程的虛擬機棧大小可以通過-Xss參數進行調整,默認單位為字節,默認大小為1MB/1024KB/1048576字節

JVM運行期間,每條線程都擁有自己獨立的虛擬機棧(線程棧),當前線程棧中的數據以棧幀的格式進行存儲,當前線程正在執行的每一個方法都會在虛擬機棧中生成一個對應的棧幀,如下案例:

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

當一條線程執行方法a()時,它的虛擬機棧情況如下:

線程執行a()方法時的棧結構

對于這條線程而言,棧中的所有棧幀在同一時刻時,只會存在一個活動棧幀,也就是位于棧頂的棧幀,也就是我們前面所說的當前棧幀。執行引擎執行時,只會執行當前棧幀的字節碼指令,如果執行當前方法時,在其中調用了其他方法,那么另外一個方法對應的棧幀會被創建出來,放在頂端,從而成為新的當前幀,接著執行引擎會去執行新幀,當該幀執行結束時,會傳回此方法的執行結果給前一個棧幀,也就是上層調用者,比如上述案例中a()就是b()的上層調用者,接著虛擬機會丟棄當前棧幀,使得前一個棧幀重新成為棧頂的當前幀。這個過程會不斷重復,直至一條方法調用鏈結束或因為異常中斷,才會停止。

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

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

因為是c編寫的本地方法,所以本地方法庫中的Native方法會被編譯為基于本機硬件和操作系統的程序。本地方法執行是在os中執行的,并非在JVM中執行的,所以使用的是os的程序計數器而非JVM的程序計數器,當開始執行一個本地方法時,就會進入不再受虛擬機限制的環境,級別與虛擬機一樣,可以直接訪問JVM的任何內存區域,也可以直接使用CPU處理器的寄存器和本地內存等。而本地方法棧只是存儲了線程要運行這個方法的必要信息,比如出口,入口,動態鏈接,局部變量表,操作數棧等。

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

1.2、線程共享區

線程共享的含義是指:在運行時,這些區域對于程序中的所有線程而言都是可見的,這些區域的狀態不會因為某一條線程的死亡而發生改變,這些區域創建后是與JVM同級別的,伴隨JVM的生命周期共生共死。

運行時數據區中的線程共享去主要包含:堆空間、元數據空間(方法區)以及直接內存這三大塊。

1.2.1、Java堆空間(Heap)

在Java內存中,堆空間也是最重要的一塊區域,大部分的JVM調優手段都是基于堆空間而進行展開的。Java堆的作用與前面分析的Java棧不同,棧主要是作為運行時的單位,用于臨時存儲運行時需要以及產生的數據,而Java堆是存儲的單位,主要解決的問題是數據存儲問題,重點關注的領域是數據怎么存,放哪里,怎么放等。

堆空間會在JVM啟動時被創建出來,對于JVM來說,堆空間是唯一的,每個JVM只會存在一個堆空間,同時容量大小會在創建時就被確定,當然,我們可以通過參數-Xms-Xmx指定堆的起始內存大小和最大內存大小,當超過-Xmx參數指定的大小時則會拋出OOM

默認情況下,如果不通過參數強制指定堆空間大小,那么JVM會根據當前所在的平臺進行自適應調整,起始大小默認為當前物理機器內存的1/64,最大大小默認為當前物理機器內存的1/4。

在Java程序運行時,系統運行過程中產生的大部分實例對象以及數組對象都會被放到堆中存儲。

創建Java堆時,本質上并不是直接在內存中劃分了一塊完整的空間給JVM,因為在《Java虛擬機規范》中提及到:堆空間在物理上可以是不連續的,只需要邏輯上視為連續即可。所以一個JVM的堆空間在實際的機器內存上,可能是由機器內存中多個不同位置的空間組成的,如下圖:

堆空間組成

Java堆同時也是變化比較頻繁的區域,在不同Java版本中,堆空間也發生了不同的改變:

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

本質上來說,影響堆空間結構的并不是Java版本的不同,Java堆結構是跟JVM運行時所使用的垃圾回收器息息相關的,由GC器決定了運行時的堆空間會被劃分為何種結構。

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

1.2.1.1、分代堆空間

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

1.2.1.2、不分代堆空間

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

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

下面簡單敘述一下不同版本的堆空間結構,具體的會在GC篇章中進行闡述。

1.2.1.3、JDK7及之前的堆空間內存劃分

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

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

JDK7及之前的堆構成

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

默認情況下新生代和年老代的空間比例為1:2,新生代占1/3,年老代占2/3,當然也可以通過參數:-XX:NewRatio=x來指定比例,也可以通過-Xmn參數強制指定新生代的內存最大大小,如果和前面的Ratio參數沖突了則以后者為準。
新生代中,一個Eden區、兩個Survivor區(Form/To區),默認比例為8:1:1,當然也可以通過參數-XX:SurvivorRatio調整這個空間比例。但實際上初始情況下是6:1:1,因為JVM存在自適應機制,當然也可以通過-XX:-UseAdaptiveSizePolicy參數關閉JVM的自適應機制(不推薦)。

1.2.1.4、JDK8堆空間內存劃分

到了JDK1.8的時候,JVM將永久代,也就是方法區整合成了元數據空間,并且將其移出了堆,將其放在堆空間外的本地內存中。

JDK8的堆構成

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

1.2.1.5、JDK9堆空間內存劃分

到了JDK1.9時,堆空間慢慢的開始了劃時代的改變,在此之前,堆空間的布局都是采用分代存儲的方式,無論從邏輯上還是從物理內存上,都是分代的。但是到了Java9的時候,因為默認GC器改為了G1,所以堆中的內存區域被劃為了一個個的Region區。

JDK9的內存布局

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

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

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

G1中的年老代晉升條件和之前的無差,達到年齡閾值的對象會被轉入年老代的Region區中,不同的是對于大對象的分配,在G1中不會讓大對象進入年老代,在G1中由專門存放大對象的Region區叫做Humongous區,如果在分配對象時,判定出一個對象屬于大對象,那么則會直接將其放入Humongous區存儲。

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

Humongous區存在的意義:可以避免一些“短命”的巨型對象直接進入年老代,節約年老代的內存空間,可以有效避免年老代因空間不足時的GC開銷。

當堆空間發生全局GC(FullGC)時,除開回收新生代和年老代之外,也會對Humongous區進行回收。

1.2.1.6、JDK11堆空間內存劃分

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

JDK11的堆結構

在ZGC中,也會把堆空間劃分為一個個的Region區域,但ZGC中的Region區不存在分代的概念,它僅僅只是簡單的將所有Region區分為了大、中、小三個等級:

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

PS:實際上,JDK11中的ZGC并不是因為要拋棄分代理念而不設計分代的堆空間的,因為實際上最開始分代理念被提出的本質原因是源于「大部分對象朝生夕死」這個概念的,而實際上大部分Java程序在運行時都符合這個現象,所以邏輯分代+物理不分代是堆空間最好的結構方案。但問題在于:ZGC為何不設計出分代的堆空間結構呢?其實本質原因是分代實現起來非常麻煩且復雜,所以就先實現出一個比較簡單可用的單代版本,后續可能會優化改進(但實際上能不能改進成功還不好說,ZGC的研發團隊負責人Per是從JRockitGC組過來的,R大在和per聊天時曾聊到過:per之前在JRockitGC器上嘗試了四五次都以失敗告終,ZGC上能不能成功還是得看未來了)。

1.2.1.7、堆總結

Java堆空間是JVM運行時內存區域中占比最大的一塊,此內存區域唯一的目的就是存儲運行時創建出的對象實例。同時,隨著運行時采用的GC器不同,Java堆也會被分為不同的結構,其中主要可分為分代和不分代的兩類結構。相對來說,分代結構是最適合Java對象“朝生夕死”的特性的,如果堆結構是分代的,可以使得JVM能夠更好的管理堆內存中的對象,包括內存的分配以及回收。

1.2.2、本地內存

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

1.2.2.1、元數據空間(Metaspace)

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

方法區也就是所謂的永久代/持久代,方法區中主要存儲了可以通過反射機制拿到的所有數據,如Class類信息、Method方法信息、Filed字段信息,方法區需要多少的空間具體會取決于JVM運行時會加載多少類,因為經過類加載后的Class文件會生成類的元數據,然后將其存儲在這塊區域。當然,當一個類被卸載時,該類數據占用的空間也會在FullGC發生時伴隨一起釋放。
方法區主要存儲的數據:類的元數據、VM內部表、類的層級信息/方法信息/字段信息、方法的編譯信息和字節碼數據、靜態變量、常量池以及符號引用。
在JDK1.7時,方法區的默認最大空間為64MB,也可以通過參數-XX:MaxPermSize調整。

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

OK,簡單的看了一下方法區的描述之后,接著可以來看看元數據空間了。當然,如果你想知道具體方法區中存什么,那么可以看這個

元數據空間則是1.8移除掉方法區之后的產物,主要用于存放運行時常量池和類信息,如下:

元數據空間

而之前方法區運行時常量池中的字符串常量池則被放置在了堆中,因為在程序運行過程中會隨著運行時間的增加,字符串常量池中的字符串會越來越多,所占空間會越來越大,所以將其放在堆中的好處在于:使得字符串常量池在GC機制的范圍之內,字符串也會存在回收操作。
同時除開字符串常量池被挪動到了堆內之外,類的靜態變量的存儲也被放在了堆中。對比如下:
JDK1.6/1.7/1.8變化

1.2.2.2、直接內存

直接內存這塊區域不是虛擬機的內存區域,在《Java虛擬機規范》中也沒有定義,在創建時會直接向操作系統申請內存空間,屬于直接使用物理內存的一塊區域,也被稱為“堆外空間”。

對比堆空間而言,訪問直接內存的速度會超出堆內存,也就是讀寫性能優于Java堆,來源于Java的NIO庫,Java的NIO可以允許Java程序直接使用本地的直接內存存儲數據緩沖,因為如果把一些文件數據轉為對象存儲在堆中時,很容易導致堆空間負載過重而OOM。所以出于性能和穩定性兩方面的考慮,一般對于一些讀寫頻繁的場景或讀取/寫出大文件時的場景都可以使用直接內存進行操作。

如果程序中需要用到直接內存時可以通過java.nio.ByteBuffer來創建,調用allocateDirect方法申請即可,同時可以通過存在堆中的DirectByteBuffer操作直接內存。

直接內存的最大空間值可以通過-XX:MaxDirectMemorySize設置,如果不指定則默認與-Xmx參數設置的空間大小一致。直接內存屬于比較昂貴的資源,因為需要直接向OS申請,所以分配成本較高,并且創建出來之后也不受JVM的直接控制,所以GC機制對于這塊區域的內存空間難以管理,只有當發生FullGC時才會對于這塊區域進行回收。

同時這塊區域是也會出現OOM的,因為物理機的內存終歸是有限的,受到硬件的限制,所以如果一直向操作系統申請直接內存使用,完事后JVM的GC機制又無法有效回收使用過的內存,可能在下一次FullGC到來之前就會將物理機分配的內存空間申請耗盡,從而引發OOM。

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

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);//執行垃圾回收
//     System.gc(); //執行Full gc進行垃圾回收
      System.out.println("end"); 
      sleep(5000); 
  } 
}

當使用完成申請的內存空間后,可以手動調用clean()方法進行內存的回收釋放。

二、內存溢出OOM(OutOfMemory)

OOM這個詞在不少篇章中都曾反復提及,它的具體含義是指OutOfMemoryError內存溢出錯誤。在JVM的運行時數據區中,除開程序計數器之外,其他的區域都會存在內存溢出的風險,下面依次進行舉例分析。

2.1、Java堆空間OOM

前面分析內存區域時曾談到:Java堆空間是用于存儲對象實例和數組數據的內存區域,同時JVM的GC機制也會重點對于這塊區域進行內存管理。但是如果內存不足發生GC時,堆中的對象都還存活,此時又沒有足夠的內存分配新的對象實例,最終堆空間就會出現OOM,如下案例:

public class OOM {
    // 測試內存溢出的對象類
    public static class OomObject{}

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

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

如上案例,在程序啟動時使用參數-Xms指定JVM堆空間的初始大小為10MB,同時為了防止內存不足時動態擴容,我們也通過-Xmx指定了堆空間的最大大小為10MB,然后在HeapOOM方法中使用死循環反復往集合中添加OomObject對象實例,

-XX:+HeapDumpOnOutOfMemoryError:可以讓虛擬機在出現內存溢出異常時Dump出內存堆運行時快照,可以使用VisualVM堆快照進行分析(后續GC篇章會用到,本篇不做詳細介紹)。

最終程序執行結果如下:

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
    .......

才能上述結果中,可以清晰的看見java.lang.OutOfMemoryError: Java heap space這一行信息,從這行信息中可以得知:目前程序執行出現了內存溢出,而溢出的區域為Java堆空間。

2.1.1、線上環境堆空間OOM的原因

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

其實本質上來說,線上環境引發Java堆OOM的原因有很多,但歸根到底就那幾個:
一、程序正常運行,堆中存活對象過多無法回收,新對象沒有內存分配導致的。
二、代碼中存在不規范的語法,因代碼原因導致運行過程中出現OOM,如無限遞歸/死循環/用完后不釋放等。
三、運行過程中出現了內存泄露,泄露問題一點點將內存蠶食掉了,導致最終可用內存變得很小,從而誘發OOM。

2.1.2、線上環境堆OOM問題排查

一般而言,線上環境出現問題后,總會分為固定的幾個步驟,從發現問題出發,慢慢到后續的排查問題、定位問題、解決問題、嘗試最優解、適當考慮拓展性,這是解決問題的一條完整鏈路。

如前面的堆空間OOM問題,從發生問題之后,首先應該通過相關的一些JVM工具,對日志進行dump分析,定位出可能發生該問題的幾個可疑位置,然后對這些位置依次進行排查,最終定位到具體是由于什么原因導致的OOM,再“對癥下藥”,堆OOM問題解決方案一般有以下幾種:

  • ①如果確定是代碼問題,則通過工具定位到具體的代碼,然后對代碼進行改正即可。
  • ②如果確實是所分配的堆空間無法保障JVM的正常運行了,那么應該分配更大的堆空間。
  • ③如果是因為內存泄露導致的OOM,那么則應該進一步定位內存泄露出現的原因,然后進行對應的解決。

2.1.3、GC overhead limit exceeded

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

2.2、虛擬機棧和本地方法棧OOM

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

  • StackOverflowError:當前線程請求的棧深度大于虛擬機棧所允許的深度時拋出該異常。
  • OutOfMemoryError:如果擴展時無法申請到足夠的內存空間會拋出OOM異常。

2.2.1、虛擬機棧SOF問題測試

先上代碼:

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

    public static void main(String[] args){
        // 調用測試虛擬機棧SOF的方法
        VMStackSOF();
    }
}

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

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

從結果中可以很明顯的看出SOF問題,因為前面通過參數設定了每條線程的虛擬機棧空間為128K,所以在VMStackSOF()方法的不斷遞歸下,程序最終拋出了java.lang.StackOverflowError錯誤。在運行過程中,一條線程在執行一個方法時,無論是棧幀太大還是虛擬機棧容量太小,當無法分配內存時都會拋出SOF問題。

2.2.2、虛擬機棧OOM問題測試

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

    // !!!慎重運行,大多數情況下會導致OS假死!!!
    public static void main(String[] args){
        // 調用測試虛擬機棧OOM的方法
        VMStackOOM(); 
    }
}

其實Java棧的OOM是很難觀測到的,因為棧OOM的條件為:如果棧空間擴展時無法申請到足夠的內存空間會拋出OOM異常。 但是這個條件在HotSpot中幾乎很難達到,因為虛擬機棧所需的空間大小,在編譯期就已經確定了,在運行期間機會很少存在會發生Java棧動態擴容的情況,所以我們在上述代碼中,采用另一種方式觀測棧溢出,就是在VMStackOOM()方法中不斷的創建新線程并且持續保持著這些線程活躍。最終當JVM創建某條線程時,在為其分配虛擬機棧空間的時候,假設此時機器的內存空間已經被申請完了,那么此時就會出現OOM。

在上述案例中,首先使用了-Xss參數指定了虛擬機棧的大小為1MB,但是這種方式不咋靠譜,請慎重運行!因為大多數情況下會導致你的機器/電腦操作系統資源耗盡而陷入假死狀態,結果運行如下:

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

從上述結果中可以得知,當一直創建線程時就會拋出OOM異常,但是這種并不是真正意義上的棧內存溢出,只能從某種意義上來說,“勉強”可以被稱為Java棧溢出。因為每條Java線程在創建時,都會向OS申請資源并映射到一條內核線程上,每條Java線程都會占用一定的內存空間,當物理內存耗盡,OS無法為一條新創建的線程分配內存時就會出現這個問題。

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

在前面論述虛擬機棧時,曾提到過,虛擬機棧所需的空間的大多數情況下在編譯期間就已確定,所以基于這個準則,我們幾乎很難在程序中滿足棧溢出的條件。但事情不是絕對的,我們分析過棧幀之后得知:方法的入參在運行時會放在局部變量表中存儲,而局部變量表位于棧幀之中,棧幀位于虛擬機棧當中,那如果我們在編寫程序時,定義方法的時候,把方法的入參數量定義成不確定的個數,這樣的話該方法對應棧幀的所需空間大小編譯期就無法確定了,從而就會出現虛擬機棧在運行期間申請空間的動態擴容情況啦。代碼如下:

/**
 * 測試虛擬機棧OOM的方法
 * JVM啟動參數:-Xss256k
 */
public static void VMStackOOM(long... l) {}

如上方法中,入參的數量就是不確定的,必須要等到具體調用執行時才能確定到底會傳入多少個參數進來,而我們此時使用-Xss指定了棧大小為256kb,一個long類型的入參所占空間為8bytes256kb=(1024*256)bytes,理論上在調用VMStackOOM()方法時,往該方法中傳遞10wlong類型的入參,是肯定可以觀測到Java棧OOM的情況的。

但你問我為什么不貼執行結果,因為為一個方法傳遞10w個參數是個大工程,有興趣的可以自己去嘗試~

2.2.3、虛擬機棧OOM原因及解決方案

虛擬機棧這塊區域出現OOM的原因大多數情況下就只存在兩種,一種是無限遞歸導致產生大量棧幀引發的問題,另外一種則是無限創建新線程導致耗盡了物理內存拋出的問題。其實這兩種并不算真正意義上的虛擬機棧OOM,前者被稱為SOF問題,后者則是因為資源耗盡導致的。

  • SOF問題:
    • 產生原因:一般是因為無限遞歸導致的。
    • 解決方案:優化代碼,可以使用遞歸,但是不要產生無限遞歸。
  • Unable to create new native thread問題:
    • 產生原因:
      • ①線程數超過了操作系統最大線程數ulimit的限制。
      • ②線程數超過了kernel.pid_max一個進程中規定的內核映射數。
      • ③申請創建線程時,物理機內存被耗盡,沒有足夠內存分配新線程。
    • 解決方案:
      • 升級硬件配置
      • 使用-Xss縮小Java棧的大小
      • 修改操作系統默認參數

2.3、元數據空間和運行時常量池OOM

元數據空間主要存儲類名、訪問修飾符、常量池、字段描述、方法描述等信息,對于測試元數據空間的內存溢出基本思路是:在運行時產生大量類字節碼,從而使得元數據空間內存被耗盡,從而拋出OOM。案例如下:

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

    /**
     *  測試元數據空間OOM的方法
     *  JVM啟動參數:-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){
        // 調用測試元數據空間OOM的方法
        MetaSpaceOOM(args);
    }
}

在上述案例中,使用JVM參數設定了元數據空間的大小為10MB,然后通過enhancer對象的CGLIB動態代理生產大量的類字節碼文件填充元數據空間,從而最終達到OOM的效果,運行結果如下:

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
        ......

從結果中可以清晰的看見元數據空間OOM的日志:java.lang.OutOfMemoryError: Metaspace

對于運行時常量池OOM的測試,在JDK1.6時,因為字符串常量池位于運行時常量池中,所以還比較好測試,生成大量的字符串即可。但1.7之后,字符串常量池被移入到了堆空間中,這樣就很難使得運行時常量池再發生OOM的錯誤了,但如果有興趣的小伙伴也可以把上述案例中的RuntimeConstantPoolOOM()方法放在1.6的環境中跑一次,也能夠觀測到運行時常量池的內存溢出。

2.3.1、元數據空間OOM的原因及解決方案

元數據空間溢出的原因主要存在如下幾種:

  • ①加載的類信息過多,導致OOM
  • ②JIT生成的熱點代碼過多,導致OOM
  • ③運行時常量池溢出,導致OOM

對于這塊區域的OOM,因為是位于本地內存的原因,所以一般排查掉由于cglib生成了大量的代理類這種原因導致的OOM外,其他情況下一般都是因為分配的內存不足以支撐運行時產生的數據導致的,這種情況下一般通過對應的參數調大分配的空間即可。
但如果是因為cglib代理導致的OOM,那么可以開啟-XX:+CMSClassUnloadingEnabled-XX:+UseConcMarkSweepGC參數,允許JVM卸載類,因為默認情況下,JVM是不會卸載類的,這些動態代理生成的類生命周期很短暫,加載使用一次后可能很長時間內不會再使用它們,此時就可以讓JVM將這些類自動卸載掉。

2.4、直接內存OOM

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

public class OOM {
    /**
     *  測試直接內存OOM的方法
     * JVM啟動參數:-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) {
            // 申請1MB的直接內存
            unsafe.allocateMemory(1024*1024);
        }
    }

    public static void main(String[] args){
        // 調用測試虛擬機棧OOM的方法
        DirectMemoryOOM();
    }
}

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

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

DirectMemoryOOM()方法中,一直在循環申請直接內存使用,但是申請之后沒有釋放,當申請到第11次時,分配的直接內存空間被耗盡,從而拋出了OOM錯誤。

2.4.1、直接內存OOM產生原因及解決方案

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

三、內存泄露(Memory Leak)

內存泄露是指程序分配的內存由于某些原因未釋放或無法釋放,造成系統內存的浪費。針對于Java而言,是指申請的內存空間沒有被正確釋放,存儲在該區域的數據使用完后沒有被回收,而指向這塊區域的直接指針卻不存在了,但還有其他引用可以關聯到該區域,造成數據已經失效,引用鏈依舊保持,GC無法回收的情況出現,最終導致后續程序里這塊內存被永遠占用(不可達),內存空間就這么一點點被蠶食,最后導致程序運行緩慢、內存耗盡的問題出現。

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

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

內存泄露從發生方式的角度來看,可以大致被分為四類:

  • ①常發性內存泄漏:這種情況是指發生內存泄露的代碼會被多次執行到,每次執行都會導致一塊內存區域泄露。
  • ②偶發性內存泄漏:發生內存泄漏的代碼只有在某些特定環境或操作過程下才會發生。
  • ③一次性內存泄漏:發生內存泄漏的代碼在程序執行過程中只會被執行一次,二次執行時卻正常無誤。
  • ④隱式內存泄漏:程序在運行過程中不停的分配內存,但是直到結束的時候才釋放內存。

相對來說,不管是那種泄露方式在Java中都比較難碰到,因為Java有完善的GC機制存在,所以發生內存泄露的幾率很小很小,尤其是在目前的Java新版本中,發生幾率幾乎為零。不過在早期的JDK版本中發生內存泄露的幾率還是蠻大的,因為早期Sun HotSpot中沒有對method area進行有效回收,從而使得Java程序在執行過程中經常出現該問題。

在程序拋出OOM問題時,一般是先通過內存映像分析工具(如Eclipse Memory Analyzer)對dump出來的堆轉存快照進行分析,重點是確認內存中的對象是否是必要的,先分清是因為內存泄漏還是內存溢出。
如果是內存泄漏,可進一步通過工具(如Jrockit等工具)查看泄漏對象到GC Roots的引用鏈。于是就能找到泄漏對象時通過怎樣的路徑與GC Roots相關聯并導致垃圾收集器無法自動回收。

3.1、重點:關于內存溢出的誤區

先來看這么個說法:

“在Java中,兩個對象相互引用,保持著存活狀態,從而造成引用循環,導致GC機制無法回收該對象所占用的內存區域,從而造成了內存泄漏。”

上述這句話聽起來好像沒太大問題,乍一聽幾乎大部分人都會認為是正確的,但實則該說法在Java中并不成立。因為Java中GC判斷算法采用的是可達性分析算法,對于根不可達的對象都會判定為垃圾對象,會被統一回收。因此,就算在堆中有引用循環的情況出現,也不會引發內存泄漏問題。

3.2、內存溢出與內存泄漏的區別

內存溢出: 程序分配到了10MB內存,但運行過程中產生了11MB數據寫入到該空間,這叫做內存溢出。

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

內存溢出: 你在程序中申請了一塊內存,使用了之后之后不會再使用,但是沒有釋放,而JVM的GC機制也無法回收這塊區域,此時就可以被稱為內存泄漏。好比程序中開了一個流對象,使用完成之后沒手動關閉,GC機制也無法回收它,這種情況就是內存溢出。

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

四、其他的內存溢出問題

在前面介紹OOM時,對一些常見區域的內存溢出問題做了簡單介紹,接下來會介紹幾種平時難以見到的內存溢出情況。

4.1、Out of swap space

Out of swap space代表所有可用的虛擬內存已被耗盡,虛擬內存是由物理內存和交換空間兩部分組成的,當運行時程序請求的虛擬內存溢出時就會拋出該錯誤。出現該問題的原因主要有兩個,一個是地址空間不足,另一個則是物理內存已被耗盡,解決方案一般是只能提升硬件配置。

4.2、Kill process or sacrifice child

Kill process or sacrifice child這種OOM的情況,屬于Linux操作系統拋出的錯誤,當系統可用內存快耗盡時,內核的Out of Memory Killer組件會對所有進程進行打分,然后會嘗試殺死一些評分低的進程,釋放它們占用的內存空間來確保擁有足夠的內存維護OS的運行。
一般來說,Java程序中是不必擔心遇到這個問題的,因為“打分”這一操作,會基于活躍度進行,而Java程序部署之后,一般情況下都會處于持續運行的狀態。

4.3、Requested array size exceeds VM limit

JVM限制了數組的最大長度,該錯誤表示程序請求創建的數組超過最大長度限制。因為數組這種數據結構,要求在分配時,物理內存必須連續,所以當分配一個巨型數組時,發現堆空間中已經沒有一塊這么大的連續空間,并且GC之后還是分配不下,那么就會拋出Requested array size exceeds VM limit錯誤。

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

五、總結

本篇主要是對于JVM的內存區域以及每個區域運行時會出現的問題進行全面分析,對于內存溢出和內存泄露問題,在線上環境出現時,排查的過程往往會比我們所描述的要復雜很多,但理清思路,清楚細節后自然可以排查掉遇到的一些問題。當然,同時也要學會使用各種JVM工具,如Eclipse Memory AnalyzerARMSArthas以及JDK自帶的一些工具等。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,836評論 6 540
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,275評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,904評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,633評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,368評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,736評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,740評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,919評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,481評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,235評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,427評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,968評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,656評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,055評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,348評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,160評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,380評論 2 379

推薦閱讀更多精彩內容