1. 程式人生 > >JVM記憶體區域與記憶體溢位異常

JVM記憶體區域與記憶體溢位異常

對於Java程式設計師來說,Java虛擬機器是再熟悉不過了,尤其是對Hotspot VM最熟悉了,因為它是Sun JDK和OpenJDK中所帶的虛擬機器,也是目前使用範圍最廣泛的Java虛擬機器。相比C、C++開發人員來說,有了JVM,Java程式設計師不再需要為每一個new操作去寫配對的delete/free程式碼,不容易出現記憶體洩漏和記憶體溢位的問題。但也正因為記憶體控制完全交由JVM,一旦出現記憶體洩漏和溢位的問題,如果沒有深入瞭解過JVM的工作機制,那麼排查問題將會很困難,所以我準備對JVM相關的東西做一下研究整理。

1. 首先了解一下Java記憶體區域

JVM執行時資料區
如上圖(圖片摘自 深入理解Java虛擬機器),堆記憶體和方法區的內容是所有執行緒共享的,虛擬機器棧、本地方法棧和程式計數器是每個執行緒獨有的。其中方法區就是jdk中永久代(jdk1.7之後叫metaspace),本地方法棧一般是指java native方法。

  • 對程式計數器做下介紹
    當前執行緒所執行的位元組碼的行號指示器。位元組碼直譯器工作時就是通過改變計時器的值來選取下一條需要執行的位元組碼指令、分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。每個執行緒都會有自己獨立的計數器,因為多執行緒之間切換後能恢復到正確的執行位置,就是靠各自的計數器記錄的指令地址(如果正在執行的是Native方法,則計數器值為空Undefined)做到的
  • Java虛擬機器棧(Java棧)
    它的宣告週期和執行緒相同,每個方法在執行的同時,都會建立一個棧幀(方法執行時的基礎資料結構)用於儲存區域性變量表(存放了編譯器可知的各種基本型別、物件引用或控制代碼,其中只有long、double佔用2個區域性變數空間Slot,其他資料型別只佔1個)、運算元棧、動態連結、方法出口等資訊。每個方法從呼叫直至執行完成的過程,對應著一個棧幀在虛擬機器棧(Java棧)中入棧道出棧的過程。
    說明:Java棧有兩種異常型別
    StackOverflowError異常:如果執行緒請求的棧深度大於虛擬機器鎖允許的深度,就會丟擲這個異常;
    OutOfMemoryError異常:如果虛擬機器棧動態擴充套件時,無法申請到足夠的記憶體,就會丟擲這個異常;
    稍後會有報錯的案例分析
  • 本地方法棧
    它和虛擬機器棧的作用基本一樣,唯一的區別是它是為虛擬機器使用到的Native方法服務(一般Java中執行C語言的方法,使用的就是本地方法棧),而虛擬機器棧是為棧執行Java方法服務。
  • Java堆
    堆記憶體大家都很熟悉了,它的唯一目的就是存放物件例項,幾乎所有的物件例項(隨著JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換技術使物件不一定在堆上分配)都在這裡分記憶體。
  • 方法區
    它主要用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。在Hotspot虛擬機器中,該區域叫“永久代”(jdk1.7之後叫metaspace)。但方法區並不等價於永久代,這個是java虛擬機器規範中的概念,僅僅是因為Hotspot設計團隊選擇把GC分代收集擴充套件至方法區(或者說使用永久代來實現方法區而已),這樣垃圾收集器可以像管理Java堆一樣來管理這部分記憶體,能夠省去專門為方法區編寫記憶體管理程式碼的工作。
    說明:方法區可能丟擲的異常
    當方法區無法滿足記憶體分配需求時,將會丟擲OutOfMemoryError,如果是jdk1.6及以前的版本,後邊會跟隨著提示資訊是“PermGen space”。
  • 執行時常量池
    它是方法區的一部分(Class檔案中除了有類的版本號、欄位、方法、介面等描述資訊外,還有就是常量池),用於存放編譯期生成的各種字面量(例如:常量)和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。
    說明:執行期間使用String.intern()方法可以將新的常量放入池中
  • 符號引用和直接引用區別
    符號引用:使以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能夠無歧義的定位到目標就好。例如:Class檔案中它以Constant_Class_info等型別的常量出現;
    直接引用:可以是直接指向目標的指標,例如:Class物件、類變數的直接引用裡可能是指向方法區的指標,相對偏移量、一個能間接定位到目標的控制代碼。

2. Hotspot虛擬機器記憶體分析

接下來,就對最為流程的JVM進行詳細的研究學習

  • 物件建立的記憶體分配方式
    一般有兩種分配方式:一種是指標碰撞,但前提是堆記憶體規整的情況下;
    另一種是空閒列表(可以在堆記憶體不規整的情況下)。
    Serial、ParNew(帶壓縮整理) GC演算法,通常採用指標碰撞;CMS(標記清除)演算法,通常採用空閒列表。

  • 堆記憶體分配併發處理
    一種是對記憶體空間的動作進行同步處理,實際上JVM採用CAS+失敗重試的方式;
    另一種是把記憶體分配的動作按照執行緒分在不同空間之中進行,也就是說每個執行緒在java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝(TLAB)。虛擬機器是否使用TLAB,可通過-XX: +/-UseTLAB來設定,預設是開啟的。

  • 物件的記憶體佈局
    可分為3塊區域:物件頭、例項資料、對齊填充;
    **物件頭:**包含兩部分資訊,一是用於儲存物件自身的執行時資料(如:雜湊碼、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳),這些又稱為“Mark Word”;另一部分是型別指標,即物件指向它的類元資料的指標,JVM通過這個指標來確定這個物件是哪個類的例項。注意:如果是java陣列的話,物件頭中還必須有一塊用於記錄陣列長度的資料,因為JVM可以通過普通java物件的元資料資訊確定java物件的大小,但是從陣列的元資料中卻無法確定陣列的大小。
    **例項資料:**是物件真正儲存的有效資訊,也是在程式中所定義的各種型別的欄位內容。
    對齊填充: 因為HotspotVM要求物件起始地址必須是8位元組的整數倍,也就是說物件大小必須是8位元組的整數倍,所以物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。

  • 物件的訪問定位方式
    有兩種方式:一種是使用控制代碼訪問,在java堆中將會分出一塊記憶體來作為控制代碼池,棧中reference中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料和型別資料各自的具體地址資訊,如下圖(圖片來自 深入理解Java虛擬機器):
    控制代碼訪問

另一種方式是使用直接指標訪問,reference中儲存的直接就是物件地址
指標訪問
說明:兩者優缺點
這兩種方式各有優勢,使用控制代碼來訪問,最大的好處就是reference中儲存的是穩定的控制代碼地址,不管物件怎麼被移動(垃圾收集時移動物件是非常普遍的行為),只需要改變控制代碼中的例項資料指標,而reference本身不需要修改;
使用指標訪問,最大的好處就是速度更快,它節省了一次指標定位的時間開銷,但是物件變化的頻繁,會帶來棧指標變化的成本。

3. 記憶體溢位分析

  • Java堆溢位 OOM案例,以下為案例程式碼:
/**
 * @className: JavaHeapOOM
 * @summary: Java堆記憶體溢位案例
 * @Description: OOM
 *  VM 引數:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 *  說明:以上引數限制java堆的大小為20MB,不可擴充套件(堆最大值和最小值設定為一樣的)
 *  -XX:+HeapDumpOnOutOfMemoryError引數的作用是讓JVM出現記憶體溢位異常時,
 *  Dump出當前的記憶體堆轉儲快照以便事後進行分析。
 * @author: helon
 * date: 2018/10/28 1:13 PM
 * version: v1.0
 */
public class JavaHeapOOM {

    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }

    }
}

在啟動執行之前,我們要設定以下idea的VM引數,如下截圖:
VM引數配置
配置完成後開始啟動main方法,發現報出了OOM,如下資訊:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid2342.hprof ...
Heap dump file created [27819705 bytes in 0.095 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:265)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
	at java.util.ArrayList.add(ArrayList.java:462)
	at com.helon.JavaHeapOOM.main(JavaHeapOOM.java:27)

Dumping heap to java_pid2342.hprof … 報錯之後dump了一份hprof堆轉儲快照檔案,可以使用Eclipse的MAT(Memory Analyzer Tool)工具進行分析(idea貌似沒有類似的工具,找了好久沒有找到…),確認物件是否是必要的,也就是說先分析清楚到底是出現了記憶體洩漏(Memory Leak)還是記憶體溢位(Memory Overflow)。如果是記憶體洩漏了,那麼可以通過工具檢視洩漏物件到GC ROOTS(可達性分析物件到GC ROOTS的引用鏈關係,包括虛擬機器棧中引用的物件、方法區中類靜態屬性引用的物件、方法區中常量引用的物件、本地方法棧中JNI引用的物件)的引用鏈,看是什麼原因導致物件沒有被回收。如果不是記憶體洩漏,那麼就要分析一下,堆記憶體引數配置對比實體記憶體是否可以適當調大,或者嘗試減少程式執行期的記憶體消耗。

  • 虛擬機器棧和本地方法棧溢位案例
/**
 * @className: JavaVMStackSOF
 * @summary: 虛擬機器棧和本地方法棧OOM測試
 * @Description: 設定虛擬機器棧容量 -Xss, -Xoss為本地方法棧大小,但設定不小
 * 設定 -Xss大小為160k
 * 單執行緒執行
 * @author: helon
 * date: 2018/10/28 4:09 PM
 * version: v1.0
 */
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("====棧深度:" + oom.stackLength);
            throw e;
        }
    }
}

在程式碼執行前,首先配置一下VM引數,將虛擬機器棧大小設定為160k,如下截圖:
SOF案例測試
執行main方法,每次報錯都是StackOverflowError,如果定義大量的本地變數,結果也是丟擲StackOverflowError:

====棧深度:772
Exception in thread "main" java.lang.StackOverflowError
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:18)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
	...

測試結果表明:在單執行緒情況下,無論是由於棧幀太大還是虛擬機器棧容量太小,當記憶體無法分配時,JVM都是丟擲StackOverflowError異常。
那麼我們在多執行緒情況下做個測試,如下程式碼:

/**
 * @className: JavaVMStackOOM
 * @summary: 多執行緒場景下導致虛擬機器棧OOM
 * @Description: 設定-Xss2m
 * @author: helon
 * date: 2018/10/28 4:44 PM
 * version: v1.0
 */
public class JavaVMStackOOM {

    private void dontStop() {
        while (true) {

        }
    }
    public void stackLeakByThread() {
        while (true) {
            new Thread(() -> {
                dontStop();
            }).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

測試表明,在多執行緒情況下可以產生棧記憶體溢位異常,但是這樣產生的記憶體溢位異常與棧空間是否足夠大,並不存在任何聯絡,或者說為每個執行緒的棧分配的記憶體越大,反而越容易產生記憶體溢位異常。因為堆記憶體總大小減去Xmx最大堆容量,再減去MaxPermSize最大方法區容量,程式計數器可以忽略不計,那麼剩下的記憶體就是虛擬機器棧和本地方法棧的記憶體大小了。如果每個執行緒分配到的記憶體容量越大,那麼可以建立的執行緒數量就越少。

  • 方法區和執行時常量池溢位案例
    首先看以下程式碼:
/**
 * @className: RuntimeConstantPoolOOM
 * @summary: JDK1.6及以前版本 使用以下案例可以測出OOM:PermGen space
 * @Description: 設定 -XX:PermSize=10M -XX:MaxPermSize=10M
 * @author: helon
 * date: 2018/10/28 5:30 PM
 * version: v1.0
 */
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        //使用List保持著常量池引用,避免Full GC回收常量池行為
        List<String> list = new ArrayList<>();
        //10MB的PermSize在integer範圍內足夠產生OOM了
        int i = 0;
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

首先解釋下String的intern()方法,它是一個Native方法,作用是:如果字串常量池中已經包含了一個等於此String物件的字串,那麼就返回常量池中這個字串的String物件,如果不包含就將此String物件包含的字串新增到常量池中,並返回引用。
以上程式碼需要在JDK1.6及之前版本能夠測出OOM異常,我本機為JDK1.8環境,測試不出異常,因為在JDK1.7及以上版本已經去“永久代”,設定PermSize引數是無效的。並且,JDK1.7之後,intern方法不會再複製物件例項,只是在常量池中記錄首次出現的引用
除了以上測試方式,還可以藉助CGLib直接操作位元組碼執行時生成大量的動態類,讓其填滿方法區,直到溢位,參照以下程式碼:

/**
 * @className: JavaMethodAreaOOM
 * @summary: 藉助CGLib使方法區出現記憶體溢位異常
 * @Description: JDK1.6及之前版本,設定-XX:PermSize=10m -XX:MaxPermSize=10m
 * JDK1.7及之後版本,設定-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
 * @author: helon
 * date: 2018/10/28 6:01 PM
 * version: v1.0
 */
public class JavaMethodAreaOOM {
    static class OOMObject {
    }
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }
}

啟動main方法前,先設定一下VM引數:JDK1.6及之前版本,設定-XX:PermSize=10m -XX:MaxPermSize=10m;JDK1.7及之後版本,設定-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m。根據本地環境來設定不同的引數,我使用的是JDK1.8環境,設定的是metaspace的大小,設定完畢執行main方法,執行一小會時間結果丟擲了OOM,如下錯誤資訊:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

其實,在當前的很多主流框架裡,如:Spring、Hibernate在對類進行增強的時候,都會使用到CGLib這類位元組碼技術,增強類越多,就需要越大的方法區來保證動態生成的Class可以載入入記憶體。如果濫用類似位元組碼增強技術或動態語言(如groovy等),並未做好控制的話,很可能會導致以上異常。

  • 本機直接記憶體溢位案例
    如果以上查詢OOM問題的時候,以上所有情況都沒有定位原因,並且Heap Dump檔案中沒有看到明顯的異常,而程式又直接或者間接使用到了NIO,那就有可能是這方面的原因。因為這種情況可能比較少見,案例程式碼我就不在這裡列出了,如果想看案例程式碼,可以從這裡下載檢視

4. 總結

好了,以上就是JVM常見的幾種OOM,並且也總結了虛擬機器中的記憶體劃分,後續我還會經常分享一些JVM的其他知識,如果有疑義的地方,歡迎指正批評。