學習JVM之java內存區(qū)域與異常
一、前言
java是一門跨硬件平臺的面向對象高級編程語言,java程序運行在java虛擬機上(JVM),由JVM管理內存,這點是和C++最大區(qū)別;雖然內存有JVM管理,但是我們也必須要理解JVM是如何管理內存的;JVM不是只有一種,當前存在的虛擬機可能達幾十款,但是一個符合規(guī)范的虛擬機設計是必須遵循《java 虛擬機規(guī)范》的,本文是基于HotSpot虛擬機描述,對于和其它虛擬機有區(qū)別會提到;本文主要描述JVM中內存是如何分布、java程序的對象是如何存儲訪問、各個內存區(qū)域可能出現(xiàn)的異常。
二、JVM中內存分布(區(qū)域)
JVM在執(zhí)行java程序的時會把內存分為多個不同的數(shù)據(jù)區(qū)域進行管理,這些區(qū)域有著不一樣的作用、創(chuàng)建和銷毀時間,有的區(qū)域是在JVM進程啟動時分配,有的區(qū)域則與用戶線程(程序本身的線程)的生命周期相關;按照JVM規(guī)范,JVM管理的內存區(qū)域分為以下幾個運行時數(shù)據(jù)區(qū)域:
1、虛擬機棧
這塊內存區(qū)域是線程私有的,隨線程啟動而創(chuàng)建、線程銷毀而銷毀;虛擬機棧描述的java方法執(zhí)行的內存模型:每個方法在執(zhí)行開始會創(chuàng)建一個棧幀(Stack Frame),用于存儲局部變量表、操作數(shù)棧,動態(tài)鏈接、方法出口等。每個方法的調用執(zhí)行和返回結束,都對應有一個棧幀在虛擬機棧入棧和出棧的過程。
局部變量表顧名思義是存儲局部變量的內存區(qū)域:存放編譯器期可知的基本數(shù)據(jù)類型(8種java基本數(shù)據(jù)類型)、引用類型、返回地址;其中占64位的long和double類型數(shù)據(jù)會占用2個局部變量空間,其它數(shù)據(jù)類型只占用1個;由于類型大小確定、變量數(shù)量編譯期可知,所以局部變量表在創(chuàng)建時是已知大小,這部分內存空間能在編譯期完成分配,并且在方法運行期間不需要修改局部變量表大小。
在虛擬機規(guī)范中,對這塊內存區(qū)域規(guī)定了兩種異常:
1.如果線程請求的棧深度大于虛擬機所允許的深度(?),將拋出StackOverflowError
異常;
2.如果虛擬機可以動態(tài)擴展,當擴展是無法申請到足夠內存,將拋出OutOfMemory
異常;
2、本地方法棧
本地方法棧同樣也是線程私有,而且和虛擬機棧作用幾乎是一樣的:虛擬機棧是為java方法執(zhí)行提供出入棧服務,而本地方法棧則是為虛擬機執(zhí)行Native方法提供服務。
在虛擬機規(guī)范中,對本地方法棧實現(xiàn)方式?jīng)]有強制規(guī)定,可以由具體虛擬機自由實現(xiàn);HotSpot虛擬機是直接把虛擬機棧和本地方法棧合二為一實現(xiàn);對于其他虛擬機實現(xiàn)這一塊的方法,讀者有興趣可以自行查詢相關資料;
與虛擬機棧一樣,本地方法棧同樣會拋出StackOverflowError和OutOfMemory
異常。
3、程序計算器
程序器計算器也是線程私有的內存區(qū)域,可以認為是線程執(zhí)行字節(jié)碼的行號指示器(指向一條指令),java執(zhí)行時通過改變計數(shù)器的值來獲的下一條需要執(zhí)行的指令,分支、循環(huán)、跳轉、異常處理、線程恢復等執(zhí)行順序都要依賴這個計數(shù)器來完成。虛擬機的多線程是通過輪流切換并分配處理器執(zhí)行時間實現(xiàn),處理器(對多核處理器來說是一個內核)在一個時刻只能在執(zhí)行一條命令,因此線程執(zhí)行切換后需要恢復到正確的執(zhí)行位置,每個線程都有一個獨立的程序計算器。
在執(zhí)行一個java方法時,這個程序計算器記錄(指向)當前線程正在執(zhí)行的字節(jié)碼指令地址,如果正在執(zhí)行的是Native方法,這個計算器的值為undefined,這是因為HotSpot虛擬機線程模型是原生線程模型,即每個java線程直接映射OS(操作系統(tǒng))的線程,執(zhí)行Native方法時,由OS直接執(zhí)行,虛擬機的這個計數(shù)器的值是無用的;由于這個計算器是一塊占用空間很小的內存區(qū)域,為線程私有,不需要擴展,是虛擬機規(guī)范中唯一一個沒有規(guī)定任何OutOfMemoryError
異常的區(qū)域。
4、堆內存(Heap)
java 堆是線程共享的內存區(qū)域,可以說是虛擬機管理的內存最大的一塊區(qū)域,在虛擬機啟動時創(chuàng)建;java堆內存主要是存儲對象實例,幾乎所有的對象實例(包括數(shù)組)都是存儲在這里,因此這也是垃圾回收(GC)最主要的內存區(qū)域,有關GC的內容這里不做描述;
按照虛擬機規(guī)范,java堆內存可以處于不連續(xù)的物理內存中,只要邏輯上是連續(xù)的,并且空間擴展也沒有限制,既可以是固定大小,也可以是棵擴展的;如果堆內存沒有足夠的空間完成實例分配,而且也無法擴展,將會拋出OutOfMemoryError
異常。
5、方法區(qū)
方法區(qū)和堆內存一樣,是線程共享的內存區(qū)域;存儲已經(jīng)被虛擬機加載的類型信息、常量、靜態(tài)變量、即時編譯期編譯后的代碼等數(shù)據(jù);虛擬機規(guī)范對于方法區(qū)的實現(xiàn)沒有過多限制,和堆內存一樣不需要連續(xù)的物理內存空間,大小可以固定或者可擴展,還可以選擇不實現(xiàn)垃圾回收;當方法區(qū)無法滿足內存分配需求時將會拋出OutOfMemoryError
異常。
6、直接內存
直接內存并不是虛擬機管理內存的一部分,但是這部分內存還是可能被頻繁用到;在java程序使用到Native方法時(如 NIO,有關NIO這里不做描述),可能會直接在堆外分配內存,但是內存總空間大小是有限的,也會遇到內存不足的情況,一樣會拋出OutOfMemoryError
異常。
二、實例對象存儲訪問
上面第一點對虛擬機各區(qū)域內存有個總體的描述,對于每個區(qū)域,都存在數(shù)據(jù)是如何創(chuàng)建、布局、訪問的問題,我們以最常使用的的堆內存為例基于HotSpot說下這三個方面。
1、實例對象創(chuàng)建
當虛擬機執(zhí)行到一條new指令時,首先首先從常量池定位這個創(chuàng)建對象的類符號引用、判斷檢查類是否已經(jīng)加載初始化,如果沒有加載,則執(zhí)行類加載初始化過程(關于類加載,這里不做描述),如果這個類找不到,則拋出常見的ClassNotFoundException
異常;
通過類加載檢查后,就是實際為對象分配物理內存(堆內存),對象所需的內存空間大小是由對應的類確定的,類加載后,這個類的對象所需的內存空間是固定的;為對象分配內存空間,相當于要從堆中劃分出一塊出來分配給這個對象;
根據(jù)內存空間是否連續(xù)(已分配和未分配是區(qū)分為完整的兩部分)分為兩種分配內存方式:
1. 連續(xù)的內存:已分配和未分配中間使用一個指針作為分界點,對象內存分配只需要指針向未分配內存段移動一段空間大小即可;這種方式稱 為“指針碰撞”。
2. 非連續(xù)內存:虛擬機需要維護(記錄)一個列表,記錄堆中那些內存塊的沒有分配的,在分配對象內存時從中選擇一塊適合大小的內存區(qū)域 分配給對象,并更新這個列表;這種方式稱為“空閑列表”。
對象內存的分配也會遇到并發(fā)的問題,虛擬機使用兩種方案解決這個線程安全問題:第一使用CAS(Compare and set)+識別重試,保證分配操作的原子性;第二是內存分配按照線程劃分不同的空間,即每個線程在堆中預先分配好一塊線程私有的內存,稱為本地線程分配緩存區(qū)(Thread Local Allocation Buffer,TLAB);那個線程要分配內存時,直接從TLAB中分配出來,只有當線程的TLAB分配完需要重新分配,才需要同步操作從堆中分配,這個方案有效的減少線程間對象分配堆內存的并發(fā)情況出現(xiàn);虛擬機是否使用TLAB這種方案,是通過JVM參數(shù) -XX:+/-UseTLAB 設定。
完成內存分配后,除對象頭信息外,虛擬機會將分配到的內存空間初始化為零值,保證對象實例的字段可以不賦值就可直接使用到數(shù)據(jù)類型對應的零值;緊接著,執(zhí)行 init 方法按照代碼完成初始化,才完成一個實例對象的創(chuàng)建;
2、對象在內存的布局
在HotSpot虛擬機中,對象在內存分為3個部分:對象頭(Header)、實例數(shù)據(jù)(Instance Data)、對齊填充(Padding):
其中對象頭又分兩個部分:一部分存儲對象運行時數(shù)據(jù),包括哈希碼、垃圾回收分代年齡、對象鎖狀態(tài)、線程持有的鎖、偏向線程ID、偏向 時間戳等;在32位和64位虛擬機中,這部分數(shù)據(jù)分別占用32位和64位;由于運行時數(shù)據(jù)較多,32位或者64位不足以完全存儲全部數(shù)據(jù),所以 這部分設計為非固定格式存儲運行時數(shù)據(jù),而是根據(jù)對象的狀態(tài)不同而使用不同位來存儲數(shù)據(jù);另一部分存儲對象類型指針,指向這個對象的 類,但這并不是必須的,對象的類元數(shù)據(jù)不一定要使用這部分存儲來確定(下面會講到);
實例數(shù)據(jù)則是存儲對象定義的各種類型數(shù)據(jù)的內容,而這些程序定義的數(shù)據(jù)并不是完全按照定義的順序存儲的,它們是按照虛擬機分配策略和定義的順序確定:long/double、int、short/char、byte/boolean、oop(Ordinary Object Ponint),可以看出,策略是按照類型占位多少分配的,相同的類型會在一起分配內存;而且,在滿足這些前提條件下,父類變量順序先于子類;
而對象填充這部分不是一定會存在,它僅僅是起到占位對齊的作用,在HotSpot虛擬機內存管理是按照8字節(jié)為單位管理,因此當分配完內存后,對象大小不是8的倍數(shù),則由對齊填充補全;
3、對象的訪問
在java程序中,我們創(chuàng)建了一個對象,實際上我們得到一個引用類型變量,通過這個變量來實際操作一個在堆內存中的實例;在虛擬機規(guī)范中,只規(guī)定了引用(reference)類型是指向對象的引用,沒有規(guī)定這個引用是如何去定位、訪問到堆中實例的;目前主流的虛擬機中,主要有兩種方式實現(xiàn)對象的訪問:
1. 句柄方式:堆內存中劃分出一塊區(qū)域作為句柄池,引用變量中存儲的是對象的句柄地址,而句柄中存儲了示例對象和對象類型的具體地址信息,因此對象頭中可以不包含對象的類型:
2. 指針直接訪問:引用類型直接存儲的是實例對象在堆中的地址信息,但是這就必須要求實例對象的布局中,對象頭必須包含對象的類型:
這兩種訪問方式各有優(yōu)勢:當對象地址改變(內存整理、垃圾回收),句柄方式訪問對象,引用變量不需要改變,只需要改變句柄中的對象地址值就可;而使用指針直接訪問方式,則需要修改這個對象全部的引用;但是指針方式,可以減少一次尋址操作,在大量對象訪問的情況下,這種方式的優(yōu)勢比較明顯;HotSpot虛擬機就是使用這中指針直接訪問方式。
三、運行時內存異常
java程序內存在運行時主要可能發(fā)生兩種異常情況:OutOfMemoryError、StackOverflowError;那個內存區(qū)域會發(fā)生什么異常,前面已經(jīng)簡單提到,除了程序計數(shù)器已外,其他內存區(qū)域都會發(fā)生;本節(jié)主要通過實例代碼演示各個內存區(qū)域發(fā)生異常的情況,其中會使用到許多常用的虛擬機啟動參數(shù)以便更好說明情況。(如何使用參數(shù)運行程序這里不做描述)
1、java堆內存溢出
堆內存溢出發(fā)生在堆容量達到最大堆容量后創(chuàng)建對象情況下,在程序中只要不斷的創(chuàng)建對象,并且保證這些對象不會被垃圾回收:
/** * 虛擬機參數(shù): * -Xms20m 最小堆容量 * -Xmx20m 最大堆容量 * @author hwz * */ public class HeadOutOfMemoryError { public static void main(String[] args) { //使用容器保存對象,保證對象不被垃圾回收 List<HeadOutOfMemoryError> listToHoldObj = new ArrayList<HeadOutOfMemoryError>(); while(true) { //不斷創(chuàng)建對象并加入容器中 listToHoldObj.add(new HeadOutOfMemoryError()); } } }
這里可以加上虛擬機參數(shù):-XX:HeapDumpOnOutOfMemoryError
,在發(fā)送OOM異常的時候讓虛擬機轉儲當前堆的快照文件,后續(xù)可以通過這個文件分詞異常問題,這個不做詳細描述,后續(xù)再寫個博客詳細描述使用MAT工具分析內存問題。
2、虛擬機棧和本地方法棧溢出
在HotSpot虛擬機中,這兩個方法棧是沒有一起實現(xiàn)的,根據(jù)虛擬機規(guī)范,這兩塊內存區(qū)域會發(fā)生這兩種異常:
1. 如果線程請求棧深度大于虛擬機允許的最大深度,拋出StackOverflowError異常;
2. 如果虛擬機在擴展??臻g時,無法申請大內存空間,將拋出OutOfMemoryError異常;
這兩種情況實際上是存在重疊的:當??臻g無法繼續(xù)分配是,到底是內存太小還是已使用的棧深度太大,這個無法很好的區(qū)分。
使用兩種方式測試代碼
1. 使用-Xss參數(shù)減少棧大小,無限遞歸調用一個方法,無限加大棧深度:
/** * 虛擬機參數(shù):<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、方法區(qū)和運行時常量池溢出
這里先描述一下String的intern方法:如果字符串常量池已經(jīng)包含一個等于此String對象的字符串,則返回代表這個字符串的String對象,否則將此String對象添加到常量池中,并返回此String對象的引用;通過這個方法不斷在常量池中增加String對象,導致溢出:
/** * 虛擬機參數(shù):<br> * -XX:PermSize=10M 永久區(qū)大小 * -XX:MaxPermSize=10M 永久區(qū)最大容量 * @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下沒有發(fā)生運行時常量池溢出,在JDK1.6倒是會發(fā)生,為此再寫一段測試代碼驗證這個問題:
/** * 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創(chuàng)建的字符串實例在堆中,所以不相等;
而在JDK1.7中,intern()方法不會復制實例,只是在常量池記錄首次出現(xiàn)的實例的引用,因此intern返回的引用和StringBuilder創(chuàng)建的實例是同一個,所以返回true;
所以常量池溢出的測試代碼不會發(fā)生常量池溢出異常,而是在不斷運行后可能發(fā)生堆內存不足溢出異常;
那要測試方法區(qū)溢出,只要不斷往方法區(qū)加入東西就行了,比如類名、訪問修飾符、常量池等。我們可以讓程序加載大量的類去不斷填充方法區(qū)從而導致溢出,這個我們使用CGLib直接操作字節(jié)碼生成大量動態(tài)類:
/** * 方法區(qū)內存溢出測試類 * @author hwz * */ public class MethodAreaOOM { public static void main(String[] args) { //使用GCLib無限動態(tài)創(chuàng)建子類 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加載類的數(shù)量和PerGen的使用成直線上升:
4、直接內存溢出
直接內存的大小可以通過虛擬機參數(shù)設定:-XX:MaxDirectMemorySize,要使直接內存溢出,只需要不斷的申請直接內存即可,以下同Java NIO 中直接內存緩存測試:
/** * 虛擬機參數(shù):<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); //通過不斷申請直接緩存區(qū)內存消耗直接內存 buffers.add(ByteBuffer.allocateDirect(1024*1024)); //每次申請1M } } }
在循環(huán)中,每次申請1M直接內存,設置最大直接內存為30M,程序運行到31次時拋出異常:java.lang.OutOfMemoryError: Direct buffer memory
四、總結
以上就是本文的全部內容,本文主要描述JVM中內存的布局結構、對象存儲和訪問已經(jīng)各個內存區(qū)域可能出現(xiàn)的內存異常;主要參考書目《深入理解Java虛擬機(第二版)》,如有不正確之處,還請在評論中指出;謝謝大家對腳本之家的支持。
相關文章
Java超詳細精講數(shù)據(jù)結構之bfs與雙端隊列
廣搜BFS的基本思想是: 首先訪問初始點v并將其標志為已經(jīng)訪問。接著通過鄰接關系將鄰接點入隊。然后每訪問過一個頂點則出隊。按照順序,訪問每一個頂點的所有未被訪問過的頂點直到所有的頂點均被訪問過。廣度優(yōu)先遍歷類似與層次遍歷2022-07-07