JVM學習-java內存區域與異常

注:本文基於JDK1.7,HotSpot虛擬機

一、前言

java是一門跨硬件平臺的面向對象高級編程語言,java程序運行在java虛擬機上(JVM),由JVM管理內存,這點是和C++最大區別;雖然內存有JVM管理,但是我們也必須要理解JVM是如何管理內存的;JVM不是隻有一種,當前存在的虛擬機可能達幾十款,但是一個符合規範的虛擬機設計是必須遵循《java 虛擬機規範》的,本文是基於HotSpot虛擬機描述,對於和其它虛擬機有區別會提到;本文主要描述JVM中內存是如何分佈、java程序的對象是如何存儲訪問、各個內存區域可能出現的異常。

二、JVM中內存分佈(區域)

JVM在執行java程序的時會把內存分爲多個不同的數據區域進行管理,這些區域有着不一樣的作用、創建和銷燬時間,有的區域是在JVM進程啓動時分配,有的區域則與用戶線程(程序本身的線程)的生命週期相關;按照JVM規範,JVM管理的內存區域分爲以下幾個運行時數據區域:
image

1、虛擬機棧

這塊內存區域是線程私有的,隨線程啓動而創建、線程銷燬而銷燬;虛擬機棧描述的java方法執行的內存模型:每個方法在執行開始會創建一個棧幀(Stack Frame),用於存儲局部變量表、操作數棧,動態鏈接、方法出口等。每個方法的調用執行和返回結束,都對應有一個棧幀在虛擬機棧入棧和出棧的過程。

局部變量表顧名思義是存儲局部變量的內存區域:存放編譯器期可知的基本數據類型(8種java基本數據類型)、引用類型、返回地址;其中佔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異常。

二、實例對象存儲訪問

上面第一點對虛擬機各區域內存有個總體的描述,對於每個區域,都存在數據是如何創建、佈局、訪問的問題,我們以最常使用的的堆內存爲例基於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. 句柄方式:堆內存中劃分出一塊區域作爲句柄池,引用變量中存儲的是對象的句柄地址,而句柄中存儲了示例對象和對象類型的具體地址信息,因此對象頭中可以不包含對象的類型:
image
2. 指針直接訪問:引用類型直接存儲的是實例對象在堆中的地址信息,但是這就必須要求實例對象的佈局中,對象頭必須包含對象的類型:
image
這兩種訪問方式各有優勢:當對象地址改變(內存整理、垃圾回收),句柄方式訪問對象,引用變量不需要改變,只需要改變句柄中的對象地址值就可;而使用指針直接訪問方式,則需要修改這個對象全部的引用;但是指針方式,可以減少一次尋址操作,在大量對象訪問的情況下,這種方式的優勢比較明顯;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;
        }
    }
}
  1. 方法中定義大量本地變量,增加方法棧中本地變量表的長度,同樣無限遞歸調用:
/**
 * @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的使用成直線上升:
image

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中內存的佈局結構、對象存儲和訪問已經各個內存區域可能出現的內存異常;主要參考書目《深入理解Java虛擬機(第二版)》,如有不正確之處,還請在評論中指出;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章