一、前言
java是一門跨硬體平台的物件導向進階程式設計語言,java程式運行在java虛擬機器上(JVM),由JVM管理記憶體,這點是和C++最大區別;雖然記憶體有JVM管理,但是我們也必須要理解JVM是如何管理記憶體的;JVM不是只有一種,當前存在的虛擬機器可能達幾十款,但是一個符合規範的虛擬機器設計是必須遵循《java 虛擬機器規範》的,本文是基於HotSpot虛擬機器描述,對於和其它虛擬機器有區別會提到;本文主要描述JVM中記憶體是如何分布、java程式的對象是如何儲存訪問、各個記憶體地區可能出現的異常。
二、JVM中記憶體分布(地區)
JVM在執行java程式的時會把記憶體分為多個不同的資料區域進行管理,這些地區有著不一樣的作用、建立和銷毀時間,有的地區是在JVM進程啟動時分配,有的地區則與使用者線程(程式本身的線程)的生命週期相關;按照JVM規範,JVM管理的記憶體地區分為以下幾個運行時資料區域:
1、虛擬機器棧
這塊記憶體地區是線程私人的,隨線程啟動而建立、線程銷毀而銷毀;虛擬機器棧描述的java方法執行的記憶體模型:每個方法在執行開始會建立一個棧幀(Stack Frame),用於儲存局部變數表、運算元棧,動態連結、方法出口等。每個方法的調用執行和返回結束,都對應有一個棧幀在虛擬機器棧入棧和出棧的過程。
局部變數表顧名思義是儲存局部變數的記憶體地區:存放編譯器期可知的基礎資料型別 (Elementary Data Type)(8種java基礎資料型別 (Elementary Data Type))、參考型別、返回地址;其中佔64位的long和double類型資料會佔用2個局部變數空間,其它資料類型只佔用1個;由於類型大小確定、變數數量編譯期可知,所以局部變數表在建立時是已知大小,這部分記憶體空間能在編譯期完成分配,並且在方法運行期間不需要修改局部變數表大小。
在虛擬機器規範中,對這塊記憶體地區規定了兩種異常:
1.如果線程請求的棧深度大於虛擬機器所允許的深度(?),將拋出StackOverflowError異常;
2.如果虛擬機器可以動態擴充,當擴充是無法申請到足夠記憶體,將拋出OutOfMemory異常;
2、本地方法棧
本地方法棧同樣也是線程私人,而且和虛擬機器棧作用幾乎是一樣的:虛擬機器棧是為java方法執行提供出入棧服務,而本地方法棧則是為虛擬機器執行Native方法提供服務。
在虛擬機器規範中,對本地方法棧實現方式沒有強制規定,可以由具體虛擬機器自由實現;HotSpot虛擬機器是直接把虛擬機器棧和本地方法棧合二為一實現;對於其他虛擬機器實現這一塊的方法,讀者有興趣可以自行查詢相關資料;
與虛擬機器棧一樣,本地方法棧同樣會拋出StackOverflowError和OutOfMemory異常。
3、程式計算機
程式器計算機也是線程私人的記憶體地區,可以認為是線程執行位元組碼的行號指標(指向一條指令),java執行時通過改變計數器的值來獲的下一條需要執行的指令,分支、迴圈、跳轉、異常處理、線程恢複等執行順序都要依賴這個計數器來完成。虛擬機器的多線程是通過輪流切換並分配處理器執行時間實現,處理器(對多核處理器來說是一個核心)在一個時刻只能在執行一條命令,因此線程執行切換後需要恢複到正確的執行位置,每個線程都有一個獨立的程式計算機。
在執行一個java方法時,這個程式計算機記錄(指向)當前線程正在執行的位元組碼指令地址,如果正在執行的是Native方法,這個計算機的值為undefined,這是因為HotSpot虛擬機器執行緒模式是原生執行緒模式,即每個java線程直接映射OS(作業系統)的線程,執行Native方法時,由OS直接執行,虛擬機器的這個計數器的值是無用的;由於這個計算機是一塊佔用空間很小的記憶體地區,為線程私人,不需要擴充,是虛擬機器規範中唯一一個沒有規定任何OutOfMemoryError異常的地區。
4、堆記憶體(Heap)
java 堆是線程共用的記憶體地區,可以說是虛擬機器管理的記憶體最大的一塊地區,在虛擬機器啟動時建立;java堆記憶體主要是儲存物件執行個體,幾乎所有的對象執行個體(包括數組)都是儲存在這裡,因此這也是記憶體回收(GC)最主要的記憶體地區,有關GC的內容這裡不做描述;
按照虛擬機器規範,java堆記憶體可以處於不連續的實體記憶體中,只要邏輯上是連續的,並且空間擴充也沒有限制,既可以是固定大小,也可以是棵擴充的;如果堆記憶體沒有足夠的空間完成執行個體分配,而且也無法擴充,將會拋出OutOfMemoryError異常。
5、方法區
方法區和堆記憶體一樣,是線程共用的記憶體地區;儲存已經被虛擬機器載入的類型資訊、常量、靜態變數、即時編譯期編譯後的代碼等資料;虛擬機器規範對於方法區的實現沒有過多限制,和堆記憶體一樣不需要連續的實體記憶體空間,大小可以固定或者可擴充,還可以選擇不實現記憶體回收;當方法區無法滿足記憶體配置需求時將會拋出OutOfMemoryError異常。
6、直接記憶體
直接記憶體並不是虛擬機器管理記憶體的一部分,但是這部分記憶體還是可能被頻繁用到;在java程式使用到Native方法時(如 NIO,有關NIO這裡不做描述),可能會直接在堆外分配記憶體,但是記憶體總空間大小是有限的,也會遇到記憶體不足的情況,一樣會拋出OutOfMemoryError異常。
二、執行個體Object Storage Service訪問
上面第一點對虛擬機器各地區記憶體有個總體的描述,對於每個地區,都存在資料是如何建立、布局、訪問的問題,我們以最常使用的的堆記憶體為例基於HotSpot說下這三個方面。
1、執行個體對象建立
當虛擬機器執行到一條new指令時,首先首先從常量池定位這個建立對象的類符號引用、判斷檢查類是否已經載入初始化,如果沒有載入,則執行類載入初始化過程(關於類載入,這裡不做描述),如果這個類找不到,則拋出常見的ClassNotFoundException異常;
通過類載入檢查後,就是實際為對象分配實體記憶體(堆記憶體),對象所需的記憶體空間大小是由對應的類確定的,類載入後,這個類的對象所需的記憶體空間是固定的;為對象分配記憶體空間,相當於要從堆中劃分出一塊出來分配給這個對象;
根據記憶體空間是否連續(已指派和未分配是區分為完整的兩部分)分為兩種分配記憶體方式:
1. 連續的記憶體:已指派和未分配中間使用一個指標作為分界點,對象記憶體配置只需要指標向未分配記憶體段移動一段空間大小即可;這種方式稱 為“指標碰撞”。
2. 非連續記憶體:虛擬機器需要維護(記錄)一個列表,記錄堆中那些記憶體塊的沒有分配的,在指派至記憶體時從中選擇一塊適合大小的記憶體地區 分配給對象,並更新這個列表;這種方式稱為“空閑列表”。
對象記憶體的分配也會遇到並發的問題,虛擬機器使用兩種方案解決這個安全執行緒問題:第一使用CAS(Compare and set)+識別重試,保證分配操作的原子性;第二是記憶體配置按照線程劃分不同的空間,即每個線程在堆中預先分配好一塊線程私人的記憶體,稱為本地線程分配緩衝區(Thread Local Allocation Buffer,TLAB);那個線程要分配記憶體時,直接從TLAB中分配出來,只有當線程的TLAB分配完需要重新分配,才需要同步操作從堆中分配,這個方案有效減少線程間對象分配堆記憶體的並發情況出現;虛擬機器是否使用TLAB這種方案,是通過JVM參數 -XX:+/-UseTLAB 設定。
完成記憶體配置後,除對象頭資訊外,虛擬機器會將分配到的記憶體空間初始化為零值,保證對象執行個體的欄位可以不賦值就可直接使用到資料類型對應的零值;緊接著,執行 init 方法按照程式碼完成初始化,才完成一個執行個體對象的建立;
2、對象在記憶體的布局
在HotSpot虛擬機器中,對象在記憶體分為3個部分:對象頭(Header)、執行個體資料(Instance Data)、對齊填充(Padding):
其中對象頭又分兩個部分:一部分儲存物件運行時資料,包括雜湊碼、記憶體回收分代年齡、對象鎖狀態、線程持有的鎖、偏向線程ID、偏向 時間戳記等;在32位和64位虛擬機器中,這部分資料分別佔用32位和64位;由於運行時資料較多,32位或者64位不足以完全儲存全部資料,所以 這部分設計為非固定格式儲存運行時資料,而是根據對象的狀態不同而使用不同位來儲存資料;另一部分儲存物件類型指標,指向這個對象的 類,但這並不是必須的,對象的類別中繼資料不一定要使用這部分儲存來確定(下面會講到);
執行個體資料則是儲存物件定義的各種類型資料的內容,而這些程式定義的資料並不是完全按照定義的順序儲存的,它們是按照虛擬機器分配策略和定義的順序確定:long/double、int、short/char、byte/boolean、oop(Ordinary Object Ponint),可以看出,策略是按照類型佔位多少分配的,相同的類型會在一起分配記憶體;而且,在滿足這些前提條件下,父類變數順序先於子類;
而對象填充這部分不是一定會存在,它僅僅是起到佔位對齊的作用,在HotSpot虛擬機器記憶體管理是按照8位元組為單位管理,因此當分配完記憶體後,對象大小不是8的倍數,則由對齊填充補全;
3、對象的訪問
在java程式中,我們建立了一個對象,實際上我們得到一個參考型別變數,通過這個變數來實際操作一個在堆記憶體中的執行個體;在虛擬機器規範中,只規定了引用(reference)類型是指向對象的引用,沒有規定這個引用是如何去定位、訪問到堆中執行個體的;目前主流的虛擬機器中,主要有兩種方式實現對象的訪問:
1. 控制代碼方式:堆記憶體中劃分出一塊地區作為控制代碼池,引用變數中儲存的是對象的控制代碼地址,而控制代碼中儲存了樣本對象和物件類型的具體地址資訊,因此對象頭中可以不包含對象的類型:
2. 指標直接存取:參考型別直接儲存的是執行個體對象在堆中的地址資訊,但是這就必須要求執行個體對象的布局中,對象頭必須包含對象的類型:
這兩種訪問方式各有優勢:當對象地址改變(記憶體整理、記憶體回收),控制代碼方式訪問對象,引用變數不需要改變,只需要改變控制代碼中的對象地址值就可;而使用指標直接存取方式,則需要修改這個對象全部的引用;但是指標方式,可以減少一次定址操作,在大量對象訪問的情況下,這種方式的優勢比較明顯;HotSpot虛擬機器就是使用這中指標直接存取方式。
三、運行時記憶體異常
java程式記憶體在運行時主要可能發生兩種異常情況:OutOfMemoryError、StackOverflowError;那個記憶體地區會發生什麼異常,前面已經簡單提到,除了程式計數器已外,其他記憶體地區都會發生;本節主要通過執行個體代碼示範各個記憶體地區發生異常的情況,其中會使用到許多常用的虛擬機器啟動參數以便更好說明情況。(如何使用參數運行程式這裡不做描述)
1、java堆記憶體溢出
堆記憶體溢出發生在堆容量達到最大堆容量後建立對象情況下,在程式中只要不斷的建立對象,並且保證這些對象不會被記憶體回收:
/** * 虛擬機器參數: * -Xms20m 最小堆容量 * -Xmx20m 最大堆容量 * @author hwz * */public class HeadOutOfMemoryError { public static void main(String[] args) { //使用容器儲存對象,保證對象不被記憶體回收 List<HeadOutOfMemoryError> listToHoldObj = new ArrayList<HeadOutOfMemoryError>(); while(true) { //不斷建立對象並加入容器中 listToHoldObj.add(new HeadOutOfMemoryError()); } }}
這裡可以加上虛擬機器參數:-XX:HeapDumpOnOutOfMemoryError,在發送OOM異常的時候讓虛擬機器轉儲當前堆的快照檔案,後續可以通過這個檔案分詞異常問題,這個不做詳細描述,後續再寫個部落格詳細描述使用MAT工具分析記憶體問題。
2、虛擬機器棧和本地方法棧溢出
在HotSpot虛擬機器中,這兩個方法棧是沒有一起實現的,根據虛擬機器規範,這兩塊記憶體地區會發生這兩種異常:
1. 如果線程請求棧深度大於虛擬機器允許的最大深度,拋出StackOverflowError異常;
2. 如果虛擬機器在擴充棧空間時,無法申請大記憶體空間,將拋出OutOfMemoryError異常;
這兩種情況實際上是存在重疊的:當棧空間無法繼續分配是,到底是記憶體太小還是已使用的棧深度太大,這個無法很好的區分。
使用兩種方式測試代碼
1. 使用-Xss參數減少棧大小,無限遞迴調用一個方法,無限加大棧深度:
/** * 虛擬機器參數:<br> * -Xss128k 棧容量 * @author hwz * */public class StackOverflowError { private int stackDeep = 1; /** * 無限遞迴,無限加大調用棧深度 */ public void recursiveInvoke() { stackDeep++; recursiveInvoke(); } public static void main(String[] args) { StackOverflowError soe = new StackOverflowError(); try { soe.recursiveInvoke(); } catch (Throwable e) { System.out.println("stack deep = " + soe.stackDeep); throw e; } }}
方法中定義大量本地變數,增加方法棧中本地變數表的長度,同樣無限遞迴調用:
/** * @author hwz * */public class StackOOMError { private int stackDeep = 1; /** * 定義大量本地變數,增大棧中本地變數表 * 無限遞迴,無限加大調用棧深度 */ public void recursiveInvoke() { Double i; Double i2; //.......此處省略大量變數定義 stackDeep++; recursiveInvoke(); } public static void main(String[] args) { StackOOMError soe = new StackOOMError(); try { soe.recursiveInvoke(); } catch (Throwable e) { System.out.println("stack deep = " + soe.stackDeep); throw e; } }}
以上代碼測試說明,無論是幀棧太大還是虛擬機器容量太小,當記憶體無法分配時,拋出的都是StackOverflowError異常;
3、方法區和運行時常量池溢出
這裡先描述一下String的intern方法:如果字串常量池已經包含一個等於此String對象的字串,則返回代表這個字串的String對象,否則將此String對象添加到常量池中,並返回此String對象的引用;通過這個方法不斷在常量池中增加String對象,導致溢出:
/** * 虛擬機器參數:<br> * -XX:PermSize=10M 永久區大小 * -XX:MaxPermSize=10M 永久區最大容量 * @author hwz * */public class RuntimeConstancePoolOOM { public static void main(String[] args) { //使用容器儲存對象,保證對象不被記憶體回收 List<String> list = new ArrayList<String>(); //使用String.intern方法,增加常量池的對象 for (int i=1; true; i++) { list.add(String.valueOf(i).intern()); } }}
但是這段測試代碼在JDK1.7下沒有發生運行時常量池溢出,在JDK1.6倒是會發生,為此再寫一段測試代碼驗證這個問題:
/** * String.intern方法在不同JDK下測試 * @author hwz * */public class StringInternTest { public static void main(String[] args) { String str1 = new StringBuilder("test").append("01").toString(); System.out.println(str1.intern() == str1); String str2 = new StringBuilder("test").append("02").toString(); System.out.println(str2.intern() == str2); }}
在JDK1.6下運行結果為:false、false;
在JDK1.7下運行結果為:true、true;
原來在JDK1.6中,intern()方法把首次遇到的字串執行個體複製到永久代,反回的是永久代中的執行個體的引用,而有StringBuilder建立的字串執行個體在堆中,所以不相等;
而在JDK1.7中,intern()方法不會複製執行個體,只是在常量池記錄首次出現的執行個體的引用,因此intern返回的引用和StringBuilder建立的執行個體是同一個,所以返回true;
所以常量池溢出的測試代碼不會發生常量池溢出異常,而是在不斷運行後可能發生堆記憶體不足溢出異常;
那要測試方法區溢出,只要不斷往方法區加入東西就行了,比如類名、存取修飾詞、常量池等。我們可以讓程式載入大量的類去不斷填充方法區從而導致溢出,這個我們使用CGLib直接操作位元組碼產生大量動態類:
/** * 方法區記憶體溢出測試類別 * @author hwz * */public class MethodAreaOOM { public static void main(String[] args) { //使用GCLib無限動態建立子類 while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(MAOOMClass.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(); } } static class MAOOMClass {}}
通過VisualVM觀察可以看到,JVM載入類的數量和PerGen的使用成直線上升:
4、直接記憶體溢出
直接記憶體的大小可以通過虛擬機器參數設定:-XX:MaxDirectMemorySize,要使直接記憶體溢出,只需要不斷的申請直接記憶體即可,以下同Java NIO 中直接記憶體緩衝測試:
/** * 虛擬機器參數:<br> * -XX:MaxDirectMemorySize=30M 直接記憶體大小 * @author hwz * */public class DirectMemoryOOm { public static void main(String[] args) { List<Buffer> buffers = new ArrayList<Buffer>(); int i = 0; while (true) { //列印當前第幾次 System.out.println(++i); //通過不斷申請直接緩衝區記憶體消耗直接記憶體 buffers.add(ByteBuffer.allocateDirect(1024*1024)); //每次申請1M } }}
在迴圈中,每次申請1M直接記憶體,設定最大直接記憶體為30M,程式運行到31次時拋出異常:java.lang.OutOfMemoryError: Direct buffer memory
四、總結
以上就是本文的全部內容,本文主要描述JVM中記憶體的布局結構、Object Storage Service和訪問已經各個記憶體地區可能出現的記憶體異常;主要參考書目《深入理解Java虛擬機器(第二版)》,如有不正確之處,還請在評論中指出;謝謝大家對雲棲社區的支援。