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