1. 程式人生 > >JVM系列(二):JVM的記憶體模型

JVM系列(二):JVM的記憶體模型

深入理解JVM記憶體模型

   Java虛擬機器在執行Java程式的過程中,把它所管理裡的記憶體劃分了不同的資料型別區域,作為一名開發者,我們需要了解jvm的記憶體分配機制以及這些不同的資料區域各自的作用。
   JVM將記憶體劃分成了以下幾個執行時資料區:
                                                               

JVM一共將記憶體分為了五大資料區域,其中藍色部分為執行緒共享的區域,橙色部分為執行緒私有區域。

一、程式計數器

   程式計數器是一塊較小的空間,記錄著當前執行緒所執行的位元組碼的行號指示器。我們知道,Java虛擬機器的多執行緒是通過搶佔CPU分配的時間片來完成的,那麼,當前執行緒正在執行的時候,就會遇到別的執行緒搶佔了時間片導致當前執行緒掛起,如果沒有程式計數器,當CPU下一次想要再繼續執行這個執行緒的時候,它並不知道這個執行緒執行到哪裡了,所以需要有這麼一個計數器來記錄它上次執行到哪個位置,因此每個Java虛擬機器執行緒都有其自己的 pc(程式計數器)暫存器,該pc暫存器包含當前正在執行的Java虛擬機器指令的地址。

   有一點我們要知道,當我們執行的是native方法時,這個計數器為空,因為程式計數器記錄的是位元組碼指令地址,但是native方法是大多是通過C實現並未編譯成需要執行的位元組碼指令所以在計數器中是空的,native 方法是通過呼叫系統指令來實現的,由原生平臺直接執行。

二、虛擬機器棧

   虛擬機器棧和程式計數器一樣,也是執行緒私有的,每個Java虛擬機器執行緒都有一個私有Java虛擬機器堆疊,與該執行緒同時建立。棧是一種資料結構,那麼資料結構是用來儲存資料的,每個方法在執行的時候都會建立一個棧幀用來儲存區域性變量表、運算元棧,本地方法棧、動態連結等。每個方法從呼叫到完成的過程就對應著一個棧幀在虛擬機器棧中的入棧和出棧的過程。如圖所示:

                                                               

棧幀中的幾種資料介紹:

1.區域性變量表

   區域性變量表裡面存放了編譯器可知的八大基本型別(byte、char、short、int、long、double、float、boolean)、物件引用(可能是物件的控制代碼地址或者是物件在堆中的直接地址)以及returnAdress型別。其中基本型別中64位長度的long和double型別的資料將會佔用2個區域性變量表其餘的資料型別佔用一個。區域性變量表所需的記憶體空間在編譯器就可知,因此一個方法分配多大的區域性變量表在進入這個方法的時候就已經固定了。區域性變量表裡面的區域性變數是其起始位置是從0開始傳遞的。

2.運算元棧

   每個棧幀都包含一個後進先出(LIFO)堆疊,稱為其運算元堆疊,Java虛擬機器提供了將區域性變數或欄位中的常量或值載入到運算元堆疊上的指令。其他Java虛擬機器指令從運算元堆疊中獲取運算元,對其進行操作,然後將結果壓回運算元堆疊。運算元堆疊還用於準備要傳遞給方法的引數並接收方法結果。我們通過一個示例來演示下入棧和出棧的過程:
這裡有一個類,類裡有一個方法,

public class Person {
    private String name="lczd";
    private static String sex;
    private int age;
    private static final String custNum="3207231993";

    public static int calc(int a,int b){
        a = 1;
        int c = a+b;
        return c;
    }

    public static void main(String[] args) {
        calc(2,3);
    }
}

現在使用命令將這個檔案編譯位元組碼檔案,並反編譯檢視它的位元組碼指令:

javac Person.java; javap -c Person.class 反編譯過後的位元組碼檔案,我們以calc方法為例:

Compiled from "Person.java"
public class classloader.Person {
  public classloader.Person();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: ldc           #2                  // String lczd
       7: putfield      #3                  // Field name:Ljava/lang/String;
      10: return

  public static int calc(int, int);
    Code:
       0: iconst_1
       1: istore_0
       2: iload_0
       3: iload_1
       4: iadd
       5: istore_2
       6: iload_2
       7: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: iconst_2
       1: iconst_3
       2: invokestatic  #4                  // Method calc:(II)I
       5: pop
       6: return
}

我們來看下這個calc的位元組碼指令:
   calc()這個方法有兩個區域性變數入參a和b,iconst_1,1是數字,這個指令的含義是說把int型別的值push到運算元棧中,結合原始碼檔案中把int型別為1的值賦值給了局部變數a,要得先有一個1,如何有這個1的值呢,得先把1壓到這個運算元棧裡面,istore的含義是說要把運算元為1進行出棧賦值給我們的區域性變量表裡的a,為什麼是賦值給的a呢,上面也提到,區域性變量表裡面的區域性變數是0開始傳遞的。雖然原始碼只是一個賦值操作,但是對應的位元組碼指令卻是兩個。iload_0的含義是把區域性變量表中的第一個位置的變數也就是a的值進行入棧。iload1就是將區域性變數b的值入棧。iadd操作就是將棧頂兩int型別數相加,也就是說將這兩個值出棧相加,然後再進行入棧。經過了入棧,出棧,,相加入棧的操作。因此根據位元組碼指令對應原始碼檔案,我們可以大概瞭解這個方法在虛擬機器中是如何操作的,位元組碼指令可以根據官網進行檢視,或者直接百度搜索中文版的位元組碼指令集。

3.動態連結

每一個棧幀都包含著一個指向執行時常量池中的該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連結。Class常量池中存在大量的符號引用,位元組碼中方法呼叫指令會以常量池中指向方法的符合引用作為引數,有一部分符號引用會在類載入階段或者第一次使用的時候就會轉化為直接引用,這種轉化稱為靜態解析,還有一部分需要在執行期間轉換,這一部分的稱為動態連結,動態連結將這些符號方法引用轉換為具體的方法引用,根據需要載入類以解析尚未定義的符號,並將變數訪問轉換為與這些變數的執行時位置關聯的儲存結構中的適當偏移量。比如說,多型是不知道呼叫的是哪個實現類,執行時確定具體型別

4.返回地址

   簡單來說一個方法的執行,不管是否有異常,都需要退出返回到方法被呼叫的位置,這樣程式才能正常執行,這個返回地址就是上層方法的繼續執行的指令地址。

三、Java堆

   java堆是執行緒共享的一塊區域,堆中存放著大量物件例項,java堆在虛擬機器啟動的時候就被建立了,這個區域存放著我們程式執行時的各種類例項和陣列物件。那麼物件到底存了些什麼,或者說一個java物件由哪些資料組成呢?
在HotSpot虛擬機器中,物件在記憶體中的儲存可以分為3塊區域:物件頭(Header)、例項資料(Instance Data)、對其填充(Padding)。我們來看下下面這張圖:

   Java虛擬機器在執行Java程式的過程中,把它所管理裡的記憶體劃分了不同的資料型別區域,作為一名開發者,我們需要了解jvm的記憶體分配機制以及這些不同的資料區域各自的作用。JVM將記憶體劃分成了以下幾個執行時資料區:
                                                               

   HotSpot虛擬機器的物件頭包含兩部分,第一部分是執行時資料,如雜湊嗎、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID等,這部分資料的長度在32位和64位虛擬機器中分別為32bit和64bit,以32位虛擬機器為例,Mark Word的32bit位中的25bit用於儲存物件雜湊嗎,4bit用於儲存物件的分代年齡,2bit用於儲存鎖標誌位,1bit固定為0。

   物件頭的另外一部分是型別指標。即指向他的類元資料的指標,虛擬機器通過這個指標來確定是哪個類的例項,如果物件是一個java陣列,那麼物件頭中還必須有一塊用於記錄陣列長度的資料,因為虛擬機器可以通過普通java物件的元資料資訊確定java物件的大小,但是從陣列的元資料中無法確定陣列的大小。

   物件中除了物件頭之外,還包含了例項資料,例項資料是在程式程式碼中所定義的各種型別的欄位內容,無論是從父類中繼承下來的還是子類中定義的,都需要記錄下來。例項資料的大小由各變數的型別決定。

   第三部分的對其填充不是必然存在的,僅僅起著佔位符的作用,HotSpot虛擬機器的自動記憶體管理系統要求物件其實位置地址必須是8位元組的整數倍,就是說物件的大小必須是8的整數倍,如果物件不是8的整數倍的話,就需要對其填充來補全。

四、方法區

   同java堆一樣,方法區是執行緒共享的記憶體區域,它儲存每個類的資訊,例如執行時常量池,欄位和方法資料、方法程式碼等,包括用於類和例項初始化以及介面初始化的特殊方法。
舉例:
比如型別資訊:

  • 型別的全限定名
  • 超類的全限定名
  • 直接超介面的全限定名
  • 型別標誌(該類是類型別還是介面型別)
  • 類的訪問描述符(public、private、default、abstract、final、static)

比如:執行時常量池
   執行時常量池(Runtime Constant Pool)是方法區的一部分.Class檔案中除了有類的版本/欄位/方法/介面等描述資訊外,還有一項資訊是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將類在載入後進入方法區的執行時常量池中存放.

比如欄位資訊:
欄位名,欄位型別、欄位的訪問修飾符等,方法資訊如:方法名,方法返回型別、方法修飾符等。

   以上簡單介紹了下java執行時資料區的各部分組成。java程式在執行的過程,會把執行程式所需要用到的資料和物件型別的資訊分配在不同的執行時資料區,除了程式計數器以外,以上的的執行時資料區都有可能會在執行的時候產生一些記憶體不足相關的異常,下面就通過一些簡單的例項來分析下各個資料區會產生的異常型別。

虛擬機器棧的異常

   前面提到,每一個方法呼叫直至完成的過程,就對應著一個棧幀在虛擬機器中出棧和入棧的過程。而棧幀中的區域性變量表存放了編譯期可知的各種型別的資料,基本型別、引用型別、returnAdress型別,區域性變量表所需的記憶體空間在編譯期間完成分配,當一個方法需要在幀中分配多大的區域性變數空間是完全確定的,在java虛擬機器中,對這個區域規定了兩種異常情況,如果請求的棧深度大於虛擬機器所允許的棧深度,將會跑出StackOverFlowError異常,如果虛擬機器棧可以動態擴充套件,並且在擴充套件時無法申請到足夠的記憶體的時候就會丟擲OutOfMemoryError異常。

我們來看下StackOverFlowError的示例:

    public class StackOverDemo {
    public static long count=0;
    public static void sum(long count){
        System.out.println(count++);
        sum(count);
    }

    public static void main(String[] args) {
        sum(1);
    }
}
7477
Exception in thread "main" java.lang.StackOverflowError
    at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
    at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
    at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
    at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
    at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)
    at java.io.BufferedWriter.flushBuffer(BufferedWriter.java:129)
    at java.io.PrintStream.write(PrintStream.java:526)
    at java.io.PrintStream.print(PrintStream.java:611)
    at java.io.PrintStream.println(PrintStream.java:750)
    at jvm.StackOverDemo.sum(StackOverDemo.java:6)

   當我們在一個方法中不斷的遞迴呼叫的時候,這個方法執行緒請求的棧的深度大於了虛擬機器所允許的棧的深度,就丟擲了StackOverFlowError異常,當然我們可以根據引數-Xss設定每個執行緒的堆疊大小。執行緒棧的大小是個雙刃劍,如果設定過小,可能會出現棧溢位,特別是在該執行緒內有遞迴、大的迴圈時出現溢位的可能性更大,如果該值設定過大,就有影響到建立棧的數量,如果是多執行緒的應用,就會出現記憶體溢位的錯誤。在深入理解java虛擬機器棧一書提供了一個記憶體溢位的例子,但是,正如書中所提示的那樣,這段程式碼執行導致了我的Windows系統宕機也沒有出現OOM,最後不得不關機重啟。有興趣的朋友可以用書中的這段程式碼執行看看,友情提醒:注意儲存資料。

程式碼示例:

public class StackOOMDemo {

    private void dontStop() {
        while (true) {
        }
    }

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

    public static void main(String[] args) {
        StackOOMDemo oom = new StackOOMDemo();
        oom.stackLeakByThread();
    }
}

堆的記憶體溢位

   對於大多數應用來說,java堆是虛擬機器所管理裡的記憶體中最大的一塊區域,也是垃圾收集器管理的主要區域。java堆用於儲存物件例項,只要不斷的建立物件,並且保證GC Roots到物件之間有可達路徑來避免垃圾回收機制來清除這些物件,那麼在物件大小到達堆的最大容量限制之後就會產生記憶體溢位異常,也就是OutOfMemoryError。下面來通過程式碼示例演示一下:
為了方便演示,將堆的初始化和最大值都設定為20M並且可以通過引數-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機器在出現記憶體溢位遺產時Dump出當前的記憶體堆轉儲存快照進行分析。
-Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError

public class HeapOOMDemo {
    static class Student{
        String name="chen";
    }

    public static void main(String[] args) {
        List<Student> list = new ArrayList<>();
        while (true){
            list.add(new Student());
        }
    }
}
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid6524.hprof ...
Heap dump file created [34819181 bytes in 0.202 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:261)
    at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
    at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
    at java.util.ArrayList.add(ArrayList.java:458)
    at jvm.HeapOOMDemo.main(HeapOOMDemo.java:15)

Process finished with exit code 1

方法區記憶體溢位

   方法區存放的是類的資訊如類名、常量池、欄位資訊等資料,所以需要在執行時產生大量的類去填滿方法區,將元資料區(基於jdk1.8)設定大小為10M,使用位元組碼增強器來不斷的產生類。
-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M

public class MethodAreaOOMDemo {
    static class Sutdent{
        private void say(){};
        private String name="cc";

    }

    public static void main(String[] args) {
        while (true){
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(HeapOOMDemo.Student.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invoke(o,objects);
                }
            });
            enhancer.create();
        }
    }

}
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:526)
    at org.springframework.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:363)
    at org.springframework.cglib.proxy.Enhancer.generate(Enhancer.java:582)
    at org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:131)
    at org.springframework.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:319)
    at org.springframework.cglib.proxy.Enhancer.createHelper(Enhancer.java:569)
    at org.springframework.cglib.proxy.Enhancer.create(Enhancer.java:384)
    at jvm.MethodAreaOOMDemo.main(MethodAreaOOMDemo.java:27)

Process finished with exit code 1

我們注意到,方法區的java.lang.OutOfMemoryError和堆區的java.lang.OutOfMemoryError提示是不一樣的,堆區是java heap space 而方法區是Metaspace,這裡和它們所屬的區域是對應的,需要注意的是,如果是jdk1.7的話,方法區提示的是PermGen space,並且設定方法區大小的引數是XX:PermSize10M -XX:MaxPermSize10M ,這是因為從JDK8開始,永久代(PermGen)的概念被廢棄掉了,取而代之的是稱為Metaspace的儲存空間。Metaspace使用的是本地記憶體,預設情況下Metaspace的大小隻與本地記憶體大小有關。

本地方法棧

本地方法棧和虛擬機器棧類似,本地方法棧也會丟擲StackOverFlow和OutofMemory異常。

   通過上面的介紹,大致瞭解了虛擬機器的記憶體結構和各個執行時資料區可能產生的異常,在生產中當我們遇到這些問題該如何下手,或者說如何分析,這一部分內容將在後面的JVM調優和各種常見異常分析章節中進行解讀。其實,對於我們java程式設計師來說,在虛擬機器自動記憶體管理機制的幫助下,出現上面的異常是很少的,除非程式碼有著嚴重的bug,我第一家公司剛入職的時候有個線上應用經常無法使用,程式經常報錯OOM,但是當時確並不知道這個異常產生的原因。如果上天再給我一次機會的話,我一定要深扒一下