jvm記憶體區域之虛擬機器棧

yuanGrowing發表於2020-11-26

虛擬機器棧

執行緒私有,由一個個棧幀組成,每個棧幀對應著一個呼叫的方法,儲存有方法的區域性變數等資訊。方法被呼叫時棧幀入棧,方法結束呼叫時棧幀出棧。

出棧和入棧

可以結合下面的程式碼合來看下棧幀的出入棧過程。下面程式碼,在Main函式中呼叫methodA,執行完之後返回。

public class Test {
    public static void main(String[] args) {
        Test test = new Test();
        test.methodA();
    }

    public void methodA(int x, int y) {
        int a = 1;
        int b = a + x;
        int c = b + y;
        return c;
    }
}
  • 1、 在開啟main函式主執行緒的同時,會在虛擬機器棧中分配一個棧空間,用於main函式主執行緒的方法呼叫。
  • 2、 主執行緒呼叫main函式,建立建立main函式棧幀,入棧。
  • 3、 main函式呼叫methodA,建立methodA函式棧幀,入棧。
  • 4、 methodA函式執行完畢,方法返回,棧幀出棧。
  • 5、 main函式執行完畢,方法返回,棧幀出棧。
  • 6、 主執行緒執行完畢,棧空間釋放。
    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-u8S7px4J-1606354941708)(./image/jvm1.gif)]

棧幀結構

每個棧幀儲存的是一個方法的資訊,那麼,一個方法,都由什麼組成呢?

    public int test(Human human,int x) {
        int a = x;
        human.test();
        return a+x;
    }

看上面這個方法,有public訪問修飾符,int返回值型別,test方法名,human入參,return 0 返回值,區域性變數a,還有具體的計算邏輯。這些東西可以分成靜態和動態的,訪問修飾符、方法名這些屬於方法的結構,是靜態資訊,直接存放在方法區裡面。而區域性變數、出入參這些,每一次呼叫可能都不一樣,屬於動態資訊,這部分資訊放在棧幀裡面。
具體的,棧幀由區域性變數表、運算元棧、動態連結和返回地址組成。

區域性變數表

顧名思義,區域性變數表,存放的是方法執行過程中產生的區域性變數,包括方法內定義的變數、方法引數,比如上例中的int a。 方法中的區域性變數在編譯期就可以確定有哪些,因此區域性變數表是固定的大小,由class檔案Code屬性中的 max_locals 確定(max_locals可以使用javap命令看到,可以自行百度個教程看下)。一個方法一般都是很複雜的,有很多的if else分支,那難道區域性變數表會存放所有的出現區域性變數麼?我們可以結合javap來探究一下。

  • 空方法、空入參的區域性變數
    可以看下下面的方法,區域性變數表大小是多少呢?你也許會說,沒有入參,方法也沒定義變數,顯然是0個。

    public void test() {
    }
    

    但是使用javap命令之後,可以看到test方法對應的 max_locals = 1 。 為什麼空方法也會佔用一個區域性變數的空間呢?虛擬機器規範裡其實有答案。
    關於區域性變數表,虛擬機器規範有這麼一段描述:

    On instance method invocation,local variable 0 is always used to pass a reference 
    to the object on which the instance method is being invoked (this in the 
    Java programming language). 
    在例項方法裡面,區域性變數表的第一個總是用來存放一個方法呼叫者物件的引用(也就是java語法裡的this)。
    

    看完這個就瞭解了,因為這個test方法是個例項方法,因此區域性變數表預設有個this的引用,因此 max_locals = 1 。

    那如果是靜態方法呢?有興趣可以嘗試一下,把這個test方法改為static方法,max_locals會是0。

  • 輸入引數
    輸入引數也是動態的,在執行期間才知道的,沒法放在方法區,應該也放在區域性變數表才對。可以來驗證一下。
    下面方法有兩個輸入引數,結合上this,這個方法的區域性變數表大小應該為3才對。

    public void test(int a, int b) {
    }
    

    執行javap看下,確實是3,
    在這裡插入圖片描述

  • if分支
    那if分支裡定義的區域性變數,怎麼算呢?因為只有執行的時候才知道會進哪個分支,使用到哪些區域性變數啊。
    這裡猜測可能會有兩種情況,一種是不管你有幾個分支,只要出現區域性變數,都算。還有一種比較智慧,既然是分支,那麼同級的分支之間可定是互斥的,那麼就在所有可能執行的分支路徑中,找到一個使用區域性變數最多的,作為程式碼中區域性變數的大小。

    看下面的方法,按照第一種演算法,區域性變數是所有分支的總和,那就有6個,加上入參和this,max_locals = 9 ;按照第二種演算法,區域性變數分支最多的是4個,那 max_locals = 7 。

    public void test(int a, int b) {
        if (a == 1) {
            int s1 = 0;
            int s2 = 0;
        } else {
            int s1 = 0;
            int s2 = 0;
            int s3 = 0;
            int s4 = 0;
        }
    }
    

    javap之後…
    在這裡插入圖片描述

    結果是7個,看來jvm比較智慧,會取一個最大值,而不是簡單的全部相加。其實這也比較合理,畢竟即使是編譯期間,也可以知道程式碼可能的執行路徑,那就沒有必要全部分支的區域性變數相加了。
    如果把上面方法中的if條件換成 if(true) , 再用javap來看 max_locals = 5 。

總結一下,區域性變數表存著入參和區域性變數,如果是例項方法還有一個this引用,區域性變數表的大小取決於程式碼所有可能執行的分支路徑裡面,區域性變數最多的那個。

運算元棧

首先,得先了解一下指令集的架構。可以看下這篇文章 基於棧與基於暫存器的指令集架構
看完之後你就知道,我們們javaee的開發者用的jvm,都是用的基於棧的指令集。而我們的運算元棧,就是用於方法中的計算。棧的最大深度,其實也是可以在編譯器確定的,存放在class檔案code屬性的 max_stacks 中。

動態連結

個人對於這個的理解比較簡單,因為java是存在多型和繼承的,有些方法呼叫在類載入時沒法確定到底呼叫的是哪個類的方法,只有在執行的時候才能確定下來,所有就有了動態連結。
看下面的例子。

  • 例1
    public class Test {

        public static void test1() {
            System.out.println("test1");
        }

        public void action() {
            Test.test1();
            this.test2();
        }

        private void test2() {
            System.out.println("test2");
        }
    }

上面的action方法,呼叫了靜態方法test1和私有方法test2,因為靜態方法和私有方法不能被重寫,因此這個action方法需要呼叫的方法是明確的,固定的。在類解析階段,這個action方法中的具體的方法呼叫就會明確的在方法區內儲存。所有例項物件不需要自己再去做什麼操作就可以呼叫這個action方法。

  • 例2
    public class Test {
        public void action(Human human) {
            human.test();
        }
    }

    abstract class Human {
        public abstract void test();
    }

    class Man extends Human {
        @Override
        public void test() {
            System.out.println("human");
        }
    }

    class Woman extends Human {
        @Override
        public void test() {
            System.out.println("women");
        }
    }

這個例子裡的action方法,因為human存在Man和Woman兩個實現,這個human.test()的方法呼叫時不明確的,類載入的時候只能知道是執行human的實現之一,但是到底是Man還是Woman呢,沒法確定。每個例項物件執行這個action的時候,可能會呼叫到不同的實現。這種方法的呼叫,就要放在動態連結裡面。

如果瞭解了類載入機制,就會知道類載入的解析過程,實際上就是將類似於例1中,這些確定的固定的符號引用轉換成直接引用;動態連結,就是在執行期間,將類似例2這種不確定不固定的符號引用轉換成直接引用。(至於啥事符號引用,可以自行百度下)。

動態連結其實也是java實現多型的底層原理。

返回地址/方法出口

一個方法的結束有兩種方式,return或者異常。return方式的結束就是通過棧幀來完成的。
虛擬機器規範中關於return方式的描述:

The current frame (§2.6) is used in this case to restore the state of the invoker,
including its local variables and operand stack, with the program counter of the
invoker appropriately incremented to skip past the method invocation instruction.
Execution then continues normally in the invoking method's frame with the
returned value (if any) pushed onto the operand stack of that frame.

大概的意思就是,當前棧會儲存呼叫者的區域性變數表和運算元棧,方法正常執行完畢之後,
會恢復呼叫者區域性變數表和運算元棧,同時把返回值壓如運算元棧。

雖然jvm規範說一個方法的棧幀裡面會存有呼叫者的區域性變數表和運算元棧資訊,但是實際的虛擬機器中兩個棧幀會公用某一部分,避免資訊的重複複製。所以從物理儲存上來講,棧幀中的部分內容並不完全是一個方法棧幀獨有的,可能會和呼叫者棧幀有所重合。

總結

  • 虛擬機器棧由棧幀組成,方法的呼叫和結束對應棧幀的出入棧行為。
  • 棧幀由區域性變數表、運算元棧、動態連結、返回地址組成。
  • 區域性變數表存放this引用、入參和區域性變數,如果是靜態方法則沒有this引用。
  • 存在運算元棧是因為jvm虛擬機器指令集是基於棧的,這樣的指令對底層硬體的依賴更小,有利於跨平臺。
  • 存在動態連結是因為有些方法的呼叫只有在執行期間才能確定,它是java多型的實現原理。
  • 返回地址儲存了方法結束的資訊,不僅僅是一個地址,還有呼叫者的運算元棧和區域性變數表,用於在方法結束時恢復呼叫者的狀態,向呼叫者運算元棧中壓入方法返回值。

相關文章