天天看點

面試常問的Java虛拟機記憶體模型,看這篇就夠了

一、虛拟機

面試常問的Java虛拟機記憶體模型,看這篇就夠了

同樣的java代碼在不同平台生成的機器碼肯定是不一樣的,因為不同的作業系統底層的硬體指令集是不同的。

同一個java代碼在windows上生成的機器碼可能是0101.......,在linux上生成的可能是1100......,那麼這是怎麼實作的呢?

不知道同學們還記不記得,在下載下傳jdk的時候,我們在oracle官網,基于不同的作業系統或者位數版本要下載下傳不同的jdk版本,也就是說針對不同的作業系統,jdk虛拟機有不同的實作。

那麼虛拟機又是什麼東西呢,如圖是從軟體層面屏蔽不同作業系統在底層硬體與指令上的差別,也就是跨平台的由來。

說到這裡同學們可能還是有點不太明白,說的還是太宏觀了,那我們來了解下java虛拟機的組成。

二、虛拟機組成

面試常問的Java虛拟機記憶體模型,看這篇就夠了

1.棧

我們先講一下其中的一塊記憶體區域棧,大家都知道棧是存儲局部變量的,也是線程獨有的區域,也就是每一個線程都會有自己獨立的棧區域。

public class Math {
    public static int initData = 666;
    public static User user = new User();
 
    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a+b) * 10;
        return c;
    }
 
    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
        System.out.println("test");
    }
}      

說起棧大家都不會陌生,資料結構中就有學,這裡線程棧中存儲資料的部分使用的就是棧,先進後出。

大家都知道每個方法都有自己的局部變量,比如上圖中main方法中的math,compute方法中的a b c,那麼java虛拟機為了區分不同方法中局部變量作用域範圍的記憶體區域,每個方法在運作的時候都會配置設定一塊獨立的棧幀記憶體區域,我們試着按上圖中的程式來簡單畫一下代碼執行的記憶體活動。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

執行main方法中的第一行代碼是,棧中會配置設定main()方法的棧幀,并存儲math局部變量,,接着執行compute()方法,那麼棧又會配置設定compute()的棧幀區域。

這裡的棧存儲資料的方式和資料結構中學習的棧是一樣的,先進後出。當compute()方法執行完之後,就會出棧被釋放,也就符合先進後出的特點,後調用的方法先出棧。

棧幀

那麼棧幀内部其實不隻是存放局部變量的,它還有一些别的東西,主要由四個部分組成。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

那麼要講這個就會涉及到更底層的原理--位元組碼。我們先看下我們上面代碼的位元組碼檔案。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

看着就是一個16位元組的檔案,看着像亂碼,其實每個都是有對應的含義的,oracle官方是有專門的jvm位元組碼指令手冊來查詢每組指令對應的含義的。那我們研究的,當然不是這個。

jdk有自帶一個javap的指令,可以将上述class檔案生成一種更可讀的位元組碼檔案。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

我們使用javap -c指令将class檔案反編譯并輸出到TXT檔案中。

Compiled from "Math.java"
public class com.example.demo.test1.Math {
  public static int initData;
 
  public static com.example.demo.bean.User user;
 
  public com.example.demo.test1.Math();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
 
  public int compute();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush 10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn
 
  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class com/example/demo/test1/Math
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method compute:()I
      12: pop
      13: getstatic #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      16: ldc #6                  // String test
      18: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      21: return
 
  static {};
    Code:
       0: sipush 666
       3: putstatic #8                  // Field initData:I
       6: new           #9                  // class com/example/demo/bean/User
       9: dup
      10: invokespecial #10                 // Method com/example/demo/bean/User."<init>":()V
      13: putstatic #11                 // Field user:Lcom/example/demo/bean/User;
      16: return
}      

此時的jvm指令碼就清晰很多了,大體結構是可以看懂的,類、靜态變量、構造方法、compute()方法、main()方法。

其中方法中的指令還是有點懵,我們舉compute()方法來看一下:

Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush 10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn      

這幾行代碼就是對應的我們代碼中compute()方法中的四行代碼。大家都知道越底層的代碼,代碼實作的行數越多,因為他會包含一些java代碼在運作時底層隐藏的一些細節原理。

那麼一樣的,這個jvm指令官方也是有手冊可以查閱的,網上也有很多翻譯版本,大家如果想了解可自行百度。

這裡我隻講解本博文設計代碼中的部分指令含義:

0. 将int類型常量1壓入操作數棧

0: iconst_1      

這一步很簡單,就是将1壓入操作數棧

面試常問的Java虛拟機記憶體模型,看這篇就夠了

1. 将int類型值存入局部變量1

1: istore_1      

局部變量1,在我們代碼中也就是第一個局部變量a,先給a在局部變量表中配置設定記憶體,然後将int類型的值,也就是目前唯一的一個1存入局部變量a

面試常問的Java虛拟機記憶體模型,看這篇就夠了

2. 将int類型常量2壓入操作數棧

2: iconst_2      

3. 将int類型值存入局部變量2

3: istore_2      

這兩行代碼就和前兩行類似了。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

4. 從局部變量1中裝載int類型值

4: iload_1      

5. 從局部變量2中裝載int類型值

5: iload_2      

這兩個代碼是将局部變量1和2,也就是a和b的值裝載到操作數棧中

面試常問的Java虛拟機記憶體模型,看這篇就夠了

6. 執行int類型的加法

6: iadd      

iadd指令一執行,會将操作數棧中的1和2依次從棧底彈出并相加,然後把運算結果3在壓入操作數棧底。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

7. 将一個8位帶符号整數壓入棧

7: bipush 10      

這個指令就是将10壓入棧

面試常問的Java虛拟機記憶體模型,看這篇就夠了

8. 執行int類型的乘法

9: imul      

這裡就類似上面的加法了,将3和10彈出棧,把結果30壓入棧

面試常問的Java虛拟機記憶體模型,看這篇就夠了

9. 将将int類型值存入局部變量3

10: istore_3      

這裡大家就不陌生了吧,和第二步第三步是一樣的,将30存入局部變量3,也就是c

面試常問的Java虛拟機記憶體模型,看這篇就夠了

10. 從局部變量3中裝載int類型值

11: iload_3      

這個前面也說了

面試常問的Java虛拟機記憶體模型,看這篇就夠了

11. 傳回int類型值

12: ireturn      

這個就不用多說了,就是将操作數棧中的30傳回

到這裡就把我們compute()方法講解完了,講完有沒有對局部變量表和操作數棧的了解有所加深呢?說白了指派号=後面的就是操作數,在這些操作數進行指派,運算的時候需要記憶體存放,那就是存放在操作數棧中,作為臨時存放操作數的一小塊記憶體區域。

接下來我們再說說方法出口。

方法出口說白了不就是方法執行完了之後要出到哪裡,那麼我們知道上面compute()方法執行完之後應該回到main()方法第三行那麼當main()方法調用compute()的時候,compute()棧幀中的方法出口就存儲了目前要回到的位置,那麼當compute()方法執行完之後,會根據方法出口中存儲的相關資訊回到main()方法的相應位置。

那麼main()方同樣有自己的棧幀,在這裡有些不同的地方我們講一下。

我們上面已經知道局部變量會存放在棧幀中的局部變量表中,那麼main()方法中的math會存入其中,但是這裡的math是一個對象,我們知道new出來的對象是存放在堆中的

面試常問的Java虛拟機記憶體模型,看這篇就夠了

那麼這個math變量和堆中的對象有什麼聯系呢?是同一個概念麼?

當然不是的,局部變量表中的math存儲的是堆中那個math對象在堆中的記憶體位址

2.程式計數器

程式計數器也是線程私有的區域,每個線程都會配置設定程式計數器的記憶體,是用來存放目前線程正在運作或者即将要運作的jvm指令碼對應的位址,或者說行号位置。

上述代碼中每個指令碼前面都有一個行号,你就可以把它看作目前線程執行到某一行代碼位置的一個辨別,這個值就是程式計數器的值。

那麼jvm虛拟機為什麼要設定程式計數器這個結構呢?就是為了多線程的出現,多線程之間的切換,當一個程式被挂起的時候,總是要恢複的,那麼恢複到哪個位置呢,總不能又重新開始執行吧,那麼程式計數器就解決了這個問題。

3.方法區

在jdk1.8之前,有一個名稱叫做持久帶/永久代,很多同學應該聽過,在jdk1.8之後,oracle官方改名為元空間。存放常量、靜态變量、類元資訊。

public static int initData = 666;      

這個initData就是靜态變量,毋庸置疑是存放在方法區的

public static User user = new User();      

那麼這個user就有點不一樣了,user變量放在方法區,new的User是存放在堆中的

到這裡我們就能意識到棧,堆,方法區之間都是有聯系的。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

棧中的局部變量,方法區中的靜态變量,如果是對象類型的話都會指向堆中new出來中的對象,那麼紅色的聯系代表什麼呢?我們先來了解一下對象。

對象組成

你對對象的了解有多少呢,天天用對象,你是否知道對象在虛拟機中的存儲結構呢?

對象在記憶體中存儲的布局可以分為3塊區域:對象頭(Header)、執行個體資料(Instance Data)和對齊填充(Padding)。下圖是普通對象執行個體與數組對象執行個體的資料結構:

面試常問的Java虛拟機記憶體模型,看這篇就夠了

對象頭

HotSpot虛拟機的對象頭包括兩部分資訊:

Mark Word

第一部分markword,用于存儲對象自身的運作時資料,如哈希碼(HashCode)、GC分代年齡、鎖狀态标志、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分資料的長度在32位和64位的虛拟機(未開啟壓縮指針)中分别為32bit和64bit,官方稱它為“MarkWord”。

Klass Pointer

對象頭的另外一部分是klass類型指針,即對象指向它的類中繼資料的指針,虛拟機通過這個指針來确定這個對象是哪個類的執行個體.

數組長度(隻有數組對象有)

如果對象是一個數組, 那在對象頭中還必須有一塊資料用于記錄數組長度.

執行個體資料

執行個體資料部分是對象真正存儲的有效資訊,也是在程式代碼中所定義的各種類型的字段内容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。

對齊填充

第三部分對齊填充并不是必然存在的,也沒有特别的含義,它僅僅起着占位符的作用。由于HotSpot VM的自動記憶體管理系統要求對象起始位址必須是8位元組的整數倍,換句話說,就是對象的大小必須是8位元組的整數倍。而對象頭部分正好是8位元組的倍數(1倍或者2倍),是以,當對象執行個體資料部分沒有對齊時,就需要通過對齊填充來補全。

其中的klass類型指針就是那條紅色的聯系,那是怎麼聯系的呢?

new Thread().start();      
面試常問的Java虛拟機記憶體模型,看這篇就夠了

類加載其實最終是以類元資訊的形式存儲在方法區中的,math和math2都是由同一個類new出來的,當對象被new時,都會在對象頭中存儲一個指向類元資訊的指針,這就是Klass Pointer.

到這裡我們就講解了棧,程式計數器和方法區,下面我們簡單介紹一下本地方法區,最後再終點講解堆。

4.本地方法棧

實際上現在本地方法棧已經用的比較少了,大家應該都有聽過本地方法吧

如何經常用的線程類

new Thread().start();      
public synchronized void start() {
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        group.add(this);
        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
            }
        }
    }      

其中底層調用了一個start0()的方法

private native void start0();      

這個方法沒有實作,但又不是接口,是使用native修飾的,是屬于本地方法,底層通過C語言實作的,那java代碼裡為什麼會有C語言實作的本地方法呢?

大家都知道JAVA是問世的,在那之前一個公司的系統百分之九十九都是使用C語言實作的,但是java出現後,很多項目都要轉為java開發,那麼新系統和舊系統就免不了要有互動,那麼就需要本地方法來實作了,底層是調用C語言中的dll庫檔案,就類似于java中的jar包,當然,如今跨語言的互動方式就很多了,比如thrift,http接口方式,webservice等,當時并沒有這些方式,就隻能通過本地方法來實作了。

那麼本地方法始終也是方法,每個線程在運作的時候,如果有運作到本地方法,那麼必然也要産生局部變量等,那麼就需要存儲在本地方法棧了。如果沒有本地方法,也就沒有本地方法棧了。

5.堆

最後我們講堆,堆是最重要的一塊記憶體區域,我相信大部分人對堆都不陌生。但是對于它的内部結構,運作細節想要搞清楚也沒那麼簡單。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

對于這個基本組成大家應該都有所了解,對就是由年輕代和老年代組成,年輕代又分為伊甸園區和survivor區,survivor區中又有from區和to區.

我們new出來的對象大家都知道是放在堆中,那具體放在堆中的哪個位置呢?

其實new出來的對象一般都放在Eden區,那麼為什麼叫伊甸園區呢,伊甸園就是亞當夏娃住的地方,不就是造人的地方麼?是以我們new出來的對象就是放在這裡的,那當Eden區滿了之後呢?

假設我們給對配置設定600M記憶體,這個是可以通過參數調節的,我們後文再講。那麼老年代預設是占2/3的,也就是差不多400M,那年輕代就是200M,Eden區160M,Survivor區40M。

GC

面試常問的Java虛拟機記憶體模型,看這篇就夠了

一個程式隻要在運作,那麼就不會不停的new對象,那麼總有一刻Eden區會放滿,那麼一旦Eden區被放滿之後,虛拟機會幹什麼呢?沒錯,就是gc,不過這裡的gc屬于minor gc,就是垃圾收集,來收集垃圾對象并清理的,那麼什麼是垃圾對象呢?

好比我們上面說的math對象,我們假設我們是一個web應用程式,main線程執行完之後程式不會結束,但是main方法結束了,那麼main()方法棧幀會被釋放,局部變量會被釋放,但是局部變量對應的堆中的對象還是依然存在的,但是又沒有指針指向它,那麼它就是一個垃圾對象,那就應該被回收掉了,之後如果還會new Math對象,也不會用這個之前的了,因為已經無法找到它了,如果留着這個對象隻會占用記憶體,顯然是不合适的。

這裡就涉及到了一個GC Root根以及可達性分析算法的概念,也是面試偶爾會被問到的。

可達性分析算法是将GC Roots對象作為起點,從這些起點開始向下搜尋引用的對象,找到的對象都标記為非垃圾對象,其餘未标記的都是垃圾對象。

那麼GC Roots根對象又是什麼呢,GC Roots根就是判斷一個對象是否可以回收的依據,隻要能通過GC Roots根向下一直搜尋能搜尋到的對象,那麼這個對象就不算垃圾對象,而可以作為GC Roots根的有線程棧的本地變量,靜态變量,本地方法棧的變量等等,說白了就是找到和根節點有聯系的對象就是有用的對象,其餘都認為是垃圾對象來回收。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

經曆了第一次minor gc後,沒有被清理的對象就會被移到From區,如上圖。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

上面在說對象組成的時候有寫到,在對象頭的Mark Word中有存儲GC分代年齡,一個對象每經曆一次gc,那麼它的gc分代年齡就會+1,如上圖。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

那麼如果第二次新的對象又把Eden區放滿了,那麼又會執行minor gc,但是這次會連着From區一起gc,然後将Eden區和From區存活的對象都移到To區域,對象頭中分代年齡都+1,如上圖。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

那麼當第三次Eden區又滿的時候,minor gc就是回收Eden區和To區域了,TEden區和To區域還活着的對象就會都移到From區,如上圖。說白了就是Survivor區中總有一塊區域是空着的,存活的對象存放是在From區和To區輪流存放,也就是互相複制拷貝,這也就是垃圾回收算法中的複制-回收算法。

如果一個對象經曆了一個限值15次gc的時候,就會移至老年代。那如果還沒有到限值,From區或者To區域也放不下了,就會直接挪到老年代,這隻是舉例了兩種正常規則,還有其他規則也是會把對象存放至老年代的。

那麼随着應用程式的不斷運作,老年代最終也是會滿的,那麼此時也會gc,此時的gc就是Full gc了。

GC案例

下面我們通過一個簡單的示範案例來更加清楚的了解GC。

public class HeapTest {
    byte[] a = new byte[1024*100];
    public static void main(String[] args) throws InterruptedException {
        ArrayList<HeapTest> heapTest = new ArrayList<>();
        while(true) {
            heapTest.add(new HeapTest());
            Thread.sleep(10);
        }
    }
}      

這塊代碼很明顯,就是一個死循環,不斷的往list中添加new出來的對象。

我們這裡使用jdk自帶的一個jvm調優工具jvisualvm來觀察一下這個代碼執行的的記憶體結構。

運作代碼打開之後我們可以看到這樣的界面:

面試常問的Java虛拟機記憶體模型,看這篇就夠了

我們在左邊的應用程式中可以看到我們運作的這個代碼,右邊是它的一些jvm,記憶體資訊,我們這裡不關注,我們需要用到的是最後一個Visual GC面闆,這是一個插件,如果你的打開沒有這一欄的話,可以再工具欄的插件中進行下載下傳安裝。

打開visual GC,我們先看一下界面大概的布局,

面試常問的Java虛拟機記憶體模型,看這篇就夠了

其中老年代(Olc),伊甸園區(Eden),S0(From),S1(To)幾個區域的記憶體和動态配置設定圖都是清晰可見,以一對應的。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

我們選擇中間一張圖給大家對應一下上面所講的内容:

1:對象放入Eden區

2:Eden區滿發生minor gc

3:第二步的存活對象移至From(Survivor 0)區

4:Eden區再滿發生minor gc

5:第四步存活的對象移至To(Survivor 1)區

面試常問的Java虛拟機記憶體模型,看這篇就夠了

這裡可以注意到From和To區域和我們上面所說移至,總有一個是空的。

面試常問的Java虛拟機記憶體模型,看這篇就夠了

大家還可以注意到老年代這裡,都是一段一段的直線,中間是突然的增加,這就是在minor gc中一批一批符合規則的對象被批量移入老年代。

那當我們老年代滿了會發生什麼呢?當然是我們上面說過的Full GC,但是你仔細看我們寫的這個程式,我們所有new出來的HeapTest對象都是存放在heapLists中的,那就會被這個局部變量所引用,那麼Full GC就不會有什麼垃圾對象可以回收,可是記憶體又滿了,那怎麼辦?

面試常問的Java虛拟機記憶體模型,看這篇就夠了

沒錯,就是我們就算沒見過也總聽過的OOM。

到這裡jvm記憶體模型簡單介紹就結束了,看到這裡還不點個贊嘛!

                   資源擷取: