第 5 章 虛擬機器棧

2020-08-12 16:23:14

第 5 章 虛擬機器棧

1、虛擬機器棧概述

1.1、虛擬機器棧的出現背景

文件網址

https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

虛擬機器棧出現的背景

  1. 由於跨平臺性的設計,Java的指令都是根據棧來設計的。不同平臺CPU架構不同,所以不能設計爲基於暫存器的。
  2. 優點是跨平臺,指令集小,編譯器容易實現,缺點是效能下降,實現同樣的功能需要更多的指令

記憶體中的棧與堆

首先棧是執行時的單位,而堆是儲存的單位

  1. 棧解決程式的執行問題,即程式如何執行,或者說如何處理數據。
  2. 堆解決的是數據儲存的問題,即數據怎麼放,放哪裏

image-20200705163928652

1.2、虛擬機器棧的儲存內容

虛擬機器棧的基本內容

Java虛擬機器棧是什麼

Java虛擬機器棧(Java Virtual Machine Stack),早期也叫Java棧。每個執行緒在建立時都會建立一個虛擬機器棧,其內部儲存一個個的棧幀(Stack Frame),對應着一次次的Java方法呼叫,棧是執行緒私有的


虛擬機器棧的生命週期

生命週期和執行緒一致,也就是執行緒結束了,該虛擬機器棧也銷燬了


虛擬機器棧的作用

主管Java程式的執行,它儲存方法的區域性變數(8 種基本數據型別、物件的參照地址)、部分結果,並參與方法的呼叫和返回

  1. 區域性變數,它是相比於成員變數來說的(或屬性)
  2. 基本數據型別變數 VS 參照型別變數(類、陣列、介面)

1.3、虛擬機器棧的特點

棧的特點

棧是一種快速有效的分配儲存方式,存取速度僅次於程式計數器。JVM直接對Java棧的操作只有兩個:

  1. 每個方法執行,伴隨着進棧(入棧、壓棧)
  2. 執行結束後的出棧工作

對於棧來說不存在垃圾回收問題(棧存在溢位的情況)

image-20200705165025382

1.4、虛擬機器棧的異常

棧中可能出現的異常

面試題:棧中可能出現的異常

  1. Java 虛擬機器規範允許Java棧的大小是動態的或者是固定不變的

  2. 如果採用固定大小的Java虛擬機器棧,那每一個執行緒的Java虛擬機器棧容量可以線上程建立的時候獨立選定。

  3. 如果執行緒請求分配的棧容量超過Java虛擬機器棧允許的最大容量,Java虛擬機器將會拋出一個StackoverflowError 異常。

  4. 如果Java虛擬機器棧可以動態擴充套件,並且在嘗試擴充套件的時候無法申請到足夠的記憶體,或者在建立新的執行緒時沒有足夠的記憶體去建立對應的虛擬機器棧,那Java虛擬機器將會拋出一個 OutofMemoryError 異常。

棧異常演示

  • 程式碼
public class StackErrorTest {
    private static int count = 1;
    public static void main(String[] args) {
        System.out.println(count);
        count++;
        main(args);
    }
}
  • 遞回呼叫 11418 次後,出現棧記憶體溢位

image-20200727220541524

1.5、設定棧記憶體大小

設定棧記憶體的大小

  • 我們可以使用參數 -Xss 選項來設定執行緒的最大棧空間,棧的大小直接決定了函數呼叫的最大可達深度。
-Xss1024m		// 棧記憶體爲 1024MBS
-Xss1024k		// 棧記憶體爲 1024KB
  • 設定執行緒的最大棧空間:256KB

image-20200727220826190

  • 程式碼測試
public class StackErrorTest {
    private static int count = 1;
    public static void main(String[] args) {
        System.out.println(count);
        count++;
        main(args);
    }
}
  • 遞回 2471 次,棧記憶體溢位

image-20200727220915116

2、棧的儲存單位

2.1、棧的執行原理

棧儲存什麼?

  1. 每個執行緒都有自己的棧,棧中的數據都是以棧幀(Stack Frame)的格式存在
  2. 在這個執行緒上正在執行的每個方法都各自對應一個棧幀(Stack Frame)
  3. 棧幀是一個記憶體區塊,是一個數據集,維繫着方法執行過程中的各種數據資訊。

棧的執行原理

  1. JVM直接對Java棧的操作只有兩個,就是對棧幀的壓棧和出棧,遵循先進後出(後進先出)原則

  2. 在一條活動執行緒中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的

    • 這個棧幀被稱爲當前棧幀(Current Frame)
    • 與當前棧幀相對應的方法就是當前方法(Current Method)
    • 定義這個方法的類就是當前類(Current Class)
  3. 執行引擎執行的所有位元組碼指令只針對當前棧幀進行操作。

  4. 如果在該方法中呼叫了其他方法,對應的新的棧幀會被建立出來,放在棧的頂端,成爲新的當前幀。

  5. 不同線程中所包含的棧幀是不允許存在相互參照的,即不可能在一個棧幀之中參照另外一個執行緒的棧幀。

  6. 如果當前方法呼叫了其他方法,方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接着,虛擬機器會丟棄當前棧幀,使得前一個棧幀重新成爲當前棧幀。

  7. Java方法有兩種返回函數的方式,但不管使用哪種方式,都會導致棧幀被彈出

    • 一種是正常的函數返回,使用return指令
    • 另外一種是拋出異常

image-20200705203142545


程式碼範例:

  • 程式碼
public class StackFrameTest {
    public static void main(String[] args) {
        StackFrameTest test = new StackFrameTest();
        test.method1();
    }

    public void method1() {
        System.out.println("method1()開始執行...");
        method2();
        System.out.println("method1()執行結束...");
    }

    public int method2() {
        System.out.println("method2()開始執行...");
        int i = 10;
        int m = (int) method3();
        System.out.println("method2()即將結束...");
        return i + m;
    }

    public double method3() {
        System.out.println("method3()開始執行...");
        double j = 20.0;
        System.out.println("method3()即將結束...");
        return j;
    }
}
  • 先執行的函數,最後執行結束
method1()開始執行...
method2()開始執行...
method3()開始執行...
method3()即將結束...
method2()即將結束...
method1()執行結束...
  • 反編譯,可以看到每個方法後面都帶有 return 語句或者 ireturn 語句
  public void method1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #6                  // String method1()開始執行...
         5: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: aload_0
         9: invokevirtual #8                  // Method method2:()I
        12: pop
        13: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: ldc           #9                  // String method1()執行結束...
        18: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        21: return
      LineNumberTable:
        line 16: 0
        line 17: 8
        line 18: 13
        line 19: 21
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      22     0  this   Lcom/atguigu/java1/StackFrameTest;
  public int method2();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #10                 // String method2()開始執行...
         5: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: bipush        10
        10: istore_1
        11: aload_0
        12: invokevirtual #11                 // Method method3:()D
        15: d2i
        16: istore_2
        17: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        20: ldc           #12                 // String method2()即將結束...
        22: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        25: iload_1
        26: iload_2
        27: iadd
        28: ireturn
      LineNumberTable:
        line 22: 0
        line 23: 8
        line 24: 11
        line 25: 17
        line 26: 25
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      29     0  this   Lcom/atguigu/java1/StackFrameTest;
           11      18     1     i   I
           17      12     2     m   I
  public double method3();
    descriptor: ()D
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #13                 // String method3()開始執行...
         5: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: ldc2_w        #14                 // double 20.0d
        11: dstore_1
        12: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        15: ldc           #16                 // String method3()即將結束...
        17: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        20: dload_1
        21: dreturn
      LineNumberTable:
        line 30: 0
        line 31: 8
        line 32: 12
        line 33: 20
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      22     0  this   Lcom/atguigu/java1/StackFrameTest;
           12      10     1     j   D

2.2、棧的內部結構

棧幀內部結構

每個棧幀中儲存着:

  1. 區域性變數表(Local Variables)
  2. 運算元棧(Operand Stack)(或表達式棧)
  3. 動態鏈接(Dynamic Linking)(或指向執行時常數池的方法參照)
  4. 方法返回地址(Return Address)(或方法正常退出或者異常退出的定義)
  5. 一些附加資訊

image-20200705204836977

並行每個執行緒下的棧都是私有的,因此每個執行緒都有自己各自的棧,並且每個棧裏面都有很多棧幀,棧幀的大小主要由區域性變數表 和 運算元棧決定的

image-20200705205443993

3、區域性變數表

3.1、認識區域性變數表

認識區域性變數表

  1. 區域性變數表:Local Variables,被稱之爲區域性變數陣列或本地變數表
  2. 定義爲一個數位陣列,主要用於儲存方法參數和定義在方法體內的區域性變數,這些數據型別包括各類基本數據型別、物件參照(reference),以及returnAddress型別。
  3. 由於區域性變數表是建立線上程的棧上,是執行緒的私有數據,因此不存在數據安全問題
  4. 區域性變數表所需的容量大小是在編譯期確定下來的,並儲存在方法的Code屬性的maximum local variables數據項中。在方法執行期間是不會改變區域性變數表的大小的。
  5. 方法巢狀呼叫的次數由棧的大小決定。一般來說,棧越大,方法巢狀呼叫次數越多。
    • 對一個函數而言,它的參數和區域性變數越多,使得區域性變數表膨脹,它的棧幀就越大,以滿足方法呼叫所需傳遞的資訊增大的需求。
    • 進而函數呼叫就會佔用更多的棧空間,導致其巢狀呼叫次數就會減少。
  6. 區域性變數表中的變數只在當前方法呼叫中有效。
    • 在方法執行時,虛擬機器通過使用區域性變數表完成參數值到參數變數列表的傳遞過程。
    • 當方法呼叫結束後,隨着方法棧幀的銷燬,區域性變數表也會隨之銷燬。

區域性變數表所需的容量大小是在編譯期確定下來的

  • 程式碼
public class LocalVariablesTest {
    private int count = 0;

    public static void main(String[] args) {
        LocalVariablesTest test = new LocalVariablesTest();
        int num = 10;
        test.test1();
    }

    public void test1() {
        Date date = new Date();
        String name1 = "atguigu.com";
        test2(date, name1);
        System.out.println(date + name1);
    }

    public String test2(Date dateP, String name2) {
        dateP = null;
        name2 = "songhongkang";
        double weight = 130.5;//佔據兩個slot
        char gender = '男';
        return dateP + name2;
    }
    
}
  • 反編譯後,可得結論:
    • 在編譯期間,區域性變數的個數、每個區域性變數的大小都已經被記錄下來
    • 所以區域性變數表所需的容量大小是在編譯期確定下來的

image-20200728094057286

  • 利用 JClassLib 也可以檢視區域性變數的個數

image-20200728094210198


思考:

  • 程式碼
public static void main(String[] args) {
    if(args == null){
        LocalVariablesTest test = new LocalVariablesTest();
    }
    int num = 10;
}
  • 反編譯後,提出問題:上面程式碼中的 test 變數跑哪兒哪了呢?
  • 我估計 test 變數和 num 變數共用一個 slot

image-20200728094439892

位元組碼中方法內部結構的剖析

  • [Ljava/lang/String] :
    • [] 表示陣列
    • L 表示參照型別
    • java/lang/String 表示 java.lang.String
  • 合起來就是:main() 方法的形參型別爲 String[]

image-20200728100032128

  • 位元組碼,位元組碼長度爲 16(0~15)

image-20200728100232360

  • 方法異常資訊表

image-20200728100332466

  • 雜項(Misc)

image-20200728100423166

  • 位元組碼指令行號和原始 java 程式碼行號的對應關係

image-20200728100757147

  • 注意:生效行數和剩餘有效行數都是針對於位元組碼檔案的行數

image-20200728101044588

3.2、關於 Slot 的理解

關於 Slot 的理解

  1. 參數值的存放總是從區域性變數陣列索引 0 的位置開始,到陣列長度-1的索引結束

  2. 區域性變數表,最基本的儲存單元是Slot(變數槽),區域性變數表中存放編譯期可知的各種基本數據型別(8種),參照型別(reference),returnAddress型別的變數。

  3. 在區域性變數表裏,32位元以內的型別只佔用一個slot(包括returnAddress型別),64位元的型別佔用兩個slot(1ong和double)。

  4. JVM會爲區域性變數表中的每一個Slot都分配一個存取索引,通過這個索引即可成功存取到區域性變數表中指定的區域性變數值

  5. 當一個實體方法被呼叫的時候,它的方法參數和方法體內部定義的區域性變數將會按照順序被複制到區域性變數表中的每一個slot上

  6. 如果需要存取區域性變數表中一個64bit的區域性變數值時,只需要使用前一個索引即可。(比如:存取long或doub1e型別變數)

  7. 如果當前幀是由構造方法或者實體方法建立的,那麼該物件參照this將會存放在index爲0的slot處,其餘的參數按照參數表順序繼續排列。

image-20200705212454445

Slot 程式碼範例

this 存放在 index = 0 的位置:

  • 程式碼
public void test3() {
    this.count++;
}
  • 區域性變數表:this 存放在 index = 0 的位置

image-20200728102851509


64位元的型別(1ong和double)佔用兩個slot

  • 程式碼
public String test2(Date dateP, String name2) {
    dateP = null;
    name2 = "songhongkang";
    double weight = 130.5;//佔據兩個slot
    char gender = '男';
    return dateP + name2;
}
  • weight 爲 double 型別,index 直接從 3 蹦到了 5

image-20200728103053250


static 無法呼叫 this

  • this 不存在與 static 方法的區域性變數表中,所以無法呼叫
//練習:
public static void testStatic(){
    LocalVariablesTest test = new LocalVariablesTest();
    Date date = new Date();
    int count = 10;
    System.out.println(count);
    //因爲 this 變數不存在於當前方法的區域性變數表中!!
    //System.out.println(this.count);
}

3.3、Slot 的重複利用

Slot 的重複利用

棧幀中的區域性變數表中的槽位是可以重用的,如果一個區域性變數過了其作用域,那麼在其作用域之後申明新的區域性變數變就很有可能會複用過期區域性變數的槽位,從而達到節省資源的目的。


  • 程式碼
public void test4() {
    int a = 0;
    {
        int b = 0;
        b = a + 1;
    }
    //變數c使用之前已經銷燬的變數b佔據的slot的位置
    int c = a + 1;
}
  • 區域性變數 c 重用了區域性變數 b 的 slot 位置

image-20200728103342525

靜態變數與區域性變數的對比

變數的分類:

  1. 按照數據型別分:
    1. 基本數據型別
    2. 參照數據型別
  2. 按照在類中宣告的方式分:
    2. 類變數:
    • linking的prepare階段:給類變數預設賦值
    • initial階段:給類變數顯式賦值即靜態程式碼塊賦值
    1. 範例變數:隨着物件的建立,會在堆空間中分配範例變數空間,並進行預設賦值
    2. 區域性變數:在使用前,必須要進行顯式賦值的!否則,編譯不通過,應該是棧中數據彈出後,不會清除上次的值,再次使用時,如果不顯示初始化,就會出現髒數據

  1. 參數表分配完畢之後,再根據方法體內定義的變數的順序和作用域分配。
  2. 我們知道類變數表有兩次初始化的機會第一次是在「準備階段」,執行系統初始化,對類變數設定零值,另一次則是在「初始化」階段,賦予程式設計師在程式碼中定義的初始值
  3. 和類變數初始化不同的是,區域性變數表不存在系統初始化的過程,這意味着一旦定義了區域性變數則必須人爲的初始化,否則無法使用。

程式碼範例

  • 報錯:區域性變數未初始化

image-20200728103943968

補充說明

  1. 在棧幀中,與效能調優關係最爲密切的部分就是前面提到的區域性變數表。在方法執行時,虛擬機器使用區域性變數表完成方法的傳遞。

  2. 區域性變數表中的變數也是重要的垃圾回收根節點,只要被區域性變數表中直接或間接參照的物件都不會被回收。

4、運算元棧

4.1、運算元棧的特點

運算元棧的特點

運算元棧:Operand Stack

  1. 每一個獨立的棧幀除了包含區域性變數表以外,還包含一個後進先出(Last - In - First -Out)的 運算元棧,也可以稱之爲表達式棧(Expression Stack)

  2. 運算元棧,在方法執行過程中,根據位元組碼指令,往棧中寫入數據或提取數據,即入棧(push)和 出棧(pop)

  3. 某些位元組碼指令將值壓入運算元棧,其餘的位元組碼指令將運算元取出棧。使用它們後再把結果壓入棧,比如:執行復制、交換、求和等操作

image-20200706090618332


程式碼舉例

  • 左邊爲 java 原始碼,右邊爲 java 程式碼編譯生成的位元組碼指令

image-20200706090833697

4.2、運算元棧的作用

運算元棧的作用

  1. 運算元棧,主要用於儲存計算過程的中間結果,同時作爲計算過程中變數臨時的儲存空間

  2. 運算元棧就是JVM執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也會隨之被建立出來,這時方法的運算元棧是空的(這個時候陣列是有長度的,只是運算元棧爲空)

  3. 每一個運算元棧都會擁有一個明確的棧深度用於儲存數值,其所需的最大深度在編譯期就定義好了,儲存在方法的Code屬性中,爲maxstack的值。

  4. 棧中的任何一個元素都是可以任意的Java數據型別

    • 32bit的型別佔用一個棧單位深度
    • 64bit的型別佔用兩個棧單位深度
  5. 運算元棧並非採用存取索引的方式來進行數據存取的,而是只能通過標準的入棧和出棧操作來完成一次數據存取

  6. 如果被呼叫的方法帶有返回值的話,其返回值將會被壓入當前棧幀的運算元棧中,並更新PC暫存器中下一條需要執行的位元組碼指令。

  7. 運算元棧中元素的數據型別必須與位元組碼指令的序列嚴格匹配,這由編譯器在編譯器期間進行驗證,同時在類載入過程中的類檢驗階段的數據流分析階段要再次驗證。

  8. 另外,我們說Java虛擬機器的解釋引擎是基於棧的執行引擎,其中的棧指的就是運算元棧

運算元棧的深度

通過反編譯生成的位元組碼指令檢視運算元棧的深度

image-20200728105905469

5、程式碼追蹤

運算元棧程式碼追蹤

  • 程式碼
public void testAddOperation() {
    //byte、short、char、boolean:都以int型來儲存
    byte i = 15;
    int j = 8;
    int k = i + j;
}
  • 反編譯得到的位元組碼指令
 0 bipush 15
 2 istore_1
 3 bipush 8
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return

程式執行流程如下

  • 首先執行第一條語句,PC暫存器指向的是0,也就是指令地址爲0,然後使用bipush讓運算元15入運算元棧。

image-20200706093131621

  • 執行完後,讓PC + 1,指向下一行程式碼,下一行程式碼就是將運算元棧的元素儲存到區域性變數表1的位置,我們可以看到區域性變數表的已經增加了一個元素
  • 解釋爲什麼區域性變數表索引從 1 開始,因爲該方法爲實體方法,區域性變數表索引爲 0 的位置存放的是 this

image-20200706093251302

  • 然後PC+1,指向的是下一行。讓運算元8也入棧,同時執行store操作,存入區域性變數表中

    image-20200706093646406

    image-20200706093751711

  • 然後從區域性變數表中,依次將數據放在運算元棧中,等待執行 add 操作

image-20200706093859191

image-20200706093921573

  • 然後將運算元棧中的兩個元素執行相加操作,並儲存在區域性變數表3的位置

image-20200706094046782

image-20200706094109629

關於 int j = 8; 的說明

  • 我們反編譯得到的位元組碼指令如下
    • 因爲 8 可以存放在 byte 型別中,所以壓入運算元棧的型別爲 byte ,而不是 int ,所以執行的位元組碼指令爲 bipush 8
    • 然後執行將數值 8 存放在 int 型別的變數中:istore_2

image-20200728112222199

關於呼叫方法,返回值入運算元棧的說明

  • 程式碼
public int getSum(){
    int m = 10;
    int n = 20;
    int k = m + n;
    return k;
}

public void testGetSum(){
    //獲取上一個棧楨返回的結果,並儲存在運算元棧中
    int i = getSum();
    int j = 10;
}
  • getSum() 方法位元組碼指令:最後帶着個 ireturn

image-20200728112603621

  • testGetSum() 方法位元組碼指令:一上來就載入 getSum() 方法的返回值

image-20200728112631597

++i 與 i++ 的區別

  • 程式碼
// 程式設計師面試過程中, 常見的i++和++i 的區別,放到位元組碼篇章時再介紹。
public void add(){
    //第1類問題:
    int i1 = 10;
    i1++;

    int i2 = 10;
    ++i2;

    //第2類問題:
    int i3 = 10;
    int i4 = i3++;

    int i5 = 10;
    int i6 = ++i5;

    //第3類問題:
    int i7 = 10;
    i7 = i7++;

    int i8 = 10;
    i8 = ++i8;

    //第4類問題:
    int i9 = 10;
    int i10 = i9++ + ++i9;
}
  • 下面 下麪,我根據位元組碼指令,簡單說下 i++ 和 ++i 的區別
 0 bipush 10
 2 istore_1
 3 iinc 1 by 1
 6 bipush 10
 8 istore_2
 9 iinc 2 by 1
12 bipush 10
14 istore_3
15 iload_3
16 iinc 3 by 1
19 istore 4
21 bipush 10
23 istore 5
25 iinc 5 by 1
28 iload 5
30 istore 6
32 bipush 10
34 istore 7
36 iload 7
38 iinc 7 by 1
41 istore 7
43 bipush 10
45 istore 8
47 iinc 8 by 1
50 iload 8
52 istore 8
54 bipush 10
56 istore 9
58 iload 9
60 iinc 9 by 1
63 iinc 9 by 1
66 iload 9
68 iadd
69 istore 10
71 return

i++

  • java 原始碼
//第2類問題:
int i3 = 10;
int i4 = i3++;
  • 位元組碼指令:
    • bipush 10 :將 10 壓入運算元棧
    • istore_3 :將運算元棧中的 10 儲存到變數 i3 中
    • iload_3 :將變數 i3 的值(10)載入至運算元棧中
    • iinc 3 by 1:變數 i3 執行 +1 操作
    • istore 4:將運算元棧中的值儲存至變數 i4 中(10)
12 bipush 10
14 istore_3
15 iload_3
16 iinc 3 by 1
19 istore 4

++i

  • java 原始碼
int i5 = 10;
int i6 = ++i5;
  • 位元組碼指令
    • bipush 10 :將 10 壓入運算元棧
    • istore 5 :將運算元棧中的 10 儲存到變數 i5 中
    • iinc 5 by 1:變數 i5 執行 +1 操作
    • iload 5 :將變數 i5 的值(11)載入至運算元棧中
    • istore 6:將運算元棧中的值儲存至變數 i6 中(11)
21 bipush 10
23 istore 5
25 iinc 5 by 1
28 iload 5
30 istore 6

總結:

  1. i++:先將 i 的值載入到運算元棧,再將 i 的值加 1
  2. ++i:先將 i 的值加 1,在將 i 的值載入到運算元棧

6、棧頂快取技術

棧頂快取技術:Top Of Stack Cashing

  1. 前面提過,基於棧式架構的虛擬機器所使用的零地址指令更加緊湊,但完成一項操作的時候必然需要使用更多的入棧和出棧指令,這同時也就意味着將需要更多的指令分派(instruction dispatch)次數和記憶體讀/寫次數。

  2. 由於運算元是儲存在記憶體中的,因此頻繁地執行記憶體讀/寫操作必然會影響執行速度。爲了解決這個問題,HotSpot JVM的設計者們提出了棧頂快取(Tos,Top-of-Stack Cashing)技術,將棧頂元素全部快取在物理CPU的暫存器中,以此降低對記憶體的讀/寫次數,提升執行引擎的執行效率。

  3. 暫存器的主要優點:指令更少,執行速度快

7、動態鏈接

動態鏈接(或指向執行時常數池的方法參照)

動態鏈接:Dynamic Linking

image-20200706100311886


  1. 每一個棧幀內部都包含一個指向執行時常數池中該棧幀所屬方法的參照

  2. 包含這個參照的目的就是爲了支援當前方法的程式碼能夠實現動態鏈接(Dynamic Linking),比如:invokedynamic指令

  3. 在Java原始檔被編譯到位元組碼檔案中時,所有的變數和方法參照都作爲符號參照(Symbolic Reference)儲存在class檔案的常數池裏

  4. 比如:描述一個方法呼叫了另外的其他方法時,就是通過常數池中指向方法的符號參照來表示的,那麼動態鏈接的作用就是爲了將這些符號參照轉換爲呼叫方法的直接參照

image-20200706101251847

程式碼範例

  • 程式碼
public class DynamicLinkingTest {

    int num = 10;

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

    public void methodB(){
        System.out.println("methodB()....");
        methodA();
        num++;
    }

}

  • 在位元組碼指令中,methodB() 方法中通過 invokevirtual #7 指令呼叫了方法 A
  • 那麼 #7 是個啥呢?
  public void methodB();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #6                  // String methodB()....
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: aload_0
         9: invokevirtual #7                  // Method methodA:()V
        12: aload_0
        13: dup
        14: getfield      #2                  // Field num:I
        17: iconst_1
        18: iadd
        19: putfield      #2                  // Field num:I
        22: return
      LineNumberTable:
        line 16: 0
        line 18: 8
        line 20: 12
        line 21: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   Lcom/atguigu/java1/DynamicLinkingTest;

  • 往上面翻,找到常數池的定義:#7 = Methodref #8.#31
    • 先找 #8 :
      • #8 = Class #32 :去找 #32
      • #32 = Utf8 com/atguigu/java1/DynamicLinkingTest
      • 結論:通過 #8 我們找到了 DynamicLinkingTest 這個類
    • 再來找 #31:
      • #31 = NameAndType #19:#13 :去找 #19 和 #13
      • #19 = Utf8 methodA :方法名爲 methodA
      • #13 = Utf8 ()V :方法沒有形參,返回值爲 void
  • 結論:通過 #7 我們就能找到需要呼叫的 methodA() 方法,並進行呼叫
Constant pool:
   #1 = Methodref          #9.#23         // java/lang/Object."<init>":()V
   #2 = Fieldref           #8.#24         // com/atguigu/java1/DynamicLinkingTest.num:I
   #3 = Fieldref           #25.#26        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = String             #27            // methodA()....
   #5 = Methodref          #28.#29        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #6 = String             #30            // methodB()....
   #7 = Methodref          #8.#31         // com/atguigu/java1/DynamicLinkingTest.methodA:()V
   #8 = Class              #32            // com/atguigu/java1/DynamicLinkingTest
   #9 = Class              #33            // java/lang/Object
  #10 = Utf8               num
  #11 = Utf8               I
  #12 = Utf8               <init>
  #13 = Utf8               ()V
  #14 = Utf8               Code
  #15 = Utf8               LineNumberTable
  #16 = Utf8               LocalVariableTable
  #17 = Utf8               this
  #18 = Utf8               Lcom/atguigu/java1/DynamicLinkingTest;
  #19 = Utf8               methodA
  #20 = Utf8               methodB
  #21 = Utf8               SourceFile
  #22 = Utf8               DynamicLinkingTest.java
  #23 = NameAndType        #12:#13        // "<init>":()V
  #24 = NameAndType        #10:#11        // num:I
  #25 = Class              #34            // java/lang/System
  #26 = NameAndType        #35:#36        // out:Ljava/io/PrintStream;
  #27 = Utf8               methodA()....
  #28 = Class              #37            // java/io/PrintStream
  #29 = NameAndType        #38:#39        // println:(Ljava/lang/String;)V
  #30 = Utf8               methodB()....
  #31 = NameAndType        #19:#13        // methodA:()V
  #32 = Utf8               com/atguigu/java1/DynamicLinkingTest
  #33 = Utf8               java/lang/Object
  #34 = Utf8               java/lang/System
  #35 = Utf8               out
  #36 = Utf8               Ljava/io/PrintStream;
  #37 = Utf8               java/io/PrintStream
  #38 = Utf8               println
  #39 = Utf8               (Ljava/lang/String;)V
  • 在上面,其實還有很多符號參照,比如 Object、System、PrintStream 等等

爲什麼要用常數池呢?

  1. 因爲在不同的方法,都可能呼叫常數或者方法,所以只需要儲存一份即可,然後記錄其參照即可,節省了空間

  2. 常數池的作用:就是爲了提供一些符號和常數,便於指令的識別

8、解析和分派

8.1、靜態鏈接與動態鏈接

靜態鏈接機制 機製與動態鏈接機制 機製

在JVM中,將符號參照轉換爲呼叫方法的直接參照與方法的系結機制 機製相關

  1. 靜態鏈接

    當一個位元組碼檔案被裝載進JVM內部時,如果被呼叫的目標方法在編譯期確定,且執行期保持不變時,這種情況下將呼叫方法的符號參照轉換爲直接參照的過程稱之爲靜態鏈接

  2. 動態鏈接

    如果被呼叫的方法在編譯期無法被確定下來,也就是說,只能夠在程式執行期將呼叫的方法的符號轉換爲直接參照,由於這種參照轉換過程具備動態性,因此也被稱之爲動態鏈接。

8.2、早期系結與晚期系結

方法的系結機制 機製

靜態鏈接和動態鏈接對應的方法的系結機制 機製爲:早期系結(Early Binding)和晚期系結(Late Binding)。系結是一個欄位、方法或者類在符號參照被替換爲直接參照的過程,這僅僅發生一次。

  1. 早期系結

    早期系結就是指被呼叫的目標方法如果在編譯期可知,且執行期保持不變時,即可將這個方法與所屬的型別進行系結,這樣一來,由於明確了被呼叫的目標方法究竟是哪一個,因此也就可以使用靜態鏈接的方式將符號參照轉換爲直接參照

  2. 晚期系結

    如果被呼叫的方法在編譯期無法被確定下來,只能夠在程式執行期根據實際的型別系結相關的方法,這種系結方式也就被稱之爲晚期系結。

程式碼範例

  • 程式碼
/**
 * 說明早期系結和晚期系結的例子
 *
 * @author shkstart
 * @create 2020 上午 11:59
 */
class Animal {
    public void eat() {
        System.out.println("動物進食");
    }
}

interface Huntable {
    void hunt();
}

class Dog extends Animal implements Huntable {
    @Override
    public void eat() {
        System.out.println("狗吃骨頭");
    }

    @Override
    public void hunt() {
        System.out.println("捕食耗子,多管閒事");
    }
}

class Cat extends Animal implements Huntable {
    public Cat() {
        super();//表現爲:早期系結
    }

    public Cat(String name) {
        this();//表現爲:早期系結
    }

    @Override
    public void eat() {
        super.eat();//表現爲:早期系結
        System.out.println("貓吃魚");
    }

    @Override
    public void hunt() {
        System.out.println("捕食耗子,天經地義");
    }
}

public class AnimalTest {
    public void showAnimal(Animal animal) {
        animal.eat();//表現爲:晚期系結
    }

    public void showHunt(Huntable h) {
        h.hunt();//表現爲:晚期系結
    }
}
  • invokevirtual 體現爲晚期系結

image-20200728150755367

  • invokeinterface 也體現爲晚期系結

image-20200728150903827

  • invokespecial 體現爲早期系結

image-20200728150955826

8.3、多型性與方法系結

多型性與方法系結機制 機製

  1. 隨着高階語言的橫空出世,類似於Java一樣的基於物件導向的程式語言如今越來越多,儘管這類程式語言在語法風格上存在一定的差別,但是它們彼此之間始終保持着一個共性,那就是都支援封裝、繼承和多型等物件導向特性,既然這一類的程式語言具備多型特性,那麼自然也就具備早期系結和晚期系結兩種系結方式。

  2. Java中任何一個普通的方法其實都具備虛擬函式的特徵,它們相當於C++語言中的虛擬函式(C++中則需要使用關鍵字virtual來顯式定義)。如果在Java程式中不希望某個方法擁有虛擬函式的特徵時,則可以使用關鍵字final來標記這個方法。

虛方法與非虛方法

虛方法與非虛方法的區別

  1. 如果方法在編譯期就確定了具體的呼叫版本,這個版本在執行時是不可變的。這樣的方法稱爲非虛方法。
  2. 靜態方法、私有方法、fina1方法、範例構造器、父類別方法都是非虛方法。
  3. 其他方法稱爲虛方法。

子類物件的多型的使用前提:

  1. 類的繼承關係
  2. 方法的重寫

虛擬機器中呼叫方法的指令

四條普通指令:

  1. invokestatic:呼叫靜態方法,解析階段確定唯一方法版本
  2. invokespecial:呼叫<init>方法、私有及父類別方法,解析階段確定唯一方法版本
  3. invokevirtual:呼叫所有虛方法
  4. invokeinterface:呼叫介面方法

一條動態呼叫指令

invokedynamic:動態解析出需要呼叫的方法,然後執行


區別

  1. 前四條指令固化在虛擬機器內部,方法的呼叫執行不可人爲幹預
  2. 而invokedynamic指令則支援由使用者確定方法版本
  3. 其中invokestatic指令和invokespecial指令呼叫的方法稱爲非虛方法,其餘的(fina1修飾的除外)稱爲虛方法。

程式碼範例:

  • 程式碼
/**
 * 解析呼叫中非虛方法、虛方法的測試
 *
 * invokestatic指令和invokespecial指令呼叫的方法稱爲非虛方法
 * @author shkstart
 * @create 2020 下午 12:07
 */
class Father {
    public Father() {
        System.out.println("father的構造器");
    }

    public static void showStatic(String str) {
        System.out.println("father " + str);
    }

    public final void showFinal() {
        System.out.println("father show final");
    }

    public void showCommon() {
        System.out.println("father 普通方法");
    }
}

public class Son extends Father {
    public Son() {
        //invokespecial
        super();
    }

    public Son(int age) {
        //invokespecial
        this();
    }

    //不是重寫的父類別的靜態方法,因爲靜態方法不能被重寫!
    public static void showStatic(String str) {
        System.out.println("son " + str);
    }

    private void showPrivate(String str) {
        System.out.println("son private" + str);
    }

    public void show() {
        //invokestatic
        showStatic("atguigu.com");

        //invokestatic
        super.showStatic("good!");

        //invokespecial
        showPrivate("hello!");

        //invokevirtual
        //雖然位元組碼指令中顯示爲invokevirtual,但因爲此方法宣告有final,不能被子類重寫,所以也認爲此方法是非虛方法。
        showFinal();

        //invokespecial
        super.showCommon();

        //invokevirtual
        //有可能子類會重寫父類別的showCommon()方法
        showCommon();
        info();

        MethodInterface in = null;
        //invokeinterface
        in.methodA();
    }

    public void info() {

    }

    public void display(Father f) {
        f.showCommon();
    }

    public static void main(String[] args) {
        Son so = new Son();
        so.show();
    }
}

interface MethodInterface {
    void methodA();
}
  • Son 類中 show() 方法的位元組碼指令如下

image-20200728152731214


關於 invokedynamic 指令

  1. JVM位元組碼指令集一直比較穩定,一直到Java7中才增加了一個invokedynamic指令,這是Java爲了實現【動態型別語言】支援而做的一種改進。

  2. 但是在Java7中並沒有提供直接生成invokedynamic指令的方法,需要藉助ASM這種底層位元組碼工具來產生invokedynamic指令。直到Java8的Lambda表達式的出現,invokedynamic指令的生成,在Java中纔有了直接的生成方式。

  3. Java7中增加的動態語言型別支援的本質是對Java虛擬機器規範的修改,而不是對Java語言規則的修改,這一塊相對來講比較複雜,增加了虛擬機器中的方法呼叫,最直接的受益者就是執行在Java平臺的動態語言的編譯器。


程式碼範例

  • 程式碼
@FunctionalInterface
interface Func {
    public boolean func(String str);
}

public class Lambda {
    public void lambda(Func func) {
        return;
    }

    public static void main(String[] args) {
        Lambda lambda = new Lambda();

        Func func = s -> {
            return true;
        };

        lambda.lambda(func);

        lambda.lambda(s -> {
            return true;
        });
    }
}
  • 位元組碼指令

image-20200728154236568

8.4、方法重寫的本質

動態語言和靜態語言

  1. 動態型別語言和靜態型別語言兩者的區別就在於對型別的檢查是在編譯期還是在執行期,滿足前者就是靜態型別語言,反之是動態型別語言。

  2. 說的再直白一點就是,靜態型別語言是判斷變數自身的型別資訊;動態型別語言是判斷變數值的型別資訊,變數沒有型別資訊,變數值纔有型別資訊,這是動態語言的一個重要特徵。

Java:String info = "mogu blog";     		(Java是靜態型別語言的,會先編譯就進行型別檢查)
JS:var name = "shkstart";    var name = 10;	(執行時才進行檢查)

方法重寫的本質

Java 語言中方法重寫的本質:

  1. 找到運算元棧頂的第一個元素所執行的物件的實際型別,記作C。
  2. 如果在型別C中找到與常數中的描述符合簡單名稱都相符的方法,則進行存取許可權校驗
    • 如果通過則返回這個方法的直接參照,查詢過程結束
    • 如果不通過,則返回java.1ang.IllegalAccessError 異常
  3. 否則,按照繼承關係從下往上依次對C的各個父類別進行第2步的搜尋和驗證過程
  4. 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。

IllegalAccessError介紹

  1. 程式試圖存取或修改一個屬性或呼叫一個方法,這個屬性或方法,你沒有許可權存取。
  2. 一般的,這個會引起編譯器異常。這個錯誤如果發生在執行時,就說明一個類發生了不相容的改變。
  3. 比如,你把應該有的jar包放從工程中拿走了,或者Maven中存在jar包衝突

回看解析階段

  1. 解析階段就是將常數池內的符號參照轉換爲直接參照的過程
  2. 解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別等。對應常數池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等

8.5、多型與虛方法表

虛方法表

  1. 在物件導向的程式設計中,會很頻繁的使用到動態分派,如果在每次動態分派的過程中都要重新在類的方法元數據中搜尋合適的目標的話就可能影響到執行效率

  2. 因此,爲了提高效能,JVM採用在類的方法區建立一個虛方法表(virtual method table)來實現,非虛方法不會出現在表中。使用索引表來代替查詢。

  3. 每個類中都有一個虛方法表,表中存放着各個方法的實際入口

  4. 虛方法表是什麼時候被建立的呢?虛方法表會在類載入的鏈接階段被建立並開始初始化,類的變數初始值準備完成之後,JVM會把該類的虛方法表也初始化完畢。

  5. 如圖所示:如果類中重寫了方法,那麼呼叫的時候,就會直接在該類的虛方法表中查詢

image-20200706144954070

9、方法返回地址

方法返回地址(return address)

  1. 存放呼叫該方法的pc暫存器的值。一個方法的結束,有兩種方式:

    • 正常執行完成
    • 出現未處理的異常,非正常退出
  2. 無論通過哪種方式退出,在方法退出後都返回到該方法被呼叫的位置。方法正常退出時,呼叫者的pc計數器的值作爲返回地址,即呼叫該方法的指令的下一條指令的地址。而通過異常退出的,返回地址是要通過異常表來確定,棧幀中一般不會儲存這部分資訊。

  3. 本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢復上層方法的區域性變數表、運算元棧、將返回值壓入呼叫者棧幀的運算元棧、設定PC暫存器值等,讓呼叫者方法繼續執行下去。

  4. 正常完成出口和異常完成出口的區別在於:通過異常完成出口退出的不會給他的上層呼叫者產生任何的返回值。

方法退出的兩種方式

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


正常退出:

  1. 執行引擎遇到任意一個方法返回的位元組碼指令(return),會有返回值傳遞給上層的方法呼叫者,簡稱正常完成出口;
  2. 一個方法在正常呼叫完成之後,究竟需要使用哪一個返回指令,還需要根據方法返回值的實際數據型別而定。
  3. 在位元組碼指令中,返回指令包含:
    1. ireturn:當返回值是boolean,byte,char,short和int型別時使用
    2. lreturn:Long型別
    3. freturn:Float型別
    4. dreturn:Double型別
    5. areturn:參照型別
    6. return:返回值型別爲void的方法、範例初始化方法、類和介面的初始化方法

異常退出:

  1. 在方法執行過程中遇到異常(Exception),並且這個異常沒有在方法內進行處理,也就是隻要在本方法的異常表中沒有搜尋到匹配的例外處理器,就會導致方法退出,簡稱異常完成出口。

  2. 方法執行過程中,拋出異常時的例外處理,儲存在一個異常處理表,方便在發生異常的時候找到處理異常的程式碼

image-20200706154554604

程式碼舉例

  • 程式碼
public class ReturnAddressTest {
    public boolean methodBoolean() {
        return false;
    }

    public byte methodByte() {
        return 0;
    }

    public short methodShort() {
        return 0;
    }

    public char methodChar() {
        return 'a';
    }

    public int methodInt() {
        return 0;
    }

    public long methodLong() {
        return 0L;
    }

    public float methodFloat() {
        return 0.0f;
    }

    public double methodDouble() {
        return 0.0;
    }

    public String methodString() {
        return null;
    }

    public Date methodDate() {
        return null;
    }

    public void methodVoid() {

    }

    static {
        int i = 10;
    }

    public void method2() {
        methodVoid();
        try {
            method1();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void method1() throws IOException {
        FileReader fis = new FileReader("atguigu.txt");
        char[] cBuffer = new char[1024];
        int len;
        while ((len = fis.read(cBuffer)) != -1) {
            String str = new String(cBuffer, 0, len);
            System.out.println(str);
        }
        fis.close();
    }
}
  • 方法正常返回

    • ireturn

      image-20200728184852735

    • dreturn

      image-20200728184921891

    • areturn

      image-20200728184954040

  • 異常處理表:

    • 反編譯位元組碼檔案,可得到 Exception table
    • from :位元組碼指令起始地址
    • to :位元組碼指令結束地址
    • target :出現異常跳轉至地址爲 11 的指令執行
    • type :捕獲異常的型別

image-20200728185306587

10、一些附加資訊

棧幀中還允許攜帶與Java虛擬機器實現相關的一些附加資訊。例如:對程式偵錯提供支援的資訊。

11、棧相關面試題

舉例棧溢位的情況?(StackOverflowError)

通過 -Xss 設定棧的大小

調整棧大小,就能保證不出現溢位麼?

不能保證不溢位

分配的棧記憶體越大越好麼?

不是,一定時間內降低了OOM概率,但是會擠佔其它的執行緒空間,因爲整個虛擬機器的記憶體空間是有限的

垃圾回收是否涉及到虛擬機器棧?

不會

方法中定義的區域性變數是否執行緒安全?

何爲執行緒安全?

  1. 如果只有一個執行緒纔可以操作此數據,則必是執行緒安全的。
  2. 如果有多個執行緒操作此數據,則此數據是共用數據。如果不考慮同步機制 機製的話,會存線上程安全問題。

具體問題具體分析:

  • 如果物件是在內部產生,並在內部消亡,沒有返回到外部,那麼它就是執行緒安全的,反之則是執行緒不安全的。

  • 看程式碼
/**
 * 面試題:
 * 方法中定義的區域性變數是否執行緒安全?具體情況具體分析
 *
 *   何爲執行緒安全?
 *      如果只有一個執行緒纔可以操作此數據,則必是執行緒安全的。
 *      如果有多個執行緒操作此數據,則此數據是共用數據。如果不考慮同步機制 機製的話,會存線上程安全問題。
 * @author shkstart
 * @create 2020 下午 7:48
 */
public class StringBuilderTest {
    //s1的宣告方式是執行緒安全的
    public static void method1(){
        //StringBuilder:執行緒不安全
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
        //...
    }
    //sBuilder通過參數傳遞方法內,存線上程不安全的問題
    public static void method2(StringBuilder sBuilder){
        sBuilder.append("a");
        sBuilder.append("b");
        //...
    }
    //操作s1之後,將s1作爲返回值返回,存線上程不安全的問題
    public static StringBuilder method3(){
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
        return s1;
    }
    //s1的操作:是執行緒安全的
    public static String method4(){
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
        return s1.toString();
    }

    public static void main(String[] args) {
        StringBuilder s = new StringBuilder();
        new Thread(() -> {
            s.append("a");
            s.append("b");
        }).start();
        method2(s);
    }
}

執行時數據區,哪些部分存在Error和GC?

執行時數據區 是否存在Error 是否存在GC
程式計數器
虛擬機器棧 是(SOF)
本地方法棧
方法區 是(OOM)
是(OOM)