JVM系列(二) - JVM記憶體區域

零壹技術棧發表於2018-07-17

前言

JVM記憶體區域包括PC計數器Java虛擬機器棧本地方法棧方法區執行時常量池直接記憶體

本文主要介紹各個記憶體區域的作用和特性,同時分別闡述各個區域發生記憶體溢位的可能性和異常型別。

正文

(一). JVM記憶體區域

Java虛擬機器執行Java程式的過程中,會把所管理的記憶體劃分為若干不同的資料區域。這些記憶體區域各有各的用途,以及建立和銷燬時間。有的區域隨著虛擬機器程式的啟動而存在,有的區域伴隨著使用者執行緒的啟動和結束而建立和銷燬。

JVM記憶體區域也稱為Java執行時資料區域。其中包括:程式計數器虛擬機器棧本地方法棧靜態方法區靜態常量池等。

JVM系列(二) - JVM記憶體區域

注意:程式計數器、虛擬機器棧、本地方法棧屬於每個執行緒私有的;堆和方法區屬於執行緒共享訪問的

JVM系列(二) - JVM記憶體區域

1.1. PC計數器

程式計數器(Program Counter Register)是一塊較小的記憶體空間,它的作用可以看做是當前執行緒所執行的位元組碼行號指示器

  1. 當前執行緒所執行的位元組碼行號指示器
  2. 每個執行緒都有一個自己的PC計數器。
  3. 執行緒私有的,生命週期與執行緒相同,隨JVM啟動而生,JVM關閉而死。
  4. 執行緒執行Java方法時,記錄其正在執行的虛擬機器位元組碼指令地址
  5. 執行緒執行Native方法時,計數器記錄為(Undefined)。
  6. 唯一在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況區域。

1.2. Java虛擬機器棧

執行緒私有記憶體空間,它的生命週期和執行緒相同。執行緒執行期間,每個方法執行時都會建立一個棧幀(Stack Frame) ,用於儲存 區域性變數表運算元棧動態連結方法出口 等資訊。

  1. 區域性變數表
  2. 運算元棧
  3. 動態連結
  4. 方法出口

每一個方法從呼叫直到執行完成的過程,就對應著一個棧幀在虛擬機器棧中的入棧出棧的全過程。

下面依次解釋棧幀裡的四種組成元素的具體結構和功能:

1). 區域性變數表

區域性變數表是一組變數值的儲存空間,用於儲存方法引數區域性變數。 在 Class 檔案的方法表的 Code 屬性的 max_locals 指定了該方法所需區域性變數表的最大容量

區域性變數表在編譯期間分配記憶體空間,可以存放編譯期的各種變數型別:

  1. 基本資料型別boolean, byte, char, short, int, float, long, double8種;
  2. 物件引用型別reference,指向物件起始地址引用指標
  3. 返回地址型別returnAddress,返回地址的型別。

變數槽(Variable Slot):

變數槽區域性變數表最小單位,規定大小為32位。對於64位的longdouble變數而言,虛擬機器會為其分配兩個連續Slot空間。

2). 運算元棧

運算元棧Operand Stack)也常稱為操作棧,是一個後入先出棧。在 Class 檔案的 Code 屬性的 max_stacks 指定了執行過程中最大的棧深度。Java虛擬機器的解釋執行引擎被稱為基於棧的執行引擎 ,其中所指的就是指-運算元棧

  1. 區域性變數表一樣,運算元棧也是一個以32字長為單位的陣列。
  2. 虛擬機器在運算元棧中可儲存的資料型別intlongfloatdoublereferencereturnType等型別 (對於byteshort以及char型別的值在壓入到運算元棧之前,也會被轉換為int)。
  3. 區域性變數表不同的是,它不是通過索引來訪問,而是通過標準的棧操作壓棧出棧來訪問。比如,如果某個指令把一個值壓入到運算元棧中,稍後另一個指令就可以彈出這個值來使用。

虛擬機器把運算元棧作為它的工作區——大多數指令都要從這裡彈出資料,執行運算,然後把結果壓回運算元棧

begin
iload_0    // push the int in local variable 0 onto the stack
iload_1    // push the int in local variable 1 onto the stack
iadd       // pop two ints, add them, push result
istore_2   // pop int, store into local variable 2
end
複製程式碼

在這個位元組碼序列裡,前兩個指令 iload_0iload_1 將儲存在區域性變數表中索引為01的整數壓入運算元棧中,其後iadd指令從運算元棧中彈出那兩個整數相加,再將結果壓入運算元棧。第四條指令istore_2則從運算元棧中彈出結果,並把它儲存到區域性變數表索引為2的位置。

下圖詳細表述了這個過程中區域性變數表運算元棧的狀態變化(圖中沒有使用的區域性變數表運算元棧區域以空白表示)。

JVM系列(二) - JVM記憶體區域

3). 動態連結

每個棧幀都包含一個指向執行時常量池中所屬的方法引用,持有這個引用是為了支援方法呼叫過程中的動態連結

Class檔案的常量池中存在有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用為引數。這些符號引用:

  1. 靜態解析:一部分會在類載入階段或第一次使用的時候轉化為直接引用(如finalstatic域等),稱為靜態解析
  2. 動態解析:另一部分將在每一次的執行期間轉化為直接引用,稱為動態連結
4). 方法返回地址

當一個方法開始執行以後,只有兩種方法可以退出當前方法:

  1. 正常返回:當執行遇到返回指令,會將返回值傳遞給上層的方法呼叫者,這種退出的方式稱為正常完成出口(Normal Method Invocation Completion),一般來說,呼叫者的PC計數器可以作為返回地址。
  2. 異常返回:當執行遇到異常,並且當前方法體內沒有得到處理,就會導致方法退出,此時是沒有返回值的,稱為異常完成出口(Abrupt Method Invocation Completion),返回地址要通過異常處理器表來確定。

當一個方法返回時,可能依次進行以下3個操作:

  1. 恢復上層方法區域性變數表運算元棧
  2. 返回值壓入呼叫者棧幀運算元棧
  3. PC計數器的值指向下一條方法指令位置。

小結:

注意:在Java虛擬機器規範中,對這個區域規定了兩種異常。 其一:如果當前執行緒請求的棧深度大於虛擬機器棧所允許的深度,將會丟擲 StackOverflowError 異常(在虛擬機器棧不允許動態擴充套件的情況下);其二:如果擴充套件時無法申請到足夠的記憶體空間,就會丟擲 OutOfMemoryError 異常。

1.3. 本地方法棧

本地方法棧Java虛擬機器棧發揮的作用非常相似,主要區別是Java虛擬機器棧執行的是Java方法服務,而本地方法棧執行Native方法服務(通常用C編寫)。

有些虛擬機器發行版本(譬如Sun HotSpot虛擬機器)直接將本地方法棧Java虛擬機器棧合二為一。與虛擬機器棧一樣,本地方法棧也會丟擲StackOverflowErrorOutOfMemoryError異常。

1.4. 堆

Java堆是被所有執行緒共享最大的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。

Java中,堆被劃分成兩個不同的區域:新生代 (Young Generation) 、老年代 (Old Generation) 。新生代 (Young) 又被劃分為三個區域:一個Eden區和兩個Survivor區 - From Survivor區和To Survivor區。

簡要歸納:新的物件分配是首先放在年輕代 (Young Generation) 的Eden區,Survivor區作為Eden區和Old區的緩衝,在Survivor區的物件經歷若干次收集仍然存活的,就會被轉移到老年代Old中。

這樣劃分的目的是為了使JVM能夠更好的管理堆記憶體中的物件,包括記憶體的分配以及回收。

1.5. 方法區

方法區和Java堆一樣,為多個執行緒共享,它用於儲存類資訊常量靜態常量即時編譯後的程式碼等資料。

1.6. 執行時常量池

執行時常量池是方法區的一部分,Class檔案中除了有類的版本欄位方法介面等描述資訊外, 還有一類資訊是常量池,用於儲存編譯期間生成的各種字面量符號引用

1.7. 直接記憶體

直接記憶體不屬於虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。 Java NIO允許Java程式直接訪問直接記憶體,通常直接記憶體的速度會優於Java堆記憶體。因此,對於讀寫頻繁、效能要求高的場景,可以考慮使用直接記憶體。

(二). 常見記憶體溢位異常

除了程式計數器外,Java虛擬機器的其他執行時區域都有可能發生OutOfMemoryError的異常,下面分別給出驗證:

2.1. Java堆溢位

Java堆能夠儲存物件例項。通過不斷地建立物件,並保證GC Roots到物件有可達路徑來避免垃圾回收機制清除這些物件。 當物件數量到達最大堆的容量限制時就會產生OutOfMemoryError異常。

設定JVM啟動引數:-Xms20M設定堆的最小記憶體20M-Xmx20M設定堆的最大記憶體最小記憶體一樣,這樣可以防止Java堆在記憶體不足時自動擴容-XX:+HeapDumpOnOutOfMemoryError引數可以讓虛擬機器在出現記憶體溢位異常時Dump記憶體堆執行時快照。

JVM系列(二) - JVM記憶體區域

HeapOOM.java

/**
 * VM Args: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    public static class OOMObject {
    }

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

測試執行結果:

JVM系列(二) - JVM記憶體區域

開啟Java VisualVM匯出Heap記憶體執行時的dump檔案。

JVM系列(二) - JVM記憶體區域
HeapOOM物件不停地被建立,堆記憶體使用達到99%垃圾回收器不斷地嘗試回收但都以失敗告終。

分析:遇到這種情況,通常要考慮記憶體洩露記憶體溢位兩種可能性。

  • 如果是記憶體洩露:

進一步使用Java VisualVM工具進行分析,檢視洩露物件是通過怎樣的路徑GC Roots關聯而導致垃圾回收器無法回收的。

  • 如果是記憶體溢位:

通過Java VisualVM工具分析,不存在洩露物件,也就是說堆記憶體中的物件必須得存活著。就要考慮如下措施:

  1. 從程式碼上檢查是否存在某些物件生命週期過長持續狀態時間過長的情況,嘗試減少程式執行期的記憶體。
  2. 檢查虛擬機器的堆引數(-Xmx-Xms),對比機器的實體記憶體看是否還可以調大。

2.2. 虛擬機器和本地方法棧溢位

關於虛擬機器棧和本地方法棧,分析記憶體異常型別可能存在以下兩種:

  • 如果現場請求的棧深度大於虛擬機器所允許的最大深度,將丟擲StackOverflowError異常。
  • 如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,可能會丟擲OutOfMemoryError異常。

可以劃分為兩類問題,當棧空間無法分配時,到底時棧記憶體太小,還是已使用的棧記憶體過大

StackOverflowError異常

測試方案一:

  • 使用-Xss引數減少棧記憶體的容量,異常發生時列印的深度。
  • 定義大量的本地區域性變數,以達到增大棧幀中的本地變數表的長度。

設定JVM啟動引數:-Xss128k設定棧記憶體的大小為128k

JVM系列(二) - JVM記憶體區域

JavaVMStackSOF.java

/**
 * VM Args: -Xss128k
 */
public class JavaVMStackSOF {
    private int stackLength = 1;

    private void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("Stack length: " + oom.stackLength);
            throw e;
        }
    }
}
複製程式碼

測試結果:

JVM系列(二) - JVM記憶體區域

分析:在單個執行緒下,無論是棧幀太大還是虛擬機器棧容量太小,當無法分配記憶體的時候,虛擬機器丟擲的都是StackOverflowError異常。

測試方案二:

  • 不停地建立執行緒並保持執行緒執行狀態。

JavaVMStackOOM.java

/**
 * VM Args: -Xss2M
 */
public class JavaVMStackOOM {
    private void running() {
        while (true) {
        }
    }

    public void stackLeakByThread() {
        while (true) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    running();
                }
            }).start();
        }
    }

    public static void main(String[] args) {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}
複製程式碼

測試結果:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
複製程式碼

上述測試程式碼執行時存在較大的風險,可能會導致作業系統假死,這裡就不親自測試了,引用作者的測試結果。

2.3. 方法區和執行時常量池溢位

(一). 執行時常量池記憶體溢位測試

執行時常量字面量都存放於執行時常量池中,常量池又是方法區的一部分,因此兩個區域的測試是一樣的。 這裡採用String.intern()進行測試:

String.intern()是一個native方法,它的作用是:如果字串常量池中存在一個String物件的字串,那麼直接返回常量池中的這個String物件; 否則,將此String物件包含的字串放入常量池中,並且返回這個String物件的引用。

設定JVM啟動引數:通過-XX:PermSize=10M-XX:MaxPermSize=10M限制方法區的大小為10M,從而間接的限制其中常量池的容量。

JVM系列(二) - JVM記憶體區域

RuntimeConstantPoolOOM.java

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
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());
        }
    }
}
複製程式碼

測試結果分析:

JDK1.6版本執行結果:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
複製程式碼

JDK1.6版本執行結果顯示常量池會溢位並丟擲永久帶OutOfMemoryError異常。 而JDK1.7及以上的版本則不會得到相同的結果,它會一直迴圈下去。

(二). 方法區記憶體溢位測試

方法區存放Class相關的資訊,比如類名訪問修飾符常量池欄位描述方法描述等。 對於方法區的記憶體溢位的測試,基本思路是在執行時產生大量類位元組碼區填充方法區

這裡引入Spring框架的CGLib動態代理的位元組碼技術,通過迴圈不斷生成新的代理類,達到方法區記憶體溢位的效果。

JavaMethodAreaOOM.java

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class JavaMethodAreaOOM {

    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 obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });

            enhancer.create();
        }
    }

    private static class OOMObject {
        public OOMObject() {
        }
    }
}
複製程式碼

JDK1.6版本執行結果:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
複製程式碼

測試結果分析:

JDK1.6版本執行結果顯示常量池會溢位並丟擲永久帶OutOfMemoryError異常。 而JDK1.7及以上的版本則不會得到相同的結果,它會一直迴圈下去。

2.4. 直接記憶體溢位

本機直接記憶體的容量可通過-XX:MaxDirectMemorySize指定,如果不指定,則預設與Java最大值(-Xmx指定)一樣。

測試場景:

直接通過反射獲取Unsafe例項,通過反射向作業系統申請分配記憶體:

設定JVM啟動引數:-Xmx20M指定Java堆的最大記憶體,-XX:MaxDirectMemorySize=10M指定直接記憶體的大小。

JVM系列(二) - JVM記憶體區域

DirectMemoryOOM.java

/**
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {

    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);
        }
    }
}
複製程式碼

測試結果:

JVM系列(二) - JVM記憶體區域

測試結果分析:

DirectMemory導致的記憶體溢位,一個明顯的特徵是Heap Dump檔案中不會看到明顯的異常資訊。 如果OOM發生後Dump檔案很小,並且程式中直接或者間接地使用了NIO,那麼就可以考慮一下這方面的問題。


歡迎關注技術公眾號: 零壹技術棧

零壹技術棧

本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。

相關文章