波音不給力,NASA 計劃向 SpaceX 加訂載人飛船訂單
Java記憶體區域與記憶體溢位
1、Java執行時資料區域
Java虛擬機器在執行java程式時,會將自己管理的記憶體劃分為不同的區域。每個區域都有自己的記憶體大小、建立以及銷燬時間,有的區域會隨著java程序的啟動而建立,隨著java程序的銷燬而銷燬。有的區域是隨著使用者執行緒的啟動而建立,隨著執行緒的結束而銷燬。java執行時資料區域分為以下幾個部分,如下圖所示。
1.1、程式計數器
程式計數器是較小的一塊記憶體區域,可以看作是當前執行緒執行位元組碼的行號指示器。位元組碼直譯器就是根據程式計數器的行號來決定下一步要執行哪個位元組碼指令。它是控制流的指示器,迴圈、分支、跳轉、異常處理以及執行緒恢復等功能都要依賴程式計數器。
java虛擬機器的多執行緒是由多個執行緒輪換、分配處理器執行的時間來執行的。為了讓執行緒切換後保留恢復到之前執行的位置,每個執行緒內部都有一個程式計數器。執行緒之間相互不影響。所以程式計數器是執行緒私有的資料區域。
1.2、Java虛擬機器棧
虛擬機器棧和程式計數器一樣,都是執行緒私有的,java虛擬機器棧描述的是方法執行的記憶體模型,每個方法在執行時,虛擬機器都會建立一個棧幀,主要用來儲存區域性變量表、動態連結、運算元棧、方法的出口等資訊。每一個方法執行開始到結束,都對應著棧幀的入棧和出棧。
區域性變量表儲存了編譯器已知基本資料型別、物件引用、returnAddress型別。
java虛擬機器規範中,對這個記憶體區域有兩種異常,
-
一種是執行緒請求的棧深度超過虛擬機器棧深度,就會報StackOverflowError異常。
-
一種是虛擬機器棧容量可以動態擴充套件,當記憶體不足時就會丟擲OutOfMemoryError異常。
SotSpot虛擬機器棧容量不可以動態擴充套件,所以也不會因為動態擴充套件而出現OutOfMemoryError異常,但是如果在申請虛擬機器棧容量時記憶體已經不足,則還是會出現OOM異常。
1.3、本地方法棧
本地方法棧與虛擬機器棧類似,只不過本地方法棧是對程式中的本地方法服務。
與虛擬機器棧相同,也會因為執行緒請求棧深度超過棧深度而丟擲StackOverflowError異常,以及因為動態擴充套件或者擴充套件棧容量不足時丟擲OOM異常。
1.4、Java堆
java堆(java Heap)是記憶體區域中最大的一塊,是所有執行緒共享的記憶體區域,主要用來存放物件的例項,幾乎所有的物件例項以及陣列都在堆內分配記憶體。java堆也是垃圾收集器管理的記憶體區域,所以java 堆有時候也被叫做GC堆(Garbage Collected Heap)。
java堆記憶體在某些垃圾收集器中,是被分為年輕代、老年代這些只是垃圾收集器的設計風格而已,並不是java堆記憶體固有的記憶體佈局。
從分配記憶體的角度看,所有執行緒共享的java堆中可以分配出多個執行緒私有的緩衝區域(TLAB,Thread Local Allocation Buffer),來提升物件分配記憶體的效率。將java堆細分只是為了更好的回收記憶體以及更好的分配記憶體。
java堆記憶體的大小可以用 -Xmx和-Xms來指定。當堆記憶體無法為物件分配記憶體並且無法擴充套件時,就會產生OutOfMemoryError異常。
1.5、方法區
方法區和java堆都是執行緒共享的記憶體區域,方法區主要儲存型別資訊、常量、靜態變數、即時編譯器產生的快取資料等。
永久代:是HotSpot開發團隊將分代設計擴充套件至方法區,所以使用永久代來實現方法區。
在JDK6的時候,HotSpot團隊就放棄了永久代,使用本地記憶體來實現方法區。
JDK7,HotSpot團隊已經將字串常量池和靜態變數移出了永久代。
JDK8,完全廢棄了永久代,改用本地記憶體的元空間來代替,把永久代中的剩餘的部分全都移動到了元空間。
當方法區無法分配新的記憶體區域時,也會產生OutOfMemoryError異常。
1.6、執行時常量池
執行時常量池是方法區的一部分,它是用來儲存類編譯時的字面量以及符號引用,這部分會在類載入後被存放在執行時常量池中。
執行時常量池具備了動態性,並不是只有在編譯期間產生的字面量和符號引用會被放入執行時常量池,在執行期間產生的字面量也會儲存到常量池中,典型的應用就是,String類的intern方法。
執行時常量池是方法區的一部分,當然也受到方法區記憶體的限制,當無法分配記憶體時也會產生OutOfMemoryError異常。
1.7、直接記憶體
直接記憶體並不是虛擬機器執行時資料區的一部分。
在JDK1.4時引入了NIO,它是基於通道和緩衝區的IO方式,直接使用native函式庫直接分配堆外記憶體,使用儲存在堆內的DriectByteBuffer物件來作為這塊記憶體的引用進行操作。這樣可以顯著提高效能,避免了資料在java堆和native堆來回複製。
直接記憶體大小並不受java堆記憶體大小的限制,但是會受到本機總記憶體的限制。當-Xmx分配了太大的記憶體時,忽略了直接記憶體,動態擴充套件時也會產生OutOfMemoryError異常。
記憶體 | 儲存 | 執行緒 |
---|---|---|
程式計數器 | 位元組碼行號 | 私有 |
虛擬機器棧 | java方法:區域性變量表、動態連結、運算元棧、方法出口等 | 私有 |
本地方法棧 | 本地方法:區域性變量表、動態連結、運算元棧、方法出口等 | 私有 |
堆 | 物件例項、陣列 | 共享 |
方法區 | 型別資訊(類名、訪問修飾符等)、常量、靜態變數、即時編譯快取等 | 共享 |
2、虛擬機器中的物件
2.1、物件建立
在new物件時,
-
首先檢查常量池中是否有這個類的符號引用,並且檢查類是否已經被載入、解析和初始化過,如果沒有先執行類載入過程。
-
為物件分配記憶體:在類載入完成後已經確定了物件分配記憶體的大小。在java堆上分配一塊足夠的記憶體。分配記憶體需要根據java堆記憶體是否規整,
- 如果是規整的,所有被使用的記憶體在一邊,未被使用的記憶體在另一邊,中間使用一個指標,分配記憶體就是將指標向空閒記憶體這邊挪動一段與物件記憶體大小相等的空間。這種分配方式叫指標碰撞。
- 如果是不規整的,使用過的記憶體和未使用的記憶體交錯在一起,虛擬機器必須維護一個列表,哪塊記憶體是可用的,哪塊不可用,然後再列表中找到記憶體足夠的空間,並更新列表。這種分配方式叫空閒列表
在多執行緒下,分配記憶體空間是非常繁重的,僅僅依靠挪動指標的方法並不是執行緒安全的,一個執行緒正在分配記憶體,指標還沒來得及修改,另一個執行緒也使用了原來的指標分配。這種情況兩種解決方式:
(1)虛擬機器採用的是CAS加失敗重試機制來保證操作的原子性
(2)每個執行緒內部分配一塊私有的緩衝區域,分配記憶體時,就線上程已經分配的緩衝區域(Thread Local Allocation Buffer)分配物件記憶體,只有本地緩衝區用完之後才會同步鎖定重新分配。虛擬機器是否使用TLAB,可以通過-XX:+/-UseTLAB引數來設定。
-
初始化0值:為物件分配完記憶體後,開始初始化物件內的0值
-
設定物件頭:屬於哪個類、物件的元資料、物件的GC分代年齡等資訊。
-
初始化建構函式
2.2、物件的使用
物件建立完成後就是用來使用的。在堆記憶體中怎麼找到物件,有兩種方式,一種是通過物件控制代碼,一種是通過直接記憶體。
物件控制代碼方式:需要在堆中維護一個控制代碼池,儲存物件例項控制代碼,控制代碼內包含了物件例項資料以及物件型別各自地址,在java虛擬機器棧的本地變量表中儲存的是物件控制代碼的地址。在查詢時先通過變量表中的控制代碼地址找到控制代碼池中的控制代碼,然後再根據控制代碼中的地址資訊找到物件。
直接指標方式:再虛擬機器棧的本地變量表中儲存的就是物件的地址。直接可以通過變量表的物件地址找到物件的例項。
這兩種方式:
控制代碼方式的優點,當物件因GC而移動時只會改變物件例項資料的指標,變量表中的控制代碼地址不需要變化。而直接記憶體方式,當物件移動時,需要改動變量表中的物件例項資料地址。
直接指標方式的優點:可以直接通過變量表的記憶體找到物件例項資料,速度更快,節省了一次指標定位的時間開銷。控制代碼方式,要先根據控制代碼地址找到物件例項控制代碼,然後根據控制代碼中的地址再去找物件例項資料。
HotSpot虛擬機器中使用的是直接指標方式。
3、OutOfMemoryError異常
在java執行時資料區域中,除了程式計數器,其他的區域都會產生記憶體溢位。基於JDK1.8測試
3.1、堆記憶體溢位測試
java堆用於儲存物件例項,只要不斷建立物件,保證GC Roots與物件之間可達,確保GC不會回收這些物件。
限制-Xmx20m、-Xms20m,避免堆記憶體動態擴充套件,通過引數-XX:+HeapDumpOnOutOf-MemoryError可以讓虛擬機器在出現記憶體溢位異常的時候Dump出當前的記憶體堆轉儲快照以便進行事後分析。
public class HeapDemo {
static class OomObject{
}
public static void main(String[] args) {
List<OomObject> list = new ArrayList<>();
while(true){
list.add(new OomObject());
}
}
}
- 在IDEA中配置JVM啟動引數:
- 執行完成後,丟擲異常:
第一個框描述異常資訊,OutOfMemoryError: Java heap space(java堆空間)
第二個框產生dump檔案,堆轉儲快照檔案。
第三個框丟擲OutOfMemoryError異常
-
解決問題,對產生的dump檔案進行分析,可以使用記憶體映像分析工具(例如:Eclipse Memory
Analyzer),首先確認導致OOM的物件是否是必須的,也就是先確定是記憶體溢位(Memory Overflow)還是記憶體洩漏(Memory Leak)?
如果是記憶體洩露,看洩露的物件到GC Roots的路徑上,引用了哪些物件,導致無法被GC回收。根據洩漏物件的型別資訊以及它到GC Roots引用鏈的資訊,一般可以比較準確地定位到這些物件建立的位置,進而找出產生記憶體洩漏的程式碼的具體位置。
如果不是記憶體洩漏,也就是記憶體中的物件確實都是必須存活的,那就應當檢查Java虛擬機器的堆引數(-Xmx與-Xms)設定,與機器的記憶體對比,看看是否還有向上調整的空間。再從程式碼上檢查是否存在某些物件生命週期過長、持有狀態時間過長、儲存結構設計不合理等情況,儘量減少程式執行期的記憶體消耗。
3.2、虛擬機器棧、本地方法棧溢位測試
虛擬機器棧出現溢位的情況有兩種:
(1)執行緒請求的棧深度超過虛擬機器棧的深度就會產生StackOverflowError
(2)虛擬機器棧容量可以動態擴充套件,當記憶體無法擴充套件時,就會產生OutOfMemoryError
由於HotSpot虛擬機器不允許虛擬機器棧容量動態擴充套件,所以測試使用兩種方式:
- 使用-Xss降低虛擬機器棧的容量
-
程式碼實現
public class JavaHeapDemo {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaHeapDemo oom = new JavaHeapDemo();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
- 執行結果
棧深度可以達到988.
-
建立更多的區域性變數,增大棧幀中的區域性變量表的長度
- 程式碼,建立了很多區域性變數,導致棧幀中的區域性表增大。
public class JavaHeapDemo { private int stackLength = 1; public void stackLeak() { long a1,a2,a3,a4,a5,a6,a7,a8,a9,a10, a11,a12,a13,a14,a15,a16,a17,a18,a19,a20, a21,a22,a23,a24,a25,a26,a27,a28,a29,a30, a31,a32,a33,a34,a35,a36,a37,a38,a39,a40, a41,a42,a43,a44,a45,a46,a47,a48,a49,a50, a51,a52,a53,a54,a55,a56,a57,a58,a59,a60, a61,a62,a63,a64,a65,a66,a67,a68,a69,a70, a71,a72,a73,a74,a75,a76,a77,a78,a79,a80, a81,a82,a83,a84,a85,a86,a87,a88,a89,a90, a91,a92,a93,a94,a95,a96,a97,a98,a99,a100; stackLength++; stackLeak(); a1=a2=a3=a4=a5=a6=a7=a8=a9=a10= a11=a12=a13=a14=a15=a16=a17=a18=a19=a20= a21=a22=a23=a24=a25=a26=a27=a28=a29=a30= a31=a32=a33=a34=a35=a36=a37=a38=a39=a40= a41=a42=a43=a44=a45=a46=a47=a48=a49=a50= a51=a52=a53=a54=a55=a56=a57=a58=a59=a60= a61=a62=a63=a64=a65=a66=a67=a68=a69=a70= a71=a72=a73=a74=a75=a76=a77=a78=a79=a80= a81=a82=a83=a84=a85=a86=a87=a88=a89=a90= a91=a92=a93=a94=a95=a96=a97=a98=a99=a100 = 0; } public static void main(String[] args) { JavaHeapDemo oom = new JavaHeapDemo(); try { oom.stackLeak(); } catch (Throwable e) { System.out.println("stack length:" + oom.stackLength); throw e; } } }
- 結果
棧的深度只到53,是因為區域性變量表變大,棧深度就減小了
3.3、方法區和執行時常量池溢位
3.3.1、執行時常量池
執行時常量池如果溢位,在JDK7以前,常量池都是分配在永久代中,我們可以通過-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可間接限制其中常量池的容量。
String::intern()是一個本地方法,它的作用是如果字串常量池中已經包含一個等於此String物件的
字串,則返回代表池中這個字串的String物件的引用;否則,會將此String物件包含的字串新增
到常量池中,並且返回此String物件的引用。
所以在JDK7以前,可以使用一直迴圈呼叫首次出現的字串的intern方法,就會產生記憶體溢位。
JDK7將字串常量池移動到了java堆中,所以在JDK7以後執行時常量池的溢位基本上不會發生。
在JDK7以上,常量池中記錄的是首次出現的例項引用,由於JDK6和7在常量池的這個區別,也產生了一些現象。如下:
public class ConstantDemo {
public static void main(String[] args) {
String str = new StringBuilder("計算機").append("軟體").toString();
System.out.println(str.intern() == str);
String str1 = new StringBuilder("ja").append("va").toString();
System.out.println(str1.intern() == str1);
}
}
在上述類中:JDK6會輸出兩個false,JDK7以上會輸出true、false
- 在JDK6中,第一個str建立後,記憶體如下:
執行str.intern()後,會將首次遇到的字串例項複製到常量池中,並且返回常量池中的字串引用。
JDK6中,通過StringBuilder建立的物件在堆中,常量池中返回的也是複製後的字串引用。所以JDK6中必然不可能相同。
- 在JDK7以上,字串常量池移動到了java堆中,常量池中記錄的是首次出現的例項引用。第一個str建立後,如下圖。
- 當呼叫了str.intern()後, 在堆中存在而不在常量池中存在的字串,在常量池中存放其引用
因為指向的是同一個例項,所以str.intern()==str為true。
在JDK7以上,str1為false,是因為“java”這個字串已經在常量池中了(在載入sun.misc.Version這個類的時候進入常量池的)。
在呼叫了str1.intern()後,發現常量池中已經有了“java”這個字串,直接返回該字串的引用。
所以str1.intern()==str1為false。
3.3.2、方法區
在JDK7將常量池移動到堆以後。方法區剩下的就是類名、訪問修飾符、常量池、欄位描述、方法描述等資訊,造成方法區溢位,可以使用反射在執行時產生大量的類去填滿方法區,直到溢位為止。
在JDK 8以後,永久代便完全退出了歷史舞臺,元空間作為其替代者登場,元空間是在本地記憶體中,受本地記憶體大小的限制。
元空間提供了一些引數,防止溢位(一般情況下不會產生)。
-XX:MaxMetaspaceSize:設定元空間最大值,預設是-1,即不限制,或者說只受限於本地記憶體大小。
-XX:MetaspaceSize:指定元空間的初始空間大小,以位元組為單位,達到該值就會觸發垃圾收集進行型別解除安裝,同時收集器會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那麼在不超過-XX:MaxMetaspaceSize(如果設定了的話)的情況下,適當提高該值。
3.4、直接記憶體溢位
直接記憶體(Direct Memory)的容量大小可通過-XX:MaxDirectMemorySize引數來指定,如果不去指定,則預設與Java堆最大值(由-Xmx指定一致。
由直接記憶體導致的記憶體溢位,一個明顯的特徵是在Heap Dump檔案中不會看見有什麼明顯的異常情況,如果發現記憶體溢位之後產生的Dump檔案很小,而程式中又直接或間接使用了DirectMemory(典型的間接使用就是NIO),那就可以考慮重點檢查一下直接記憶體方面的原因了。
本文來源:<<深入理解Java虛擬機器:JVM高階特性與最佳實踐(第3版)周志明.pdf>>