攜程面試官竟然問我 Java 虛擬機棧

大家好,我是那個永遠 18 歲的老妖怪~噓

從《JVM 內存區域劃分》這篇文章中,大家應該 get 到了,Java 虛擬機內存區域可以劃分爲程序計數器、Java 虛擬機棧、本地方法棧和堆。今天,我們來圍繞其中的一個區域——Java 虛擬機棧,深入地展開下。

先說明一下哈。這篇文章的標題裏帶了一個“攜程面試官”,有標題黨的嫌疑。但有一說一,確實有讀者在上一篇文章裏留言說,攜程面試官問他了 Java 虛擬機內存方面的知識點,所以今天的標題我就“借題發揮”了。

從“相見恨晚”這個詞中,我估摸着這名讀者在這道面試題前面折戟沉沙了。這麼說吧,面試官確實喜歡問 Java 虛擬機方面的知識點,因爲很能考察出一名應聘者的真實功底,所以我打算多寫幾篇這方面的文章,希望能給大家多一點點幫助~

Java 虛擬機以方法作爲基本的執行單元,“棧幀(Stack Frame)”則是用於支持 Java 虛擬機進行方法調用和方法執行的基本數據結構。每一個棧幀中都包含了局部變量表、操作數棧、動態鏈接、方法返回地址和一些額外的附加信息(比如與調試、性能手機相關的信息)。之前的文章裏有提到過這些概念,並做了一些簡單扼要的介紹,但我覺得還不夠詳細,所以這篇重點要來介紹一下棧幀中的這些概念。

1)局部變量表

局部變量表(Local Variables Table)用來保存方法中的局部變量,以及方法參數。當 Java 源代碼文件被編譯成 class 文件的時候,局部變量表的最大容量就已經確定了。

我們來看這樣一段代碼。

public class LocalVaraiablesTable {
    private void write(int age) {
        String name = "沉默王二";
    }
}

write() 方法有一個參數 age,一個局部變量 name。

然後用 Intellij IDEA 的 jclasslib 查看一下編譯後的字節碼文件 LocalVaraiablesTable.class。可以看到 write() 方法的 Code 屬性中,Maximum local variables(局部變量表的最大容量)的值爲 3。

按理說,局部變量表的最大容量應該爲 2 纔對,一個 age,一個 name,爲什麼是 3 呢?

當一個成員方法(非靜態方法)被調用時,第 0 個變量其實是調用這個成員方法的對象引用,也就是那個大名鼎鼎的 this。調用方法 write(18),實際上是調用 write(this, 18)

點開 Code 屬性,查看 LocalVaraiableTable 就可以看到詳細的信息了。

第 0 個是 this,類型爲 LocalVaraiablesTable 對象;第 1 個是方法參數 age,類型爲整形 int;第 2 個是方法內部的局部變量 name,類型爲字符串 String。

當然了,局部變量表的大小並不是方法中所有局部變量的數量之和,它與變量的類型和變量的作用域有關。當一個局部變量的作用域結束了,它佔用的局部變量表中的位置就被接下來的局部變量取代了。

來看下面這段代碼。

public static void method() {
    // ①
    if (true) {
        // ②
        String name = "沉默王二";
    }
    // ③
    if(true) {
        // ④
        int age = 18;
    }
    // ⑤
}
  • method() 方法的局部變量表大小爲 1,因爲是靜態方法,所以不需要添加 this 作爲局部變量表的第一個元素;
  • ②的時候局部變量有一個 name,局部變量表的大小變爲 1;
  • ③的時候 name 變量的作用域結束;
  • ④的時候局部變量有一個 age,局部變量表的大小爲 1;
  • ⑤的時候局 age 變量的作用域結束;

關於局部變量的作用域,《Effective Java》 中的第 57 條建議:

將局部變量的作用域最小化,可以增強代碼的可讀性和可維護性,並降低出錯的可能性。

在此,我還有一點要提醒大家。爲了儘可能節省棧幀耗用的內存空間,局部變量表中的槽是可以重用的,就像 method() 方法演示的那樣,這就意味着,合理的作用域有助於提高程序的性能。

局部變量表的容量以槽(slot)爲最小單位,一個槽可以容納一個 32 位的數據類型(比如說 int,當然了,《Java 虛擬機規範》中沒有明確指出一個槽應該佔用的內存空間大小,但我認爲這樣更容易理解),像 float 和 double 這種明確佔用 64 位的數據類型會佔用兩個緊挨着的槽。

來看下面的代碼。

public void solt() {
    double d = 1.0;
    int i = 1;
}

用 jclasslib 可以查看到,solt() 方法的 Maximum local variables 的值爲 4。

爲什麼等於 4 呢?帶上 this 也就 3 個呀?

查看 LocalVaraiableTable 就明白了,變量 i 的下標爲 3,也就意味着變量 d 佔了兩個槽。

2)操作數棧

同局部變量表一樣,操作數棧(Operand Stack)的最大深度也在編譯的時候就確定了,被寫入到了 Code 屬性的 maximum stack size 中。當一個方法剛開始執行的時候,操作數棧是空的,在方法執行過程中,會有各種字節碼指令往操作數棧中寫入和取出數據,也就是入棧和出棧操作。

來看下面這段代碼。

public class OperandStack {
    public void test() {
        add(1,2);
    }

    private int add(int a, int b) {
        return a + b;
    }
}

OperandStack 類共有 2 個方法,test() 方法中調用了 add() 方法,傳遞了 2 個參數。用 jclasslib 可以看到,test() 方法的 maximum stack size 的值爲 3。

這是因爲調用成員方法的時候會將 this 和所有參數壓入棧中,調用完畢後 this 和參數都會一一出棧。通過 「Bytecode」 面板可以查看到對應的字節碼指令。

  • aload_0 用於將局部變量表中下標爲 0 的引用類型的變量,也就是 this 加載到操作數棧中;
  • iconst_1 用於將整數 1 加載到操作數棧中;
  • iconst_2 用於將整數 2 加載到操作數棧中;
  • invokevirtual 用於調用對象的成員方法;
  • pop 用於將棧頂的值出棧;
  • return 爲 void 方法的返回指令。

再來看一下 add() 方法的字節碼指令。

  • iload_1 用於將局部變量表中下標爲 1 的 int 類型變量加載到操作數棧上(下標爲 0 的是 this);
  • iload_2 用於將局部變量表中下標爲 2 的 int 類型變量加載到操作數棧上;
  • iadd 用於 int 類型的加法運算;
  • ireturn 爲返回值爲 int 的方法返回指令。

操作數中的數據類型必須與字節碼指令匹配,以上面的 iadd 指令爲例,該指令只能用於整形數據的加法運算,它在執行的時候,棧頂的兩個數據必須是 int 類型的,不能出現一個 long 型和一個 double 型的數據進行 iadd 命令相加的情況。

3)動態鏈接

每個棧幀都包含了一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程中的動態鏈接(Dynamic Linking)。

來看下面這段代碼。

public class DynamicLinking {
    static abstract class Human {
       protected abstract void sayHello();
    }
    
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("男人哭吧哭吧不是罪");
        }
    }
    
    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("山下的女人是老虎");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}

大家對 Java 重寫有了解的話,應該能看懂這段代碼的意思。Man 類和 Woman 類繼承了 Human 類,並且重寫了 sayHello() 方法。來看一下運行結果:

男人哭吧哭吧不是罪
山下的女人是老虎
山下的女人是老虎

這個運行結果很好理解,man 的引用類型爲 Human,但指向的是 Man 對象,woman 的引用類型也爲 Human,但指向的是 Woman 對象;之後,man 又指向了新的 Woman 對象。

從面向對象編程的角度,從多態的角度,我們對運行結果是很好理解的,但站在 Java 虛擬機的角度,它是如何判斷 man 和 woman 該調用哪個方法的呢?

用 jclasslib 看一下 main 方法的字節碼指令。

  • 第 1 行:new 指令創建了一個 Man 對象,並將對象的內存地址壓入棧中。
  • 第 2 行:dup 指令將棧頂的值複製一份並壓入棧頂。因爲接下來的指令 invokespecial 會消耗掉一個當前類的引用,所以需要複製一份。
  • 第 3 行:invokespecial 指令用於調用構造方法進行初始化。
  • 第 4 行:astore_1,Java 虛擬機從棧頂彈出 Man 對象的引用,然後將其存入下標爲 1 局部變量 man 中。
  • 第 5、6、7、8 行的指令和第 1、2、3、4 行類似,不同的是 Woman 對象。
  • 第 9 行:aload_1 指令將第局部變量 man 壓入操作數棧中。
  • 第 10 行:invokevirtual 指令調用對象的成員方法 sayHello(),注意此時的對象類型爲 com/itwanger/jvm/DynamicLinking$Human
  • 第 11 行:aload_2 指令將第局部變量 woman 壓入操作數棧中。
  • 第 12 行同第 10 行。

注意,從字節碼的角度來看,man.sayHello()(第 10 行)和 woman.sayHello()(第 12 行)的字節碼是完全相同的,但我們都知道,這兩句指令最終執行的目標方法並不相同。

究竟發生了什麼呢?

還得從 invokevirtual 這個指令着手,看它是如何實現多態的。根據《Java 虛擬機規範》,invokevirtual 指令在運行時的解析過程可以分爲以下幾步:

①、找到操作數棧頂的元素所指向的對象的實際類型,記作 C。
②、如果在類型 C 中找到與常量池中的描述符匹配的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找結束;否則返回 java.lang.IllegalAccessError 異常。
③、否則,按照繼承關係從下往上一次對 C 的各個父類進行第二步的搜索和驗證。
④、如果始終沒有找到合適的方法,則拋出 java.lang.AbstractMethodError 異常。

也就是說,invokevirtual 指令在第一步的時候就確定了運行時的實際類型,所以兩次調用中的 invokevirtual 指令並不是把常量池中方法的符號引用解析到直接引用上就結束了,還會根據方法接受者的實際類型來選擇方法版本,這個過程就是 Java 重寫的本質。我們把這種在運行期根據實際類型確定方法執行版本的過程稱爲動態鏈接

4)方法返回地址

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

  • 正常退出,可能會有返回值傳遞給上層的方法調用者,方法是否有返回值以及返回值的類型根據方法返回的指令來決定,像之前提到的 ireturn 用於返回 int 類型,return 用於 void 方法;還有其他的一些,lreturn 用於 long 型,freturn 用於 float,dreturn 用於 double,areturn 用於引用類型。

  • 異常退出,方法在執行的過程中遇到了異常,並且沒有得到妥善的處理,這種情況下,是不會給它的上層調用者返回任何值的。

無論是哪種方式退出,在方法退出後,都必須返回到方法最初被調用時的位置,程序才能繼續執行。一般來說,方法正常退出的時候,PC 計數器的值會作爲返回地址,棧幀中很可能會保存這個計數器的值,異常退出時則不會。

方法退出的過程實際上等同於把當前棧幀出棧,因此接下來可能執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值(如果有的話)壓入調用者棧幀的操作數棧中,調整 PC 計數器的值,找到下一條要執行的指令等。


以上部分內容參考自周志明老師的《深入理解 Java 虛擬機》,以及好朋友張亞的《深入理解 JVM 字節碼》。強烈推薦一下這兩本書。

初學者一開始學習 Java 虛擬機的時候可能會感到很枯燥,很難懂,但有了一定的經驗積累後,再來學習這塊知識就會有一種開竅了的感覺。當然了,Java 虛擬機這塊的知識點是必學的,因爲性能優化、找工作面試,甚至說提高編程功底都是很亟需的。

四書之一《大學》中,開篇就提到了一個概念,叫做“格物致知”,意思就是通過研究事物背後的原理來獲取知識,深入 Java 虛擬機、字節碼等背後深層次的結構和原理來剖析 Java,能讓我們變得更自信,全身瀰漫出一種“技術高手”的光芒~

推薦閱讀:

好傢伙!JVM 內存區域劃分得這麼灑脫

一把小刀,直插 class 文件的小心臟

我是沉默王二,點個贊吧,讓我們一起成爲高手,😁!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章