JVM系列(二) - JVM內存區域詳解
前言
JVM
內存區域包括 PC計數器、Java虛擬機棧、本地方法棧、堆、方法區、運行時常量池和 直接內存。
本文主要介紹各個內存區域的作用和特性,同時分別闡述各個區域發生內存溢出的可能性和異常類型。
正文
(一). JVM內存區域
Java
虛擬機執行Java
程序的過程中,會把所管理的內存劃分為若幹不同的數據區域。這些內存區域各有各的用途,以及創建和銷毀時間。有的區域隨著虛擬機進程的啟動而存在,有的區域伴隨著用戶線程的啟動和結束而創建和銷毀。
JVM
內存區域也稱為Java
運行時數據區域。其中包括:程序計數器、虛擬機棧、本地方法棧、堆、靜態方法區、靜態常量池等。
註意:程序計數器、虛擬機棧、本地方法棧屬於每個線程私有的
;堆和方法區屬於線程共享訪問的。
1.1. PC計數器
程序計數器(Program Counter Register
)是一塊較小的內存空間,它的作用可以看做是當前線程所執行的字節碼行號指示器。
- 當前線程所執行的字節碼行號指示器。
- 每個線程都有一個自己的
PC
計數器。 - 線程私有的,生命周期與線程相同,隨
JVM
啟動而生,JVM
關閉而死。 - 線程執行
Java
方法時,記錄其正在執行的虛擬機字節碼指令地址。 - 線程執行
Native
方法時,計數器記錄為空(Undefined
)。 - 唯一在
Java
虛擬機規範中沒有規定任何OutOfMemoryError
情況區域。
1.2. Java虛擬機棧
線程私有內存空間,它的生命周期和線程相同。線程執行期間,每個方法執行時都會創建一個棧幀(Stack Frame)
- 局部變量表
- 操作數棧
- 動態鏈接
- 方法出口
每一個方法從調用直到執行完成的過程,就對應著一個棧幀在虛擬機棧中的入棧和出棧的全過程。
下面依次解釋棧幀裏的四種組成元素的具體結構和功能:
1). 局部變量表
局部變量表是一組變量值的存儲空間,用於存儲方法參數和局部變量。 在 Class
文件的方法表的 Code
屬性的 max_locals
指定了該方法所需局部變量表的最大容量。
局部變量表在編譯期間分配內存空間,可以存放編譯期的各種變量類型:
- 基本數據類型 :
boolean
,byte
,char
,short
,int
float
,long
,double
等8
種; - 對象引用類型 :
reference
,指向對象起始地址的引用指針; - 返回地址類型 :
returnAddress
,返回地址的類型。
變量槽(Variable Slot
):
變量槽是局部變量表的最小單位,規定大小為
32
位。對於64
位的long
和double
變量而言,虛擬機會為其分配兩個連續的Slot
空間。
2). 操作數棧
操作數棧(Operand Stack
)也常稱為操作棧,是一個後入先出棧。在 Class
文件的 Code
屬性的 max_stacks
指定了執行過程中最大的棧深度。Java
虛擬機的解釋執行引擎被稱為基於棧的執行引擎 ,其中所指的棧就是指-操作數棧。
- 和局部變量表一樣,操作數棧也是一個以
32
字長為單位的數組。 - 虛擬機在操作數棧中可存儲的數據類型:
int
、long
、float
、double
、reference
和returnType
等類型 (對於byte
、short
以及char
類型的值在壓入到操作數棧之前,也會被轉換為int
)。 - 和局部變量表不同的是,它不是通過索引來訪問,而是通過標準的棧操作 — 壓棧和出棧來訪問。比如,如果某個指令把一個值壓入到操作數棧中,稍後另一個指令就可以彈出這個值來使用。
虛擬機把操作數棧作為它的工作區——大多數指令都要從這裏彈出數據,執行運算,然後把結果壓回操作數棧。
1
|
begin
|
在這個字節碼序列裏,前兩個指令
iload_0
和iload_1
將存儲在局部變量表中索引為0
和1
的整數壓入操作數棧中,其後iadd
指令從操作數棧中彈出那兩個整數相加,再將結果壓入操作數棧。第四條指令istore_2
則從操作數棧中彈出結果,並把它存儲到局部變量表索引為2
的位置。
下圖詳細表述了這個過程中局部變量表和操作數棧的狀態變化(圖中沒有使用的局部變量表和操作數棧區域以空白表示)。
3). 動態鏈接
每個棧幀都包含一個指向運行時常量池中所屬的方法引用,持有這個引用是為了支持方法調用過程中的動態鏈接。
Class
文件的常量池中存在有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用為參數。這些符號引用:
- 靜態解析:一部分會在類加載階段或第一次使用的時候轉化為直接引用(如
final
、static
域等),稱為靜態解析, - 動態解析:另一部分將在每一次的運行期間轉化為直接引用,稱為動態鏈接。
4). 方法返回地址
當一個方法開始執行以後,只有兩種方法可以退出當前方法:
- 正常返回:當執行遇到返回指令,會將返回值傳遞給上層的方法調用者,這種退出的方式稱為正常完成出口(
Normal Method Invocation Completion
),一般來說,調用者的PC
計數器可以作為返回地址。 - 異常返回:當執行遇到異常,並且當前方法體內沒有得到處理,就會導致方法退出,此時是沒有返回值的,稱為異常完成出口(
Abrupt Method Invocation Completion
),返回地址要通過異常處理器表來確定。
當一個方法返回時,可能依次進行以下3
個操作:
- 恢復上層方法的局部變量表和操作數棧。
- 把返回值壓入調用者棧幀的操作數棧。
- 將
PC
計數器的值指向下一條方法指令位置。
小結:
註意:在Java虛擬機規範中,對這個區域規定了兩種異常。
其一:如果當前線程請求的棧深度大於虛擬機棧所允許的深度,將會拋出StackOverflowError
異常(在虛擬機棧不允許動態擴展的情況下);其二:如果擴展時無法申請到足夠的內存空間,就會拋出OutOfMemoryError
異常。
1.3. 本地方法棧
本地方法棧和Java
虛擬機棧發揮的作用非常相似,主要區別是Java
虛擬機棧執行的是Java
方法服務,而本地方法棧執行Native
方法服務(通常用C編寫)。
有些虛擬機發行版本(譬如
Sun HotSpot
虛擬機)直接將本地方法棧和Java
虛擬機棧合二為一。與虛擬機棧一樣,本地方法棧也會拋出StackOverflowError
和OutOfMemoryError
異常。
1.4. 堆
Java
堆是被所有線程共享的最大的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這裏分配內存。
在Java
中,堆被劃分成兩個不同的區域:新生代 (Young Generation
) 、老年代 (Old Generation
) 。新生代 (Young
) 又被劃分為三個區域:一個Eden
區和兩個Survivor
區 - From Survivor
區和To Survivor
區。
簡要歸納:新的對象分配是首先放在年輕代 (
Young Generation
) 的Eden
區,Survivor
區作為Eden
區和Old
區的緩沖,在Survivor
區的對象經歷若幹次收集仍然存活的,就會被轉移到老年代Old
中。
這樣劃分的目的是為了使JVM
能夠更好的管理堆內存中的對象,包括內存的分配以及回收。
1.5. 方法區
方法區和Java
堆一樣,為多個線程共享,它用於存儲類信息、常量、靜態常量和即時編譯後的代碼等數據。
1.6. 運行時常量池
運行時常量池是方法區的一部分,Class
文件中除了有類的版本、字段、方法和接口等描述信息外,
還有一類信息是常量池,用於存儲編譯期間生成的各種字面量和符號引用。
1.7. 直接內存
直接內存不屬於虛擬機運行時數據區的一部分,也不是Java
虛擬機規範中定義的內存區域。Java NIO
允許Java
程序直接訪問直接內存,通常直接內存的速度會優於Java堆內存。因此,對於讀寫頻繁、性能要求高的場景,可以考慮使用直接內存。
(二). 常見內存溢出異常
除了程序計數器外,Java
虛擬機的其他運行時區域都有可能發生OutOfMemoryError
的異常,下面分別給出驗證:
2.1. Java堆溢出
Java
堆能夠存儲對象實例。通過不斷地創建對象,並保證GC Roots
到對象有可達路徑來避免垃圾回收機制清除這些對象。
當對象數量到達最大堆的容量限制時就會產生OutOfMemoryError
異常。
設置JVM
啟動參數:-Xms20M
設置堆的最小內存為20M
,-Xmx20M
設置堆的最大內存和最小內存一樣,這樣可以防止Java
堆在內存不足時自動擴容。-XX:+HeapDumpOnOutOfMemoryError
參數可以讓虛擬機在出現內存溢出異常時Dump
出內存堆運行時快照。
HeapOOM.java
1
|
/**
|
測試運行結果:
打開Java VisualVM
導出Heap
內存運行時的dump
文件。
HeapOOM
對象不停地被創建,堆內存使用達到99%
。垃圾回收器不斷地嘗試回收但都以失敗告終。
分析:遇到這種情況,通常要考慮內存泄露和內存溢出兩種可能性。
-
如果是內存泄露:
進一步使用
Java VisualVM
工具進行分析,查看泄露對象是通過怎樣的路徑
與GC Roots
關聯而導致垃圾回收器無法回收的。 -
如果是內存溢出:
通過
Java VisualVM
工具分析,不存在泄露對象,也就是說堆內存中的對象必須得存活著。就要考慮如下措施:- 從代碼上檢查是否存在某些對象生命周期過長、持續狀態時間過長的情況,嘗試減少程序運行期的內存。
- 檢查虛擬機的堆參數(
-Xmx
與-Xms
),對比機器的物理內存看是否還可以調大。
2.2. 虛擬機和本地方法棧溢出
關於虛擬機棧和本地方法棧,分析內存異常類型可能存在以下兩種:
- 如果現場請求的棧深度大於虛擬機所允許的最大深度,將拋出
StackOverflowError
異常。 - 如果虛擬機在擴展棧時無法申請到足夠的內存空間,可能會拋出
OutOfMemoryError
異常。
可以劃分為兩類問題,當棧空間無法分配時,到底時棧內存太小,還是已使用的棧內存過大。
StackOverflowError異常
測試方案一:
- 使用
-Xss
參數減少棧內存的容量,異常發生時打印棧的深度。 - 定義大量的本地局部變量,以達到增大棧幀中的本地變量表的長度。
設置JVM
啟動參數:-Xss128k
設置棧內存的大小為128k
。
JavaVMStackSOF.java
1
|
/**
|
測試結果:
分析:在單個線程下,無論是棧幀太大還是虛擬機棧容量太小,當無法分配內存的時候,虛擬機拋出的都是
StackOverflowError
異常。
測試方案二:
- 不停地創建線程並保持線程運行狀態。
JavaVMStackOOM.java
1
|
/**
|
測試結果:
1
|
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
|
上述測試代碼運行時存在較大的風險,可能會導致操作系統假死,這裏就不親自測試了,引用作者的測試結果。
2.3. 方法區和運行時常量池溢出
(一). 運行時常量池內存溢出測試
運行時常量和字面量都存放於運行時常量池中,常量池又是方法區的一部分,因此兩個區域的測試是一樣的。
這裏采用String.intern()
進行測試:
String.intern()是一個native方法,它的作用是:如果字符串常量池中存在一個String對象的字符串,那麽直接返回常量池中的這個String對象;
否則,將此String對象包含的字符串放入常量池中,並且返回這個String對象的引用。
設置JVM
啟動參數:通過-XX:PermSize=10M
和-XX:MaxPermSize=10M
限制方法區的大小為10M
,從而間接的限制其中常量池的容量。
RuntimeConstantPoolOOM.java
1
|
/**
|
測試結果分析:
JDK1.6
版本運行結果:
1
|
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
|
JDK1.6
版本運行結果顯示常量池會溢出並拋出永久帶的OutOfMemoryError
異常。
而JDK1.7
及以上的版本則不會得到相同的結果,它會一直循環下去。
(二). 方法區內存溢出測試
方法區存放Class
相關的信息,比如類名、訪問修飾符、常量池、字段描述、方法描述等。
對於方法區的內存溢出的測試,基本思路是在運行時產生大量類字節碼區填充方法區。
這裏引入Spring
框架的CGLib
動態代理的字節碼技術,通過循環不斷生成新的代理類,達到方法區內存溢出的效果。
JavaMethodAreaOOM.java
1
|
/**
|
JDK1.6
版本運行結果:
1
|
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
|
測試結果分析:
JDK1.6
版本運行結果顯示常量池會溢出並拋出永久帶的OutOfMemoryError
異常。
而JDK1.7
及以上的版本則不會得到相同的結果,它會一直循環下去。
2.4. 直接內存溢出
本機直接內存的容量可通過-XX:MaxDirectMemorySize
指定,如果不指定,則默認與Java
堆最大值(-Xmx指定)一樣。
測試場景:
直接通過反射獲取Unsafe
實例,通過反射向操作系統申請分配內存:
設置JVM
啟動參數:-Xmx20M
指定Java
堆的最大內存,-XX:MaxDirectMemorySize=10M
指定直接內存的大小。
DirectMemoryOOM.java
1
|
/**
|
測試結果:
測試結果分析:
由DirectMemory
導致的內存溢出,一個明顯的特征是Heap Dump
文件中不會看到明顯的異常信息。
如果OOM
發生後Dump
文件很小,並且程序中直接或者間接地使用了NIO
,那麽就可以考慮一下這方面的問題。
歡迎關註技術公眾號: 零壹技術棧
本帳號將持續分享後端技術幹貨,包括虛擬機基礎,多線程編程,高性能框架,異步、緩存和消息中間件,分布式和微服務,架構學習和進階等學習資料和文章。
JVM系列(二) - JVM內存區域詳解