深入分析Java虛擬機器堆和棧及OutOfMemory異常產生原因
2022-02-28由 全棧技術資源社群 發表于 漁業
out of memory是什麼意思
前言
JVM系列文章如無特殊說明,一些特性均是基於Hot Spot虛擬機器和JDK1。8版本講述。
下面這張圖我想對於每個學習Java的人來說再熟悉不過了,這就是整個JDK的關係圖:
從上圖我們可以看到,JavaVirtualMachine位於最底層,所有的Java應用都是基於JVM來執行的,所以學習JVM對任何一個想要深入瞭解Java的人是必不可少的。
Java的口號是:Write once,run anywhere(一次編寫,到處執行)。這就是因為JVM的存在,JVM幫我們處理好了不同平臺的相容性問題,只要我們安裝對應系統的JDK,就可以執行,而無需關心其他問題。
什麼是JVM
JVM全稱Java Virtual Machine,即Java虛擬機器,是一種抽象計算機。與真正的計算機一樣,它有一個指令集,並在執行時操作各種記憶體區域。虛擬機器有很多種,不同的廠商提供了不同的實現,只要遵循虛擬機器規範即可。目前我們常說的虛擬機器一般都指的是Hot Spot。
JVM對Java程式語言一無所知,只知道一種特定的二進位制格式,即類檔案格式。類檔案包含Java虛擬機器指令(或位元組碼)和符號表,以及其他輔助資訊。也就是說,我們寫好的程式最終交給JVM執行的時候會被編譯成為二進位制格式。
注意:Java虛擬機器只認二進位制格式檔案,所以,任何語言,只要編譯之後的格式符合要求,都可以在Java虛擬機器上執行,如Kotlin,Groovy等。
Java程式執行流程
從我們寫好的。java檔案到最終在JVM上執行時,大致是如下一個流程:
一個java類在經過編譯和類載入機制之後,會將載入後得到的資料放到執行時資料區內,這樣我們在執行程式的時候直接從JVM記憶體中讀取對應資訊就可以了。
執行時資料區
執行時資料區:Run-Time Data Areas。Java虛擬機器定義了在程式執行期間使用的各種執行時資料區域。其中一些資料區域是在Java虛擬機器啟動時建立的,只在Java虛擬機器退出時銷燬,這些區域是所有執行緒共享的,所以會有執行緒不安全的問題發生。而有一些資料區域為每個執行緒獨佔的,每個執行緒獨佔資料區域線上程建立時建立,線上程退出時銷燬,執行緒獨佔的資料區就不會有安全性問題。
Run-Time Data Areas主要包括如下部分:pc暫存器,堆,方法區,虛擬機器棧,本地方法棧。
PC(program counter) Register(程式計數器)
PC Register是每個執行緒獨佔的空間。
Java虛擬機器可以支援同時執行多個執行緒,而在任何一個確定的時刻,一個處理器只會執行一個執行緒中的一個指令,又因為執行緒具有隨機性,作業系統會一直切換執行緒去執行不同的指令,所以為了切換執行緒之後能回到原先執行的位置,每個JVM執行緒都必須要有自己的pc(程式計數器)暫存器來獨立儲存執行資訊,這樣才能繼續之前的位置往後執行。
在任何時候,每個Java虛擬機器執行緒都在執行單個方法的程式碼,即該執行緒的當前方法。如果該方法不是Native方法,則pc暫存器會記錄當前正在執行的Java虛擬機器指令的地址。如果執行緒當前執行的方法是本地的,那麼Java虛擬機器的pc暫存器的值是Undefined。
Heap(堆)
堆是Java虛擬機器所管理記憶體中最大的一塊,在虛擬機器啟動時建立,被所有執行緒共享。
堆在虛擬機器啟動時建立,用於儲存所有的物件例項和陣列(在某些特殊情況下不是)。
堆中的物件永遠不會顯式地釋放,必須由GC自動回收。所以GC也主要是回收堆中的物件例項,我們平常討論垃圾回收主要也是回收堆記憶體。
堆可以處於物理上不連續的記憶體空間,可以固定大小,也可以動態擴充套件,透過引數-Xms和Xmx兩個引數來控制堆記憶體的最小和最大值。
堆可能存在如下異常情況:
如果計算需要的堆比自動儲存管理系統提供的堆多,將丟擲OutOfMemoryError錯誤。
模擬堆內OutOfMemoryError
為了方便模擬,我們把堆固定一下大小,設定為:
-Xms20m -Xmx20m
然後新建一個測試類來測試一下:
package com。zwx。jvm。oom;
import java。util。ArrayList;
import java。util。List;
public class Heap {
public static void main(String[] args) {
List
while (true){
list。add(99999);
}
}
}
輸出結果為(後面的Java heap space,表示堆空間溢位):
Exception in thread “main” java。lang。OutOfMemoryError: Java heap space
at java。util。Arrays。copyOf(Arrays。java:3210)
at java。util。Arrays。copyOf(Arrays。java:3181)
注意:堆不能設定的太小,太小的話會啟動失敗,如上我們把引數大小都修改為2m,會出現下面的錯誤:
Error occurred during initialization of VM
GC triggered before VM initialization completed。 Try increasing NewSize, current value 1536K。
Method Area(方法區)
方法區是各個執行緒共享的記憶體區域,在虛擬機器啟動時建立。它儲存每個類的結構,比如:執行時常量池、屬性和方法資料,以及方法和建構函式的程式碼,包括在類和例項初始化以及介面初始化中使用的特殊方法。
方法區在邏輯上是堆的一部分,但是它卻又一個別名叫做Non-Heap(非堆),目的是與Java堆區分開來。
方法區域可以是固定大小,也可以根據計算的需要進行擴充套件,如果不需要更大的方法區域,則可以收縮。方法區域的記憶體不需要是連續的。
方法區中可能出現如下異常:
如果方法區域中的記憶體無法滿足分配請求時,將丟擲OutOfMemoryError錯誤。
Run-Time Constant Pool(執行時常量池)
執行時常量池是方法區中的一部分,用於儲存編譯生成的字面量和符號引用。類或介面的執行時常量池是在Java虛擬機器建立類或介面時構建的。
字面量
在計算機科學中,字面量(literal)是用於表達原始碼中一個固定值的表示法(notation)。幾乎所有計算機程式語言都具有對基本值的字面量表示,諸如:整數、浮點數以及字串等。在Java中常用的字面量就是基本資料型別或者被final修飾的常量或者字串等。
String字串去哪了
字串這裡值得拿出來單獨解釋一下,在jdk1。6以及之前的版本,Java中的字串就是放在方法區中的執行時常量池內,但是在jdk1。7和jdk1。8版本(jdk1。8之後本人沒有深入去了解過,所以不討論),將字串常量池拿出來放到了堆(heap)裡。
我們來透過一個例子來演示一下區別:
package com。zwx;
public class demo {
String str1 = new String(“lonely”) + new String(“wolf”);
System。out。println(str1==str1。intern());
這個語句的執行結果在不同的JDK版本中輸出的結果會不一樣:
JDK1。6中會輸出false:
JDK1。7中輸出true:
JDK1。8中也會輸出true:
intern()方法
jdk1。6及之前的版本中:
呼叫String。intern()方法,會先去常量池檢查是否存在當前字串,如果不存在,則會在方法區中建立一個字串,而new String(“”)方法建立的字串在堆裡面,兩個字串的地址不相等,故而返回false。
在jdk1。7及1。8版本中:
字串常量池從方法區中的執行時常量池移到了堆記憶體中,而intern()方法也隨之做了改變。呼叫String。intern()方法,首先還是會去常量池中檢查是否存在,如果不存在,那麼就會建立一個常量,並將引用指向堆,也就是說不會再重新建立一個字串物件了,兩者都會指向堆中的物件,所以返回true。
不過有一點還是需要注意,我們把上面的構造字串的程式碼改造一下:
String str1 = new String(“ja”) + new String(“va”);
這時候在jdk1。7和jdk1。8中也會返回false。
這個差異在《深入理解Java虛擬機器》一書中給出的解釋是java這個字串已經存在常量池了,所以我個人的推測是可能初始化的時候jdk本身需要使用到java字串,所以常量池中就提前已經建立好了,如果理解錯了,還請大家指正,感謝!
new String(“lonely”)建立了幾個物件
上面的例子中我用了兩個new String(“lonely”)和new String(“wolf”)相加,而如果去掉其中一個new String()語句的話,那麼實際上jdk1。7和jdk1。8中返回的也會是false,而不是true。
這是為什麼?看下面(
我們假設一開始字串常量池沒有任何字串
):
只執行一個new String(“lonely”)會產生2個物件,1個在堆,1個在字串常量池
這時候執行了String。intern()方法,String。intern()會去檢查字串常量池,發現字串常量池存在longly字串,所以會直接返回,不管是jdk1。6還是jdk1。7和jdk1。8都是檢查到字串存在就會直接返回,所以str1==str1。intern()得到的結果就是都是false,因為一個在堆,一個在字串常量池。
執行newString(“lonely”)+newString(“wolf”)會產生5個物件,3個在堆,2個在字串常量池
好了,這時候執行String。intern()方法會怎麼樣呢,如果在jdk1。7和jdk1。8會去檢查字串常量池,發現沒有lonelywolf字串,所以會建立一個指向堆中的字串放到字串常量池:
而如果是jdk1。6中,不會指向堆,會重新建立一個lonelywolf字串放到字串常量池,所以才會產生不同的執行結果。
注意:+號的底層執行的是new StringBuild()。append()語句,所以我們再看下面一個例子:
String s1 = new StringBuilder(“aa”)。toString();
System。out。println(s1==s1。intern());
String s2 = new StringBuilder(“aa”)。append(“bb”)。toString();
System。out。println(s2==s2。intern());//1。6返回false,1。7和1。8返回true
這個在jdk1。6版本全部返回false,而在jdk1。7和jdk1。8中一個返回false,一個返回true。多了一個append相當於上面的多了一個+號,原理是一樣的。
符號引用
符號引用在下篇講述類載入機制的時候會進行解釋,這裡暫不做解釋,感興趣的可以關注我,留意我的JVM系列下一篇文章。
jdk1。7和1。8的實現方法區的差異
方法區是Java虛擬機器規範中的規範,但是具體如何實現並沒有規定,所以虛擬機器廠商完全可以採用不同的方式實現方法區的。
在HotSpot虛擬機器中:
jdk1。7及之前版本
方法區採用永久代(Permanent Generation)的方式來實現,方法區的大小我們可以透過引數-XX:PermSize和-XX:MaxPermSize來控制方法區的大小和所能允許最大值。
jdk1。8版本
移除了永久代,採用元空間(Metaspace)來實現方法區,所以在jdk1。8中關於永久代的引數-XX:PermSize和-XX:MaxPermSize已經被廢棄卻代之的是引數-XX:MetaspaceSize和-XX:MaxMetaspaceSize。元空間和永久代的一個很大的區別就是元空間已經不在jvm記憶體在,而是直接儲存到了本地記憶體中。
如下,我們再jdk1。8中設定-XX:PermSize和-XX:MaxPermSize會給出警告:
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize1m; support was removed in 8。0
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize1m; support was removed in 8。0
模擬方法區OutOfMemoryError
因為jdk1。7及之前都是永久代來實現方法區,所以我們可以透過設定永久代引數來模擬記憶體溢位:
設定永久代最大為2M:
-XX:PermSize=2m -XX:MaxPermSize=2m
然後執行如下程式碼:
List
int i = 0;
list。add(String。valueOf(i++)。intern());
最後報錯OOM:PermGen space(永久代溢位)。
java。lang。OutOfMemoryError: PermGen space
at sun。misc。Launcher$ExtClassLoader。getExtClassLoader(Launcher。java:141)
at sun。misc。Launcher。
at sun。misc。Launcher。
jdk1。8
jdk1。8版本,因為永久代被取消了,所以模擬方式會不一樣。
首先引入asm位元組碼框架依賴(前面介紹動態代理的時候提到cglib動態代理也是利用了asm框架來生成位元組碼,所以也可以直接cglib的api來生成):
建立一個工具類去生成class檔案:
import jdk。internal。org。objectweb。asm。ClassWriter;
import jdk。internal。org。objectweb。asm。MethodVisitor;
import org。objectweb。asm。Opcodes;
public class MetaspaceUtil extends ClassLoader {
public static List
List
for (int i = 0; i < 10000000; ++i) {
ClassWriter cw = new ClassWriter(0);
cw。visit(Opcodes。V1_1, Opcodes。ACC_PUBLIC, “Class” + i, null,
“java/lang/Object”, null);
MethodVisitor mw = cw。visitMethod(Opcodes。ACC_PUBLIC, “
“()V”, null, null);
mw。visitVarInsn(Opcodes。ALOAD, 0);
mw。visitMethodInsn(Opcodes。INVOKESPECIAL, “java/lang/Object”,
“
mw。visitInsn(Opcodes。RETURN);
mw。visitMaxs(1, 1);
mw。visitEnd();
MetaspaceUtil test = new MetaspaceUtil();
byte[] code = cw。toByteArray();
Class<?> exampleClass = test。defineClass(“Class” + i, code, 0, code。length);
classes。add(exampleClass);
return classes;
設定元空間大小
-XX:MetaspaceSize=5M -XX:MaxMetaspaceSize=5M
然後執行測試類模擬:
public class MethodArea {
//jdk1。8
List
list。addAll(MetaspaceUtil。createClasses());
丟擲如下異常OOM:Metaspace:
Java Virtual Machine Stacks(Java虛擬機器棧)
每個Java虛擬機器執行緒都有一個與執行緒同時建立的私有Java虛擬機器棧。
Java虛擬機器棧儲存棧幀(Frame)。每個被呼叫的方法就會產生一個棧幀,棧幀中儲存了一個方法的狀態資訊,如:區域性變數,操作棧幀,方出出口等。
呼叫一個方法,就會產生一個棧幀,並壓入棧內;一個方法呼叫完成,就會把該棧幀從棧中彈出,大致呼叫過程如下圖所示:
Java虛擬機器棧中可能有下面兩種異常情況:
如果執行緒執行所需棧深度大於Java虛擬機器棧深度,則會丟擲StackOverflowError。
上圖可以知道,其實方法的呼叫就是入棧和出棧的過程,如果一直入棧而不出棧就容易發生異常(如遞迴)。
如果Java虛擬機器棧可以動態地擴充套件,但是擴充套件大小的時候無法申請到足夠的記憶體,則會丟擲一個OutOfMemoryError。
大部分Java虛擬機器棧都是支援動態擴充套件大小的,也允許設定固定大小(在Java虛擬機器規範中兩種都是可以的,具體要看虛擬機器的實現)。
注:我們經常說的JVM中的棧,一般指的就是Java虛擬機器棧。
模擬棧內StackOverflowError
下面是一個簡單的遞迴方法,沒有跳出遞迴條件:
public class JMVStack {
test();
static void test(){
輸出結果為:
Exception in thread “main” java。lang。StackOverflowError
at com。zwx。jvm。oom。JMVStack。test(JMVStack。java:15)
。。。。。
Native Method Stacks(本地方法棧)
本地方發棧類似於Java虛擬機器棧,區別就是本地方法棧儲存的是Native方法。本地方發棧和Java虛擬機器棧在有的虛擬機器中是合在一起的,並沒有分開,如:Hot Spot虛擬機器。
本地方法棧可能出現如下異常:
如果執行緒執行所需棧深度大於本地方法棧深度,則會丟擲StackOverflowError。
如果可以動態擴充套件本地方法棧,但是擴充套件大小的時候無法申請到足夠的記憶體,則會丟擲OutOfMemoryError。
總結
本文主要介紹了jvm執行時資料區的構造,以及每部分割槽域到底都存了哪些資料,然後去模擬了一下常見異常的產生方式,當然,模擬異常的方式很多,關鍵要知道每個區域存了哪些東西,模擬的時候對應生成就可以。