引言
前面的文章中重點是對于JVM的子系統進行分析,在之前已經詳細的闡述了虛擬機的類加載子系統以及執行引擎子系統,而本篇則準備對于JVM運行時的內存區域以及JVM運行時的內存溢出與內存泄露問題進行全面剖析。
一、全面詳解JVM運行時內存區域
JVM在運行Java程序時,會把自身管理的內存分為若干個不同的數據區域,這些區域各自都有各自的用途,同時,不同的區域也有著不同的生命周期,有些區域隨著虛擬機的啟動而開辟,隨著虛擬機的終止而銷毀,有的區域則是在運行過程中不斷的創建與銷毀。
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()
就是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
區 - 永久代:方法區
新生代主要用于存儲未達到年老代分配條件的對象,其中
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將永久代,也就是方法區整合成了元數據空間,并且將其移出了堆,將其放在堆空間外的本地內存中。
JDK1.8的時候沒啥好講的,和1.7差距不大,最大區別在于移除了方法區,在本地內存中加入了元數據空間來存儲之前方法區中的大部分數據(原方法區中的數據并不是所有都被遷移到了元空間存儲,有些數據被分散到了JVM各個區域)。除此之外,常量池在1.8的時候也被移到了堆外。
1.2.1.5、JDK9堆空間內存劃分
到了JDK1.9時,堆空間慢慢的開始了劃時代的改變,在此之前,堆空間的布局都是采用分代存儲的方式,無論從邏輯上還是從物理內存上,都是分代的。但是到了Java9的時候,因為默認GC器改為了G1,所以堆中的內存區域被劃為了一個個的Region
區。
在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器是真正意義上的不分代,無論是從邏輯上還是物理上都不分代。
在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
是從JRockit
GC組過來的,R大在和per
聊天時曾聊到過:per
之前在JRockit
GC器上嘗試了四五次都以失敗告終,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機制的范圍之內,字符串也會存在回收操作。
同時除開字符串常量池被挪動到了堆內之外,類的靜態變量的存儲也被放在了堆中。對比如下:
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
類型的入參所占空間為8bytes
,256kb=(1024*256)bytes
,理論上在調用VMStackOOM()
方法時,往該方法中傳遞10w
個long
類型的入參,是肯定可以觀測到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 Analyzer、ARMS、Arthas以及JDK自帶的一些工具等。