1. 程式人生 > >JVM系列(二) - JVM內存區域詳解

JVM系列(二) - JVM內存區域詳解

type system oot 用途 block 引入 locals -o 以及

前言

JVM內存區域包括 PC計數器Java虛擬機棧本地方法棧方法區運行時常量池直接內存

本文主要介紹各個內存區域的作用和特性,同時分別闡述各個區域發生內存溢出的可能性和異常類型。

正文

(一). JVM內存區域

Java虛擬機執行Java程序的過程中,會把所管理的內存劃分為若幹不同的數據區域。這些內存區域各有各的用途,以及創建和銷毀時間。有的區域隨著虛擬機進程的啟動而存在,有的區域伴隨著用戶線程的啟動和結束而創建和銷毀。

JVM內存區域也稱為Java運行時數據區域。其中包括:程序計數器虛擬機棧本地方法棧靜態方法區靜態常量池等。

技術分享圖片

註意:程序計數器、虛擬機棧、本地方法棧屬於每個線程私有的

;堆和方法區屬於線程共享訪問的

技術分享圖片

1.1. PC計數器

程序計數器(Program Counter Register)是一塊較小的內存空間,它的作用可以看做是當前線程所執行的字節碼行號指示器

  1. 當前線程所執行的字節碼行號指示器
  2. 每個線程都有一個自己的PC計數器。
  3. 線程私有的,生命周期與線程相同,隨JVM啟動而生,JVM關閉而死。
  4. 線程執行Java方法時,記錄其正在執行的虛擬機字節碼指令地址
  5. 線程執行Native方法時,計數器記錄為(Undefined)。
  6. 唯一在Java虛擬機規範中沒有規定任何OutOfMemoryError情況區域。

1.2. Java虛擬機棧

線程私有內存空間,它的生命周期和線程相同。線程執行期間,每個方法執行時都會創建一個棧幀(Stack Frame)

,用於存儲 局部變量表操作數棧動態鏈接方法出口 等信息。

  1. 局部變量表
  2. 操作數棧
  3. 動態鏈接
  4. 方法出口

每一個方法從調用直到執行完成的過程,就對應著一個棧幀在虛擬機棧中的入棧出棧的全過程。

下面依次解釋棧幀裏的四種組成元素的具體結構和功能:

1). 局部變量表

局部變量表是一組變量值的存儲空間,用於存儲方法參數局部變量。 在 Class 文件的方法表的 Code 屬性的 max_locals 指定了該方法所需局部變量表的最大容量

局部變量表在編譯期間分配內存空間,可以存放編譯期的各種變量類型:

  1. 基本數據類型boolean, byte, char, short, int
    , float, long, double8種;
  2. 對象引用類型reference,指向對象起始地址引用指針
  3. 返回地址類型returnAddress,返回地址的類型。

變量槽(Variable Slot):

變量槽局部變量表最小單位,規定大小為32位。對於64位的longdouble變量而言,虛擬機會為其分配兩個連續Slot空間。

2). 操作數棧

操作數棧Operand Stack)也常稱為操作棧,是一個後入先出棧。在 Class 文件的 Code 屬性的 max_stacks 指定了執行過程中最大的棧深度。Java虛擬機的解釋執行引擎被稱為基於棧的執行引擎 ,其中所指的就是指-操作數棧

  1. 局部變量表一樣,操作數棧也是一個以32字長為單位的數組。
  2. 虛擬機在操作數棧中可存儲的數據類型intlongfloatdoublereferencereturnType等類型 (對於byteshort以及char類型的值在壓入到操作數棧之前,也會被轉換為int)。
  3. 局部變量表不同的是,它不是通過索引來訪問,而是通過標準的棧操作壓棧出棧來訪問。比如,如果某個指令把一個值壓入到操作數棧中,稍後另一個指令就可以彈出這個值來使用。

虛擬機把操作數棧作為它的工作區——大多數指令都要從這裏彈出數據,執行運算,然後把結果壓回操作數棧

1
2
3
4
5
6
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_0iload_1 將存儲在局部變量表中索引為01的整數壓入操作數棧中,其後iadd指令從操作數棧中彈出那兩個整數相加,再將結果壓入操作數棧。第四條指令istore_2則從操作數棧中彈出結果,並把它存儲到局部變量表索引為2的位置。

下圖詳細表述了這個過程中局部變量表操作數棧的狀態變化(圖中沒有使用的局部變量表操作數棧區域以空白表示)。

技術分享圖片

3). 動態鏈接

每個棧幀都包含一個指向運行時常量池中所屬的方法引用,持有這個引用是為了支持方法調用過程中的動態鏈接

Class文件的常量池中存在有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用為參數。這些符號引用:

  1. 靜態解析:一部分會在類加載階段或第一次使用的時候轉化為直接引用(如finalstatic域等),稱為靜態解析
  2. 動態解析:另一部分將在每一次的運行期間轉化為直接引用,稱為動態鏈接
4). 方法返回地址

當一個方法開始執行以後,只有兩種方法可以退出當前方法:

  1. 正常返回:當執行遇到返回指令,會將返回值傳遞給上層的方法調用者,這種退出的方式稱為正常完成出口(Normal Method Invocation Completion),一般來說,調用者的PC計數器可以作為返回地址。
  2. 異常返回:當執行遇到異常,並且當前方法體內沒有得到處理,就會導致方法退出,此時是沒有返回值的,稱為異常完成出口(Abrupt Method Invocation Completion),返回地址要通過異常處理器表來確定。

當一個方法返回時,可能依次進行以下3個操作:

  1. 恢復上層方法局部變量表操作數棧
  2. 返回值壓入調用者棧幀操作數棧
  3. PC計數器的值指向下一條方法指令位置。

小結:

註意:在Java虛擬機規範中,對這個區域規定了兩種異常。
其一:如果當前線程請求的棧深度大於虛擬機棧所允許的深度,將會拋出 StackOverflowError 異常(在虛擬機棧不允許動態擴展的情況下);其二:如果擴展時無法申請到足夠的內存空間,就會拋出 OutOfMemoryError 異常。

1.3. 本地方法棧

本地方法棧Java虛擬機棧發揮的作用非常相似,主要區別是Java虛擬機棧執行的是Java方法服務,而本地方法棧執行Native方法服務(通常用C編寫)。

有些虛擬機發行版本(譬如Sun HotSpot虛擬機)直接將本地方法棧Java虛擬機棧合二為一。與虛擬機棧一樣,本地方法棧也會拋出StackOverflowErrorOutOfMemoryError異常。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 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工具分析,不存在泄露對象,也就是說堆內存中的對象必須得存活著。就要考慮如下措施:

    1. 從代碼上檢查是否存在某些對象生命周期過長持續狀態時間過長的情況,嘗試減少程序運行期的內存。
    2. 檢查虛擬機的堆參數(-Xmx-Xms),對比機器的物理內存看是否還可以調大。

2.2. 虛擬機和本地方法棧溢出

關於虛擬機棧和本地方法棧,分析內存異常類型可能存在以下兩種:

  • 如果現場請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError異常。
  • 如果虛擬機在擴展棧時無法申請到足夠的內存空間,可能會拋出OutOfMemoryError異常。

可以劃分為兩類問題,當棧空間無法分配時,到底時棧內存太小,還是已使用的棧內存過大

StackOverflowError異常

測試方案一:

  • 使用-Xss參數減少棧內存的容量,異常發生時打印的深度。
  • 定義大量的本地局部變量,以達到增大棧幀中的本地變量表的長度。

設置JVM啟動參數:-Xss128k設置棧內存的大小為128k

技術分享圖片

JavaVMStackSOF.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 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();
}
}

測試結果:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 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版本運行結果:

1
2
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 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版本運行結果:

1
2
3
4
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 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,那麽就可以考慮一下這方面的問題。


歡迎關註技術公眾號: 零壹技術棧

技術分享圖片零壹技術棧

本帳號將持續分享後端技術幹貨,包括虛擬機基礎,多線程編程,高性能框架,異步、緩存和消息中間件,分布式和微服務,架構學習和進階等學習資料和文章。

JVM系列(二) - JVM內存區域詳解