前言
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
)。 - 和區域性變數表不同的是,它不是通過索引來訪問,而是通過標準的棧操作 — 壓棧和出棧來訪問。比如,如果某個指令把一個值壓入到運算元棧中,稍後另一個指令就可以彈出這個值來使用。
虛擬機器把運算元棧作為它的工作區——大多數指令都要從這裡彈出資料,執行運算,然後把結果壓回運算元棧。
begin
iload_0 // push the int in local variable 0 onto the stack
iload_1 // push the int in local variable 1 onto the stack
iadd // pop two ints, add them, push result
istore_2 // pop int, store into local variable 2
end
複製程式碼
在這個位元組碼序列裡,前兩個指令
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
/**
* VM Args: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
public static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
複製程式碼
測試執行結果:
開啟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
/**
* VM Args: -Xss128k
*/
public class JavaVMStackSOF {
private int stackLength = 1;
private void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("Stack length: " + oom.stackLength);
throw e;
}
}
}
複製程式碼
測試結果:
分析:在單個執行緒下,無論是棧幀太大還是虛擬機器棧容量太小,當無法分配記憶體的時候,虛擬機器丟擲的都是
StackOverflowError
異常。
測試方案二:
- 不停地建立執行緒並保持執行緒執行狀態。
JavaVMStackOOM.java
/**
* VM Args: -Xss2M
*/
public class JavaVMStackOOM {
private void running() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
new Thread(new Runnable() {
@Override
public void run() {
running();
}
}).start();
}
}
public static void main(String[] args) {
JavaVMStackOOM oom = new JavaVMStackOOM();
oom.stackLeakByThread();
}
}
複製程式碼
測試結果:
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
/**
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用List保持著常量池的引用,避免Full GC回收常量池
List<String> list = new ArrayList<>();
// 10MB的PermSize在Integer範圍內足夠產生OOM了
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
複製程式碼
測試結果分析:
JDK1.6
版本執行結果:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
複製程式碼
JDK1.6
版本執行結果顯示常量池會溢位並丟擲永久帶的OutOfMemoryError
異常。
而JDK1.7
及以上的版本則不會得到相同的結果,它會一直迴圈下去。
(二). 方法區記憶體溢位測試
方法區存放Class
相關的資訊,比如類名、訪問修飾符、常量池、欄位描述、方法描述等。
對於方法區的記憶體溢位的測試,基本思路是在執行時產生大量類位元組碼區填充方法區。
這裡引入Spring
框架的CGLib
動態代理的位元組碼技術,通過迴圈不斷生成新的代理類,達到方法區記憶體溢位的效果。
JavaMethodAreaOOM.java
/**
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class JavaMethodAreaOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
}
private static class OOMObject {
public OOMObject() {
}
}
}
複製程式碼
JDK1.6
版本執行結果:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)
at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
複製程式碼
測試結果分析:
JDK1.6
版本執行結果顯示常量池會溢位並丟擲永久帶的OutOfMemoryError
異常。
而JDK1.7
及以上的版本則不會得到相同的結果,它會一直迴圈下去。
2.4. 直接記憶體溢位
本機直接記憶體的容量可通過-XX:MaxDirectMemorySize
指定,如果不指定,則預設與Java
堆最大值(-Xmx指定)一樣。
測試場景:
直接通過反射獲取Unsafe
例項,通過反射向作業系統申請分配記憶體:
設定JVM
啟動引數:-Xmx20M
指定Java
堆的最大記憶體,-XX:MaxDirectMemorySize=10M
指定直接記憶體的大小。
DirectMemoryOOM.java
/**
* VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}
複製程式碼
測試結果:
測試結果分析:
由DirectMemory
導致的記憶體溢位,一個明顯的特徵是Heap Dump
檔案中不會看到明顯的異常資訊。
如果OOM
發生後Dump
檔案很小,並且程式中直接或者間接地使用了NIO
,那麼就可以考慮一下這方面的問題。
歡迎關注技術公眾號: 零壹技術棧
本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。