1. 程式人生 > 實用技巧 >Java虛擬機器(1)——記憶體結構&棧

Java虛擬機器(1)——記憶體結構&棧

廢話

最近把《深入理解JVM原理》看完了,這種書理論性太強了,我怕不寫點什麼我自己過兩天就忘了,寫來加深印象。

概述

100%JVM教程或者是書上都會有這張圖。。。

啊,,,,這是JVM執行時的記憶體結構。很多Java基礎教程都會提到Java中的堆和棧,但實際上Java的執行時記憶體結構要更為複雜一些。這裡我只介紹棧,剩下的後面再說。

  • 虛擬機器棧: 就是我們平時說的棧,也是一會主要介紹的東西。Java程式設計師常說方法呼叫本質上就是棧,可以看出棧主要管的就是Java中的方法呼叫。
  • 本地方法棧: 用於呼叫Native方法。有些底層方法或者平臺相關的方法需要用原生代碼實現,成為Native方法。

虛擬機器棧

你應該先知道棧是啥,我想都nm學JVM了,不會有人不知道棧這種最基本的資料結構吧。

不知道請點這裡

虛擬機器棧裡都有啥

主要就三個東西:區域性變量表運算元棧幀資料區

倒也不是沒有別的,後面也會介紹。

虛擬機器棧長啥樣??

棧是以棧幀(Stack Frame)為單位的,一個棧幀就代表一次方法呼叫。下圖就是使用Idea自帶的Debugger中檢視的棧幀。

對應程式碼如下

public static void main(String[] args) {
    method1();
}
public static void method1(){
    method2();
}

public static void method2(){
}

那棧幀裡都有啥?用一個簡單的Java程式碼說明

public static void main(String[] args) {
    int a = 10;
    int b = 20;
    int c = a + b;
}

編譯,使用javap -v <類名>檢視位元組碼,其中包含如下資訊:

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
    stack=3, locals=4, args_size=1
        0: bipush    10
        //...省略其他位元組碼
    LineNumberTable:
        line 5: 0
        //...省略其他行號對映

    LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  args   [Ljava/lang/String;
            3       8     1     a   I
            6       5     2     b   I
           10       1     3     c   I

首先前三行就是一些關於方法的描述資訊。
第一行是該方法的簽名。
第二行是方法的引數和返回值描述符,是(引數)返回值形式,[代表是陣列,L代表是引用型別,[Ljava/lang/String就是一個java.lang包下的引用型別String組成的陣列,也就是字串陣列。V就代表返回型別是void
第三行就是方法的修飾符資訊,代表該方法被public static兩個修飾符修飾。

然後第五行我們先略過,直接看下面,下面是根據方法中的Java程式碼生成的JVM可識別的Java位元組碼,JVM會使用上文提到的運算元棧來執行這些位元組碼代表的運算,最後返回結果。

所以第五行stack=3就是運算元棧的大小為3,編譯器會自動判斷這個大小,確保能完成方法中所有操作。locals=4是方法中有四個區域性變數(分別是方法引數和abc三個)。arg_size=1就是引數有一個。

運算元棧

比如我們要執行上面a+b的操作,翻譯成位元組碼就是這樣的(我們只關注下面程式碼中沒有註釋的):

0: bipush        10
//2: istore_1
3: bipush        20
//5: istore_2
//6: iload_1
//7: iload_2
8: iadd
//9: istore_3
//10: return

首先是兩個bipush指令分別把整形的1020壓入運算元棧,然後iadd操作把棧頂的兩個運算元取出,相加,再入運算元棧。用圖片來說就是這樣的:

不過實際過程要比這個複雜,上述只是為了便於理解,實際則是這樣的:

0: bipush        10  // 把10壓入運算元棧
2: istore_1          // 把10彈出,存給區域性變量表第一個變數槽
3: bipush        20  // 把20壓入運算元棧
5: istore_2          // 把20彈出,存給區域性變量表第二個變數槽
6: iload_1           // 從區域性變量表槽1載入資料
7: iload_2           // 從區域性變量表槽2載入資料
8: iadd              // 將前面兩個運算元相加,將結果壓入運算元棧
9: istore_3          // 存給區域性變量表第三個變數槽
10: return           // 方法結束

上面的程式碼註釋很全了,就是JVM在執行時做的事,上面提到了區域性變量表,康康它是啥玩意兒。

區域性變量表

回顧上面使用javap命令檢視到的位元組碼,裡面有這樣一段,就是區域性變量表

LocalVariableTable:
    Start  Length  Slot  Name   Signature
        0      11     0  args   [Ljava/lang/String;
        3       8     1     a   I
        6       5     2     b   I
       10       1     3     c   I

區域性變量表中儲存的就是方法中的區域性變數,包括方法引數,區域性變數和例項方法中的隱含引數this

區域性變量表中有一些槽(Slot),用於存放變數值,對於基本型別是值,對於物件則是存放的引用。

把基本型別直接存放在虛擬機器棧裡有一個好處,我們知道一個棧幀代表一個方法呼叫,方法結束,棧幀銷燬,存在棧幀中的變數也隨之銷燬,而不需要GC垃圾回收器干預。Java編譯器在面對一些可展開的物件時也會把物件直接展開成若干個基本型別,從而降低垃圾回收器的工作量。如果此係列還有續的話(如果我不懶的話)應該會在後面說到。

我們看上面的變數槽,正好能和位元組碼命令中istoreiload指令下劃線後面的數字對應,這就證明虛擬機器會為方法中每一個區域性變數開闢一個單獨的槽。

槽複用

如果方法中有些作用域更小的變數,它在方法執行完成前就被釋放,在它後面定義的變數就可以複用它的槽。比如:

public static void main(String[] args) {
    int a = 10;
    int b = 20;
    {
        int d = 10; // 下面的c可以複用d的槽
    }
    int c = a + b;
}

幀資料區

幀資料區中儲存棧幀中一些方法引數和返回值的東西。

動態連結

先來說常量池,Java的Class檔案中就有常量池的描述,JVM載入類的時候會根據這個描述在方法區中建立執行時常量池。常量池裡存的就是Java程式執行時產生的一些常量,比如方法名、變數名、類名等等。而虛擬機器棧需要在執行時動態連結到當前棧幀所屬方法的引用。

我們看看下面這段程式和它對應的位元組碼。

public static void main(String[] args) {
    method1();
    method2();
}
public static void method1(){

}
public static void method2(){

}
Code:
    stack=0, locals=1, args_size=1
        0: invokestatic  #2                  // Method method1:()V
        3: invokestatic  #3                  // Method method2:()V
        6: return

可以看到第一二條指令是invokestatic,字面意思就是呼叫靜態方法,java有五個方法呼叫相關的位元組碼,分別是invokestaticinvokespecialinvokevirtualinvokeinterfaceinvokedynamic,會在後面說到。

這兩個invokestatic分別呼叫了#2#3,這兩個東西在常量池裡,我們看看這個類位元組碼中的常量池資訊。

Constant pool:
   #1 = Methodref          #5.#21         // java/lang/Object."<init>":()V
   #2 = Methodref          #4.#22         // io/lilpig/jvm_learn/stack/StackLearn_01.method1:()V
   #3 = Methodref          #4.#23         // io/lilpig/jvm_learn/stack/StackLearn_01.method2:()V
   #4 = Class              #24            // io/lilpig/jvm_learn/stack/StackLearn_01
   #5 = Class              #25            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               LocalVariableTable
  #11 = Utf8               this
  #12 = Utf8               Lio/lilpig/jvm_learn/stack/StackLearn_01;
  #13 = Utf8               main
  #14 = Utf8               ([Ljava/lang/String;)V
  #15 = Utf8               args
  #16 = Utf8               [Ljava/lang/String;
  #17 = Utf8               method1
  #18 = Utf8               method2
  #19 = Utf8               SourceFile
  #20 = Utf8               StackLearn_01.java
  #21 = NameAndType        #6:#7          // "<init>":()V
  #22 = NameAndType        #17:#7         // method1:()V
  #23 = NameAndType        #18:#7         // method2:()V
  #24 = Utf8               io/lilpig/jvm_learn/stack/StackLearn_01
  #25 = Utf8               java/lang/Object

我們看到#2#3分別就是我們的method1method2的方法引用,這個方法具體在哪是後話,就知道我們的虛擬機器棧需要動態連結到常量池找它就好了。然後可以看到#2#3又分別引用了#4.#22#4.#23,然後那些東西又引用了其它的。

所以常量池的優點就是能把一些公共的東西抽取出來,以引用的形式訪問。比如上圖的#7,是一個代表void返回值的Utf8字面量,程式中很多地方要用到,以這種常量池的方式只需要在這裡宣告一次就好了。

方法連結

前面說,JVM位元組碼中有五個呼叫方法相關的位元組碼命令,分別是invokestaticinvokespecialinvokevirtualinvokeinterfaceinvokedynamic。前四個分別用於呼叫靜態方法,呼叫例項方法,呼叫過載的方法和呼叫介面方法。第五個是Java為支援動態型別的一些特性新增的位元組碼指令,在JDK8中使用Lambda表示式就會被編譯成一個invokedynamic位元組碼。

前四個被分為兩大類,分別是靜態連結和動態連結,怎麼區別呢?就是你這個方法,編譯器能直接確定呼叫哪個版本,那就是靜態連結,靜態連結的兩條指令是invokestaticinvokespecial,反之,如果這個方法編譯器無法確定直接呼叫哪個版本,而是要留到JVM在執行期間確定,那就是動態連結,兩條指令是invokeinterfaceinvokevirtual。(我認為invokedynamic也算動態連結)。

那麼哪些方法在編譯期間可以確定版本呢?肯定是那些不能支援多型的方法,它們就沒有版本這一說,要呼叫就只有一個。比如static方法、final方法、private方法都不能被重寫。而普通的例項方法,實現的介面方法則都得等到執行期間才能確定呼叫哪個版本,這種方法也稱為虛方法(virtual method)。

或者你在程式碼中寫明瞭要呼叫哪個版本,如使用thissuper限定,這樣編譯器也能在編譯期確定方法版本。

值得一提的是final方法雖然也算靜態連結,但是使用的位元組碼仍然是invokevirtual

動態連結對應的繫結方法稱為晚期繫結,靜態連結稱為早期繫結。

看下面的程式碼,又是一個因為講解而拼湊的無意義程式碼。

public class StackLearn_02 {
    public static void main(String[] args) {
        Creature creature = getCreature();
        creature.born();
        creature.die();
    }

    public static Creature getCreature(){
        return new Human();
    }
}

interface Creature{
    void born();
    void die();
}

class Human implements Creature{

    @Override
    public void born() {
        System.out.println("Human born...");
    }

    @Override
    public void die() {
        System.out.println("Human die...");
    }
}

這個getCreature方法是靜態的,所以編譯期能確定,肯定使用的是invokestatic。而creature.borncreature.die編譯器無法確定版本,所以使用invokeinterface。位元組碼如下:

 0 invokestatic #2 <io/lilpig/jvm_learn/stack/StackLearn_02.getCreature>
 3 astore_1
 4 aload_1
 5 invokeinterface #3 <io/lilpig/jvm_learn/stack/Creature.born> count 1
10 aload_1
11 invokeinterface #4 <io/lilpig/jvm_learn/stack/Creature.die> count 1
16 return

第五個,我已經累了,不想寫了,直接看程式碼。。。

public class StackLearn_03 {
    interface IntFunc{
        int apply(int x);
    }
    public static void main(String[] args) {
        IntFunc func = x -> x*x;
        func.apply(10);
    }
}
 0 invokedynamic #2 <apply, BootstrapMethods #0>
 5 astore_1
 6 aload_1
 7 bipush 10
 9 invokeinterface #3 <io/lilpig/jvm_learn/stack/StackLearn_03$IntFunc.apply> count 2
14 pop
15 return