1.本章內容目錄: 概述 運行時數據區域 程式計數器 java虛擬機棧 本地方法棧 java堆 方法區 運行時常量池 直接記憶體 HotSpot虛擬機對象探秘 對象的創建 對象的記憶體佈局 對象的訪問定位 程式計數器 java虛擬機棧 本地方法棧 java堆 方法區 運行時常量池 直接記憶體 對象的創建 ...
1.本章內容目錄:(java記憶體區域於記憶體溢出異常)
- 概述
- 運行時數據區域
- 程式計數器
- java虛擬機棧
- 本地方法棧
- java堆
- 方法區
- 運行時常量池
- 直接記憶體
- HotSpot虛擬機對象探秘
- 對象的創建
- 對象的記憶體佈局
- 對象的訪問定位
- 實戰:OutOfMemoryError異常
- java堆溢出
- 虛擬機棧和本地方法棧溢出
- 方法區和運行時常量池溢出
- 本機直接記憶體溢出
2.本章具體內容:
2.1 概述:
對於C/C++而言,記憶體管理具有最高的權利,既擁有每一個對象的“所有權”,又擔負著每一個對象生命開始到結束的維護責任。
對於java而言,則把記憶體控制的權利交給了java虛擬機,不再需要為每一個new操作去寫配對的delete/free代碼,不容易出現記憶體泄露和溢出問題。但是,一旦出現記憶體泄露和溢出方面的問題,如果不瞭解虛擬機記憶體運行過程,排查會很艱難。以下是整個java虛擬機運行的基本結構。
2-1
2.2 運行時數據區域
java虛擬機運行時數據區域如下圖所示。其中,虛擬機棧、本地方法棧、程式計數器為線程隔離的數據區。而本地庫介面、堆、執行引擎和方法區則是所有線程共用的區域。
2-2
2.2.1 程式計數器
程式計數器是一塊很小的記憶體空間,是當前線程所執行的位元組碼的行號指示器。利用它來選取下一條執行的位元組碼的行號指令。(分支、迴圈、跳轉、異常處理、線程恢復等都依賴這個計數器來完成)
java虛擬機的多線程是通過線程的輪流切換並分配處理器執行時間來實現的。一個處理器(多核處理器而言只是一個內核)在任何一個確定的時刻只會處理一條線程中的指令。為了線程切換後能恢復到正確的位置,每條線程都需要一個獨立的程式計數器,各個線程之間獨立存儲,互不影響,我們把這類記憶體區域稱之為“線程私有”的記憶體。
如果線程正在執行一個java方法,則這個計數器記錄的是正在執行的虛擬機位元組碼指令的地址。如果正在執行Native方法(本地方法),這個計數器則為空(undefined)。此記憶體區域是唯一一個在java虛擬機規範中沒有規定OutOfMemoryError情況的區域。
2.2.2 java虛擬機棧
java虛擬機棧(Java Virtual Machine Starks)也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是java方法執行的記憶體模型:每個方法在執行時會創建一個棧幀(Stack Fram)用於存儲局部變數表、操作數棧、動態鏈接、方法出口等信息。每一個方法調用直至完成過程,就對應著一個棧在虛擬機中入棧到出棧的過程。
虛擬機棧中的局部變數表,存放了編譯期可知的各種基本數據類型(boolean、byte、char、short、int、float、double)、對象引用(reference類型,它不等同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或者其他與此對象相關的位置)、returnAddress類型(指向一條位元組碼指令的地址)。其中,64位長度的long和double類型的數據會占用2個局部變數空間(Slot),其餘的數據類型只占用1個。局部變數表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變數空間完全確定,方法運行期間不會改變局部變數表的大小。
java虛擬機棧中規定了兩種異常處理狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError異常;如果虛擬機棧可以動態擴展(目前大部分虛擬機都可以動態擴展),當擴展時無法申請到足夠的記憶體,就會拋出OutOfMemoryError異常。
2.2.3 本地方法棧(Native Method Stack)
本地方法棧(Native Method Stack)和虛擬機棧所發揮的作用是相似的。唯一的區別是,虛擬機棧為虛擬機執行java方法(也就是位元組碼)服務,而本地方法棧則為虛擬機使用到的Native方法服務。
有的虛擬機直接把本地方法棧和虛擬機棧合二為一。和虛擬機棧一樣,本地方法棧也會拋出StackOverflowError和OutOfMemoryError異常。
(註: native方法:一個Native Method就是一個java調用非java代碼的介面。一個Native Method是這樣一個java的方法:該方法的實現由非java語言實現,比如C。這個特征並非java所特有,很多其它的編程語言都有這一機制,比如在C++中,你可以用extern "C"告知C++編譯器去調用一個C的函數。 有時java應用需要與java外面的環境交互。這是本地方法存在的主要原因,你可以想想java需要與一些底層系統如操作系統或某些硬體交換信息時的情況。本地方法正是這樣一種交流機制:它為我們提供了一個非常簡潔的介面,而且我們無需去瞭解java應用之外的繁瑣的細節。)
2.2.4 Java堆(Java Heap)
一般而言,java堆(java heap)是java虛擬機所管理的記憶體中最大的一塊。java堆被所有線程共用的一個記憶體區域,在虛擬機啟動時被創建。這部分記憶體創建的唯一目的就是存放對象實例,幾乎所有的對象實例都在這裡分配記憶體。java虛擬機規範中描述是“所有的對象實例和數組都要在堆上分配”。
java堆是垃圾收集器管理的主要區域,因此很多時候也被稱之為“GC堆”(Garbage Collected Heap)。
java堆可以處於物理上不連續的記憶體空間中,只需要邏輯上是連續的既可,就像我們的磁碟空間一樣。實現時,可以時固定大小的,也可以是可擴展來實現的。如果堆中沒有完成實例分配,並且堆也無法再擴展時,將會拋出OutOfMemoryError異常。
2.2.5 方法區
方法區(Method Area)與Java堆一樣,是各個線程共用的記憶體區域,它用於存儲已經被虛擬機載入的類信息、常量、靜態變數、即時編譯器編譯後的代碼等數據。java虛擬機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名“No-Heap”(非堆),目的是與java堆區分開。
java虛擬機的方法區,除了和java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。垃圾收集的行為在這個區域是比較少見的,但並不是數據進入了方法區就相當於“永遠存在了”。這個區域的回收目標主要是針對常量池的回收和對類型的卸載。
當方法區無法滿足記憶體分配需求時,將拋出OutOfMemoryError異常。
2.2.6 運行時常量池
運行時常量池(Runtime Constant Pool)是方法區的一部分 。 Class文件中除了有類的版本、欄位、方法、介面等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯時期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的運行時常量池中存放。
常量池是方法區的一部分,所以,當記憶體池無法申請到方法時會拋出OutOfMemoryError異常。
2.2.7 直接記憶體
直接記憶體並不是虛擬機運行數據的一部分,也不是java虛擬機規範中定義的記憶體區域,為了以示區分,寫在這裡。
在JDK1.4中新加入了NIO(new input/output)類,引入了一種基於通道(Channel)和緩衝區(Buffer)的I/O方式,它可以使用Native函數庫直接分配堆外記憶體,然後通過一個存儲在java堆中的DirectByteBuffer對象作為這塊記憶體的引用進行操作。這樣通過一個存儲在java堆中的DirectByteBuffer對象作為這塊記憶體的引用進行操作。避免了在java堆和native堆中來回覆制數據,進而提高了性能。
2.3 HotSpot虛擬機對象探秘
2.3.1 對象的創建
以下是整個虛擬機對象創建的全過程。其中,虛擬機為新生對象分配記憶體的方法分為兩種:第一種,java堆是絕對規整的,所有用過的記憶體都放在一邊,空閑記憶體放在另外一邊,中間放著一個指針視作為分界點的指示器,那所分配的記憶體就僅僅是把那個指針指向空閑空間那邊挪動一段於對象大小相等的距離,這種分配方法稱為“指針碰撞”(Bump the Point);第二種,java堆中的記憶體並不規整,已使用的記憶體和空閑的記憶體相互交錯,虛擬機必須維護一個列表,記錄上哪些記憶體塊是可用的,分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式稱之為“空閑列表”(Free List)。選擇哪種分配方式由java堆是否規整決定,而java堆是否規整又由所採用垃圾收集器是否帶有壓縮整理功能決定。此外,在劃分可用空間的過程中可能對多個對象進行分配記憶體,正在對對象A分配記憶體的時候,指針還沒來得及修改,對象B同時使用了原來的指針分配記憶體。解決這個問題有兩個方案:方案一:記憶體空間的動作進行同步處理;方案二:把記憶體分配動作按照線程劃分在不同的空間中進行,即每個線程在java堆中預先分配一小塊記憶體,稱之為本地線程分配緩衝(Thread Local Allocation Buffer,TLAB)。
2-3
2.3.2 對象的記憶體佈局
在hotspot虛擬機中,對象在記憶體中存儲的佈局可以分為3塊區域:對象頭(Header)、實例數據(Instance Data)、和對齊填充(padding)。
HotSpot虛擬機的對象頭包括兩部分信息,第一部分用於存儲對象自身的運行時數據,如:哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等。
表2-3-2 HotSpot虛擬機對象頭 Mark Word
存儲內容 | 標誌位 | 狀態 |
對象哈希碼、對象分代年齡 | 01 | 未鎖定 |
指向鎖記錄的指針 | 00 | 輕量級鎖定 |
指向重量級鎖的指針 | 10 | 膨脹(重量級鎖定) |
空,不需要記錄信息 | 11 | GC標誌 |
偏向線程ID、偏向時間戳、對象分代年齡 |
01 | 可偏向 |
對象頭的另外一部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。如果對象是一個java屬數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因為虛擬機可以通過普通java對象的元數據信息確定java對象的大小,但是從數組的元數據中卻無法確定數組的大小。
實例數據部分是對象真正存儲的有效信息,也是程式代碼中所定義的各種類型的欄位內容。無論是從父類繼承下來的,還是子類中定義的,都需要記錄起來。
對齊填充並不是必然存在的,沒有什麼特殊意義,它僅僅是起著占位符的作用。由於HotSpot VM的自動記憶體管理系統要求對象起始地址必須是8位元組的整數倍。也就是說,對象的大小必須是8位元組的整數被。因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。
2.3.3 對象的訪問定位
建立對象是為了使用對象,我們的java程式需要通過棧上的reference數據來操作堆上的具體對象。由於reference類型在java虛擬機中規定了一個指向對象的引用,並沒有定義這個引用應該通過何種方式去定位、訪問堆中的對象的具體位置,所以對象的訪問方式也是取決於虛擬機實現而定的。目前主流的對象訪問方式有使用句柄和直接指針兩種。
(1)句柄訪問對象
如果使用句柄訪問的話,那麼java堆中將會劃分一塊記憶體來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據和類型數據各自的具體地址信息。如下圖所示:(ps:句柄(reference)是一種特殊的智能指針,當一個應用程式要引用其他系統(如資料庫、操作系統)所管理的記憶體塊或對象時,就要使用句柄,在C++中句柄稱為引用)。
2-4
(2) 直接指針訪問
如果使用直接指針訪問,那麼java堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息,而reference中存儲的直接就是對象地址,如下圖所示,
2-5
(2) 比較兩種訪問方式的優勢
句柄訪問最大的好處是reference中存儲的是穩定的句柄地址,在對象被移動(垃圾收集是移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而reference本身不需要修改。
直接指針訪問最大的好處是速度更快節省了一次指針定位的時間開銷,由於對象訪問在java中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成本。
記憶體泄露:指程式中動態分配記憶體給一些臨時對象,但是對象不會被GC所回收,它始終占用記憶體。即被分配的對象可達但已無用。作者: yeiqing000
鏈接:http://www.imooc.com/article/15379
來源:慕課網
(註解:記憶體泄露:指程式中動態分配記憶體給一些臨時對象,但是,對象不會被GC所回收,它始終占用記憶體,即分配的對象可達但已無用;
記憶體溢出:是指程式運行過程中,無法申請到足夠的記憶體而發生的一種錯誤;)
2.4 實戰:OutOfMemoryError 異常(簡稱“OOM”異常)
2.4.1 java堆溢出
java堆用於存儲對象實例,只要不斷地創建對象,並且保證GC Roots到對象之間有可到達路徑來避免垃圾回收機制清除這些對象,數量到達最大堆的容量限制後就會產生記憶體異常。
代碼清單中代碼限制java堆的大小為20MB,不可擴展(將堆的最小值-Xms參數與最大值-Xmx參數設置為一樣既可避免堆自動擴展),通過參數-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機在出現記憶體溢出異常時Dump出當前的記憶體堆轉儲快照一邊事後分析。
/**
*VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*
*/
public class HeapOOM{ static class OOMObject{}; public static void main(String[] args){ List<OOMObject> list = new ArrayList<OOMObject>(); while(ture) { list.add(new OOMObject()); } } }
運行結果:
java.lang.OutOfMemoryError:java Heap Space
Dumping heap to java_pid3404.hprof ...
Heap dump file created [22045981 bytes in 0.663 secs]
java堆記憶體的OOM異常是實際應用中常見的記憶體溢出異常情況。當出現java堆記憶體溢出時候,異常棧信息“java.lang.OutOfMemoryError”會跟著進一步提示“java Heap Space”。
2.4.2 java虛擬機棧和本地方法棧溢出
HotSpot虛擬機中並不區分虛擬機棧和本地方法棧,因此,對於HotSpot來說雖然-Xoss參數(本地方法棧大小)存在,但實際上是無效的,棧容量只由-Xss參數設定。關於虛擬機棧和本地方法棧,在java虛擬機中描述了兩種異常:
- 如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StarkOverflowError異常;
- 如果虛擬機無法申請到足夠大的記憶體空間,將拋出OutOfMemoryError異常;
- 使用-Xss參數減少棧記憶體容量。結果拋出StarkOverflowError異常,異常出現時輸出的堆棧深度相應縮小;
- 定義了大量的本地變數,增大此方法幀中本地變數表的長度。結果:拋出StarkOverflowError異常使輸出的對堆棧深度相應縮小。
此處代碼測試第一點;
/** *VM Args: -Xss128k * */ public class JavaVMStackSOF{ private int stackLength = 1; public void stackLeak(){ stackLength++; stackLeak(); } public static void main(String[] args) throws Throwable { JavaVMStackSOF oom = new JavaVMStackSOF(); try{ oom.stackLeak(); } catch (Throwable e){ System.out.println("stack length:"+oom.stackLength); throw e; } } }
運行結果:
stack length:2402 Exception in thread "main" java.lang.StackOverflowError ......
結果表明:在單個線程下,無論由於棧幀太大還是虛擬機棧容量太小,當記憶體無發分配的時候,虛擬機拋出的都是StackOverflowError異常。
如果創建的不限於單線程,通過不斷的建立多線程倒是可以產生記憶體溢出的異常,如下代碼清單所示:
/** *VM Args: -Xss2M(這個時候可以設置大一些) * */
public class javaVMStackOOM{ private void dontStop() { while(true){} }
public void stackLeakByThread() { while(ture){ Thread thread = new Thread (new Runnable() { @override public void run() { dontStop(); } }); thread.start(); } public static void main(String[] args) throws Throwable { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); }
}
運行結果為:
Exception in thread "main" java.lang.OutOfMemoryError:unable to create new native thread.
多線程產生的記憶體溢出(OutOfMemoryError)與棧空間的大小不存在任何聯繫,準確地說,在這種情況下,為每個線程的棧分配的記憶體越大,反而越容易產生產生記憶體溢出異常(因為棧記憶體越大,java堆記憶體變小,能夠分配的線程數量就越少,越容易造成記憶體溢出OutOfMemoryError)。而棧溢出(stackoverflow)才與棧分配記憶體大小直接相關。
多線程導致的記憶體溢出,在不能減少線程數或者更換64位虛擬機情況下,就只能通過減少最大堆和減少棧容量來換取更多的線程。因為,棧的容量減小,java堆就能夠獲得更多的記憶體,也就可以分配更多的線程;此外,最大堆減小,java堆上剩餘的堆空間就越多,同樣可以創建更多的線程。(參見JVM內部原理圖可知,圖2-1/圖2-2)。
2.4.3 方法區和運行時常量池溢出
由於運行時常量池是方法區的一部分,因此這兩個區域的溢出測試就放在一起進行。
String.intern()是一個native方法(C/C++寫的),他的作用是:如果字元串常量池中已經包含一個等於此String對象的字元串,則返回代表池中這個字元串的String對象,否則,將此String對象包含的字元串添加到常量池中,並且返回此String對象的引用。
運行時常量池導致的記憶體溢出異常:(JDK1.6)
/** *VM Args: -XX:PermSize = 10M -XX:MaxPermSize = 10M * */ public class RuntimeConstantPoolOOM{ public static void main(String[] args) { List<String> list = new ArrayList<String>(); //使用List保持著常量池的引用,避免Full GC回收常量池行為
int i = 0; //10MB的Permsize在integer範圍內足夠產生OOM了
while(ture){
list.add(String.valueOf(i++).intern()); }
}
}
運行結果:
Exception in thread ”main“ java.lang.OutOfMemoryError:PermGen space
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18
運行結果可以看到,OutOfMemoryError後面緊跟的提示信息是”PermGen space“,說明運行時常量池屬於方法區的一部分。
String.intern()返回引用的測試:(JDK1.7)
public class RuntimeConstantPoolOOM { public static void main (String[] args) { public void main(String[] args) { String str1 = new StringBuilder ("電腦").append("軟體").toString(); System.out.println (str2.intern () == str1); String str2 = new StringBuilder("ja").append("va").ToString(); System.out.println(str2.intern() == str2); } } }
JDK1.6中運行會得到2個false,而JDK1.7會得到一個ture和一個false。產生差異的原因是在JDK1.6中,intern()方法會把首次遇到的字元實例複製到永久代中,返回的也會是永久代中這個實例的引用,而由StringBuilder創建的字元串實例在java堆上,所以必然不是同一個引用,將返回false。而JDK1.7中的intern()實現不會再複製實例,只是在常量池記錄首次出現的實例引用,因此intern()返回的引用和由StringBuilder創建的字元串是同一個。
2.4.4 本機直接記憶體溢出
DirectMemory容量可以通過-XX:MaxDirectMemorySize指定,如果不指定,則預設與java堆最大值(-Xmx指定)一樣。
使用unsafe分配本機記憶體:
/** *VM Args: -Xmx20M -xx:MaxDirectMemorySize = 10M * */ public class DirectMemoryOOM { private static final int_1MB = 1024*1024; public static void main(String[] args) throws Exception { Filed unsafeFiled =Unsafe.class.getDeclaredFields () [0]; unsafeField.setAccessible(ture); Unsafe unsafe = (Unsafe) unsafeFiled.get(null); while(ture){ unsafe.allocateMemory (_1MB); } }
}
運行結果:
Exception in thread "main" java.langOutOfMemoryError
... ..
上述代碼越過了DirectByteBuffer類,直接通過反射獲取unsafe實例進行記憶體分配。雖然使用DirectByteBuffer分配記憶體也會拋出記憶體異常溢出異常,但它拋出異常時並沒有真正向操作系統申請分配記憶體,而是通過計算得知記憶體無法滿足需求(無法分配),於是手動拋出異常,真正申請分配記憶體的方法是 unsafe.allocateMemory()。
由DirectMemory導致的記憶體溢出,一個明顯的特征是在HeapDump文件中不會看見明顯的異常,如果讀者發現記憶體溢出(OOM)之後的Dump文件很小,而程式又直接或者間接使用了NIO,那就考慮一下是否是這方面的原因。