深入理解Java虛擬機之圖解Java記憶體區域與記憶體溢出異常

Java記憶體區域與記憶體溢出異常

運行時數據區域

程式計數器

  • 用於記錄從記憶體執行的下一條指令的地址,執行緒私有的一小塊記憶體,也是唯一不會報出OOM異常的區域

Java虛擬機棧

  • Java虛擬機棧(Java Virtual Machine Stack)是執行緒私有的,它的生命周期與執行緒相同。虛擬機棧描述的是Java方法執行的執行緒記憶體模型:每個方法被執行的時候,Java虛擬機都會同步創建一個棧幀(Stack Frame)用於存儲局部變數表、操作數棧、動態連接、方法出口等資訊。每一個方法被調用直至執行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程

  • 如果執行緒請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError異常

  • 如果Java虛擬機棧容量可以動態擴展,當棧擴展時無法申請到足夠的記憶體會拋出OutOfMemoryError異常

本地方法棧

  • 與Java虛擬機棧類似,只不過服務對象不一樣,本地方法棧為虛擬機使用到的本地方法服務,Java虛擬機棧為虛擬機執行Java方法(位元組碼)服務

Java堆

  • 對於Java應用程式來說,Java堆(Java Heap)是虛擬機所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機啟動時創建。此記憶體區域的唯一目的就是存放對象實例,Java世界裡「幾乎」所有的對象實例都在這裡分配記憶體
  • 當堆記憶體沒有足夠空間給對象實例分配記憶體並且堆記憶體無法擴展時都會拋出OOM異常

方法區

  • 方法區與Java堆類似,也是各個執行緒共享的區域,它用於存儲已被虛擬機載入的類型資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等數據
  • 通常用別名「非堆」來與Java堆做區分
  • 當方法區沒有足夠空間滿足記憶體分配要求時,也會拋出OOM異常

運行時常量池

  • 運行時常量池是方法區的一部分,用於存放編譯期生成的各種字面量與符號引用
  • 受方法區記憶體限制,當常量池無法再申請到記憶體時會拋出OOM異常

直接記憶體

  • 直接記憶體並不是運行時數據區的一部分,但它受總記憶體限制,也可能會出現OOM異常

HotSpot虛擬機對象探秘

對象的創建

在類載入檢查通過後,接下來虛擬機將為新生對象分配記憶體,而記憶體分配方式主要有兩種:

  • 指針碰撞

  • 空閑列表

對象的記憶體布局

在HotSpot虛擬機里,對象在堆記憶體中的存儲布局可以劃分為三個部分:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)

  • 對象頭

    • 存儲對象自身運行時數據(Mark Word),如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等
    • 類型指針(對象指向其類型元數據的指針)
  • 實例數據

    • 對象真正存儲的有效資訊,即程式碼中的各類型欄位內容
  • 對齊填充

    • 由於HotSpot虛擬機的自動記憶體管理系統要求對象起始地址必須是8位元組的整數倍,即任何對象大小都是8位元組的整數倍,故實例數據部分沒有對齊的話需要對齊填充來充當佔位符補全

對象的訪問定位

Java程式會通過棧上的reference(一個指向對象的引用)數據來操作堆上的具體對象,具體的訪問方式由虛擬機實現。

主流訪問方式主要有兩種:

  • 句柄

  • 直接指針

實戰OOM異常

採用不同的JDK及垃圾回收收集器均可能會產生不同的結果,以下實戰均以JDK8,ParallelGC垃圾收集器為例運行程式碼

# 查看默認垃圾收集器VM參數
-XX:+PrintCommandLineFlags -version

Java堆溢出

只要不斷創建對象實例,同時又避免垃圾收集器回收,這樣達到最大堆容量限制後便能產生OOM異常

public class Hello {
    /**
     * -Xms:最小堆記憶體20M -Xmx:最大堆記憶體20M 兩者設置一樣避免自動擴展 
     * VM參數:-Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
     */
    public static void main(String[] args) {
        List<Hello> hellos = new ArrayList<>();
        while (true) {
            hellos.add(new Hello());
        }
    }
}

Java虛擬機棧和本地方法棧溢出

《Java虛擬機規範》明確允許Java虛擬機實現自行選擇是否支援棧的動態擴展,而HotSpot虛擬機的選擇是不支援擴展,所以除非在創建執行緒申請記憶體時就因無法獲得足夠記憶體而出現OutOfMemoryError異常,否則在執行緒運行時是不會因為擴展而導致記憶體溢出的,只會因為棧容量無法容納新的棧幀而導致StackOverflowError異常

  • 使用-Xss參數減少棧容量
public class Hello {
    /**
     * VM參數:-Xss128k
     */
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        // 遞歸調用方法,不斷入棧
        stackLeak();
    }
    public static void main(String[] args) throws Throwable {
        Hello oom = new Hello();
        try {
            // 調用方法,入棧
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

  • 定義了大量的本地變數,增大此方法幀中本地變數表的長度(即調整棧幀大小)
public class Hello {
    private static int stackLength = 0;

    public static void test() {
        // 局部變數多,棧幀增大
        long unused1, unused2, unused3, unused4, unused5,
                unused6, unused7, unused8, unused9, unused10,
                unused11, unused12, unused13, unused14, unused15,
                unused16, unused17, unused18, unused19, unused20,
                unused21, unused22, unused23, unused24, unused25,
                unused26, unused27, unused28, unused29, unused30,
                unused31, unused32, unused33, unused34, unused35,
                unused36, unused37, unused38, unused39, unused40,
                unused41, unused42, unused43, unused44, unused45,
                unused46, unused47, unused48, unused49, unused50,
                unused51, unused52, unused53, unused54, unused55,
                unused56, unused57, unused58, unused59, unused60,
                unused61, unused62, unused63, unused64, unused65,
                unused66, unused67, unused68, unused69, unused70,
                unused71, unused72, unused73, unused74, unused75,
                unused76, unused77, unused78, unused79, unused80,
                unused81, unused82, unused83, unused84, unused85,
                unused86, unused87, unused88, unused89, unused90,
                unused91, unused92, unused93, unused94, unused95,
                unused96, unused97, unused98, unused99, unused100;
        stackLength++;
        // 遞歸調用,不斷入棧
        test();
        unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10 
        = unused11 = unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18 = unused19 
        = unused20 = unused21 = unused22 = unused23 = unused24 = unused25 = unused26 = unused27 = unused28
        = unused29 = unused30 = unused31 = unused32 = unused33 = unused34 = unused35 = unused36 = unused37 
        = unused38 = unused39 = unused40 = unused41 = unused42 = unused43 = unused44 = unused45 = unused46 
        = unused47 = unused48 = unused49 = unused50 = unused51 = unused52 = unused53 = unused54 = unused55
        = unused56 = unused57 = unused58 = unused59 = unused60 = unused61 = unused62 = unused63 = unused64 
        = unused65 = unused66 = unused67 = unused68 = unused69 = unused70 = unused71 = unused72 = unused73 
        = unused74 = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 = unused81 = unused82
        = unused83 = unused84 = unused85 = unused86 = unused87 = unused88 = unused89 = unused90 = unused91 
        = unused92 = unused93 = unused94 = unused95 = unused96 = unused97 = unused98 = unused99 = unused100 = 0;
    }

    public static void main(String[] args) {
        try {
            test();
        } catch (Error e) {
            System.out.println("stack length:" + stackLength);
            throw e;
        }
    }
}

方法區和運行時常量池溢出

  • 方法區容量控制
public class Hello {
    /**
     * JDK8前VM參數: -XX:PermSize=6M -XX:MaxPermSize=6M
     * JDK8VM參數:-XX:MetaspaceSize=6M -XX:MaxMetaspaceSize=6M
     */
    public static void main(String[] args) {
        // 使用Set保持常量池引用,避免Full GC回收常量池行為
        Set<String> set = new HashSet<>();
        // 在short範圍內足以讓6M大小的PermSize(永久代,JDK8前有,JDK8及之後版本都已採用元空間替代)產生OOM了
        short i = 0;
        // JDK8前,拋出OOM異常
        // JDK8下,正常情況會進入死循環,並不會拋出任何異常
        while (true) {
            // String.intern()進入字元串常量池
            set.add(String.valueOf(i++).intern());
        }
    }
}

上述程式碼在JDK8環境下並不會拋出任何異常,這是因為字元串常量池已經被移至Java堆之中,控制方法區容量的大小對Java堆並沒有什麼影響

  • String.intern()方法介紹:如果字元串常量池中已經包含一個等於此String對象的字元串,則返回常量池中這個字元串的String對象;否則,將此String對象包含的字元複製添加到常量池中,並返回此String對象的引用
/**
 * JDK6:false false
 * JDK8:true  false
 */
public static void main(String[] args) {
    String str1 = new StringBuilder("電腦").append("軟體").toString();
    System.out.println(str1.intern() == str1);
    String str2 = new StringBuilder("ja").append("va").toString();
    System.out.println(str2.intern() == str2);
}
  • JDK6因為new StringBuilder()分配到的是Java堆記憶體,而String.intern()會把首次遇到的字元串複製到的是字元串常量池(方法區),所以都是false

  • JDK8因為字元串常量池都移動到了Java堆中,new StringBuilder()分配到Java堆記憶體後,字元串常量池也記錄到了首次遇到的實例引用,那麼String.intern()new StringBuilder()都是同一個了(true);而因為java字元串在sun.misc.Version類載入時已進入常量池,那麼intern()方法就返回當前常量池的String對象,new StringBuilder()在堆中重新創建了一個,自然也就不一樣了(false)

  • 方法區的主要職責是用於存放類型的相關資訊,如類名、訪問修飾符、常量池、欄位描述、方法描述等,因此運行時產生大量的類填滿方法區也可以造成方法區溢出

/*
 * 藉助CGLib造成方法區溢出
 * VM參數:-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
 */
public class Hello {
    public static void main(String[] args) {
        while (true) {
           // 創建CgLib增強對象
            Enhancer enhancer = new Enhancer();
            // 設置被代理的類
            enhancer.setSuperclass(OOMObject.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 OOMObject {
    }
}

本機直接記憶體溢出

直接記憶體(Direct Memory)的容量大小可通過-XX:MaxDirectMemorySize參數來指定,如果不去指定,則默認與Java堆最大值(由-Xmx指定)一致

// 使用unsafe分配本機記憶體
public class Hello {
    // VM參數:-Xmx20M -XX:MaxDirectMemorySize=10M
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            // 真正申請分配記憶體
            unsafe.allocateMemory(_1MB);
        }
    }
}

參考資料

《深入理解Java虛擬機》(第三版) 第2章:Java記憶體區域與記憶體溢出異常

關注我

公眾號:筆架山Code