1. 程式人生 > 其它 >JVM上篇:虛擬機器棧

JVM上篇:虛擬機器棧

虛擬機器棧

1.背景

前面講過,因為Java虛擬機器想要做跨平臺的設計,而基於暫存器的結構對不同的CPU是不同的,所以Java的指令都是根據棧來設計的。

1.1優點

  • 跨平臺
  • 指令集小
  • 編譯器實現比較容易

1.2缺點

  • 效能下降
  • 指令更多

2.記憶體中棧和堆的介紹

  • 棧:解決程式執行時的問題,即程式如何執行,如何處理資料
  • 堆:解決的是資料儲存的問題,即資料怎麼放,往哪兒放

2.1舉例

下圖為廣東一道名菜,“佛跳強”的製作方式以及圖片,以紅色的線為分割,左邊我們可以理解為棧區,食材明細可以理解為變數,而做法步驟則理解為操作指令。右邊理解為堆區,用來解決存放食材,以及食材怎麼放的問題。

3.Java虛擬機器棧介紹

3.1概述

Java虛擬機器棧(Java Virtual Machine Stack),每個執行緒在建立的時候,都會建立一個虛擬機器棧,內部儲存著一個一個的棧幀Stack Frame(棧裡儲存資料的基本單元),一個棧幀對應著一個Java方法,棧幀裡面又包含區域性變量表等等。

3.2生命週期

虛擬機器棧的生命週期與執行緒的建立而建立,與執行緒的消亡而消亡

3.3棧的作用

主管Java程式的執行,它儲存方法的區域性變數(8種基本資料型別,物件的引用地址(引用資料物件真正的資料是存在堆空間中的))、部分結果,並參與方法的呼叫和返回。

  • 區域性變數 與 成員變數 的區分
  • 基本資料型別的變數 與 引用型別的變數(類,陣列,介面)

3.4棧幀舉例畫圖

解釋:在執行方法A的時候,方法A入棧,方法A中又呼叫方法B,所以方法B入棧變為當前方法,方法B結束後,出棧,然後方法A變為當前方法,方法A結束後,程式結束。

3.5虛擬機器棧的優點

  • 棧是一種快速有效的分配儲存方式,訪問速度僅次於程式計數器
  • JVM對Java棧的操作只有2個
    • 每個方法執行,伴隨著入棧
    • 執行結束後的出棧工作
  • 棧不存在垃圾回收(GC)問題(會OOM哦)

4.虛擬機器棧可能會出現的異常

Java 虛擬機器規範中指出,允許Java棧的大小是動態的或者是固定不變的

4.1棧溢位

如果採用固定的大小的Java虛擬機器棧,那每一個執行緒的Java虛擬機器棧容量可以線上程建立的時候獨立選定,如果執行緒請求分配棧的容量超過Java虛擬機器棧允許的最大容量,這個時候Java虛擬機器將會丟擲一個StackOverflowError

異常。

4.2記憶體溢位

  • 如果Java虛擬機器棧是動態擴充套件的,如果在嘗試擴充套件的時候無法申請到足夠的記憶體,Java虛擬機器將會丟擲一個OutOfMemoryError異常。
  • 如果在建立新的執行緒的時候,沒有足夠的記憶體去建立對應的虛擬機器棧,Java虛擬機器將會丟擲一個OutOfMemoryError異常。

4.3舉例棧溢位

寫一個簡單的沒有出口的遞迴方法,會導致棧中出現非常多的棧幀,每個裡面都存了很多的區域性變數等等,最後導致了StackOverflowError的異常。

public class StackErrorTest {
    public static void main(String[] args) {
        main(args);
    }
}

結果,StackOverflowError

Exception in thread "main" java.lang.StackOverflowError
	at test.StackErrorTest.main(StackErrorTest.java:5)
	at test.StackErrorTest.main(StackErrorTest.java:5)
	...

5.設定棧記憶體引數

-Xss[size][unit]
例:-Xss256k

可以使用引數-Xss 來設定執行緒的最大棧空間,棧的大小直接決定了函式呼叫的最大可達深度。

public class StackDeepTest{ 
    private static int count=0; 
    public static void recursion(){
        count++; 
        recursion(); 
    }
    public static void main(String args[]){
        try{
            recursion();
        } catch (Throwable e){
            System.out.println("deep of calling="+count); 
            e.printstackTrace();
        }
    }
}

6.棧的儲存單位

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

6.1棧執行原理

  • JVM對Java虛擬機器的操作只有2個,一個是對棧幀的壓棧(入棧),另一個是出棧
  • 一個執行緒的某一個時間點上,只會有一個活動的棧幀,即是當前棧幀(current Frame),對應的也就是當前正在執行的方法,此方法稱為當前方法,定義這個方法的類,就是當前類(current class)
  • 執行引擎執行的所有位元組碼指令都是隻針對當前棧幀進行解釋執行
  • 如果a方法中呼叫了b方法,則b方法對應的棧幀會被創建出來,放在Java虛擬機器棧的頂端,成為當前棧幀。
  • 不同執行緒中所包含的棧幀是不允許存在互相引用的,執行緒私有的
  • 如果當前方法呼叫了其他方法,方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接著虛擬機器會丟棄當前棧幀,使得前一個棧幀重新成為當前棧幀
  • Java方法有兩種返回函式的方式,一種是使用return指令(無返回值的return指令可以忽略,虛擬機器會幫我們加上),另一種是丟擲異常

6.2舉例DEBUG過程,演示棧幀(IDEA)

我們寫下面一段程式,有3個方法,互相呼叫

public class StackFramesTest {
    public static void main(String[] args) {
        StackFramesTest.method1();
    }
    public static void method1(){
        System.out.println("");
        method2();
    }
    public static void method2(){
        System.out.println("");
        method3();
    }
    public static void method3(){
        System.out.println("");
    }
}

使用IDEA,在method3中打一個斷點,進行DEBUG,檢視IDEA中顯示的Frames的情況,method3為當前棧幀,可以看到方法的呼叫關係。

當我們結束method3,回到method2 的時候,mehtod3的棧幀就會被丟棄,method2成為當前方法,對應著當前棧幀。

6.3棧幀的內部結構

每個棧幀中儲存著

  • 區域性變量表(Local Variables)
  • 運算元棧(Operand Stack)(或表示式棧)
  • 動態連結(Dynamic Linking)(或指向執行時常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者異常退出的定義)
  • 一些附加資訊

並行每個執行緒下的棧都是私有的,因此每個執行緒都有自己各自的棧,並且每個棧裡面都有很多棧幀,棧幀的大小主要是由區域性變量表(主要是變數的宣告等等)和運算元棧(主要是程式碼的複雜程度)來決定的。

7.區域性變量表(Local Variables)

7.1介紹

區域性變量表也被稱為區域性變數陣列或本地變量表

  • 定義為一個數字陣列,主要用於儲存方法引數和定義在方法體內的區域性變數,這些資料型別包括各類基本資料型別,物件引用(reference),以及returnAddress型別.
  • 由於區域性變量表是方法內部的,是建立線上程的棧上,是執行緒私有的資料,因此不存在資料安全問題
  • 區域性變量表所需要陣列長度的大小是在編譯期間就確定下來了,並且儲存在方法的Code屬性的maximum local variables資料項中,方法執行期間是不會改變區域性變量表的陣列大小的。
  • 方法巢狀的深度由棧的大小決定,這個就比較簡單了
  • 區域性變量表的變數只在當前方法中有效,在方法執行時,虛擬機器通過使用區域性變量表完成引數值到引數變數列表的傳遞過程。當方法呼叫結束後,隨著方法棧幀的銷燬,區域性變量表也會隨之銷燬。

7.2位元組碼中方法內部結構刨析

舉例

對這段程式碼,先編譯為class檔案,再對class檔案使用javap反編譯位元組碼指令

public class LocalVariables {
    public static void main(String[] args) {
        int i = 10;
        LocalVariables localVariables = new LocalVariables();
        System.out.println(i);
    }
}

7.3slot介紹

  • Slot是區域性變量表的基本儲存單元
  • 區域性變量表中存放編譯期可知的各種基本資料型別,引用資料型別(reference),retrunAddress型別的變數。
  • 在區域性變量表裡,32位以內的型別只佔用一個Slot(引用資料型別,returnAddress型別,byte、short、char在儲存前被轉換為int,boolean也被轉換為int,0表示false,非0表示true),64位的型別(long和double)佔據兩個Slot
  • JVM會給區域性變量表裡的每個Slot都分配一個索引,通過此索引即可成功訪問到區域性變量表中指定的區域性變數值
  • 當一個例項方法被呼叫的時候,它的方法引數和該方法內部定義的區域性變數將會按照變數宣告的順序,被複制到區域性變量表的每一個Slot上。
  • 如果需要訪問區域性變量表中一個64bit的區域性變數時候,只需要使用前一個索引即可
  • 如果當前幀是由構造方法或者是例項方法建立的,那麼此物件的引用(this)將會存放在index位0的Slot處,剩下的按照順序繼續排列。這也就是為什麼在非靜態方法裡可以使用this.xx這樣使用

7.4Slot的重複利用

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

public class SlotTest {
    public void test4() {
        int a = 0;
        {
            int b = 0;
            b = a + 1;
        }
        int c = a + 1;
    }
    //這段程式碼編譯後的區域性變量表的長度就會是3,因為c會複用b的槽位
}

7.6變數的分類複習

按照資料型別分類

  • 基本資料型別
  • 引用資料型別

按照在類中宣告的位置分類

  • 成員變數:在使用前,都經歷過初始化複製。
    • 類變數(靜態變數):Linking的prepare階段,給變數預設複製。inital...階段,為變數顯示賦值
    • 例項變數:跟隨著物件的建立,會在堆空間中分配例項的變數空間,並進行預設賦值
  • 區域性變數:在使用前必須要進行顯示賦值
public void test(){
    int i;
    System. out. println(i);//這行程式碼會編譯的時候就報錯
}

7.7補充說明(調優)

在棧幀中,與效能調優關係最密切的部分就是區域性變量表。

  • 在方法執行的時候,虛擬機器使用區域性變量表完成方法的傳遞。
  • 區域性變量表中的變數也是最重要的垃圾回收根節點,只要被區域性變量表中直接引用或者間接引用的物件,都不會被回收

8.運算元棧(Operand Stack)

每一個獨立的棧幀中除了包含區域性變量表之外,還包含一個先進後出的運算元棧,也可以稱為表示式棧(Expression Stack)

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

  • 某些位元組碼指令會將值壓入運算元棧,其餘的位元組碼指令將運算元棧中的值取出,使用過它們以後,再把結果壓棧。
  • 比如:執行賦值,交換,求和等操作
  • 運算元棧,主要用於儲存計算過程的中間結果,同時作為計算過程中變數臨時的儲存空間
  • 運算元棧就是JVM執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也會隨之被創建出來,一創建出來的時候,這個方法的運算元棧,包括區域性變量表等都是空的
  • Java程式碼在編譯後,每一個運算元棧都有明確的棧深度用於儲存資料,運算元棧的最大深度,區域性變量表陣列的長度,均是在編譯期間就定義好了,儲存在方法的Code屬性中
  • 棧中的任何一個元素都可以是任意的Java資料型別
    • 32位的型別佔用一個棧單位深度
    • 64位的型別佔用兩個棧單位深度
  • 運算元棧並非採用訪問索引的方式來訪問資料,而是隻能通過標準的入棧(push)出棧(pop)操作來完成一次資料的訪問。
  • 如果被呼叫的方法帶有返回值的話,其返回值將會被壓入當前棧幀的運算元棧中,並更新PC暫存器中下一條需要執行的位元組碼指令。
  • 運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,這由編譯器在編譯器期間進行驗證,同時在類載入過程中的類檢驗階段的資料流分析階段要再次驗證。
  • 另外,我們說Java虛擬機器的解釋引擎是基於棧的執行引擎,其中的棧指的就是運算元棧。

8.1程式碼追蹤

public class OperandStack {
    public static void main(String[] args) {
        byte i = 15;
        int j =8;
        int k = i + j;
    }
}

使用javap 命令反編譯class檔案:javap -v 類名.class

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1     
         0: bipush        15             //往運算元棧中存入byte型別的15,存為int型別
         2: istore_1                     //從運算元棧中取出int型別的15放入區域性變量表為1的位置
         3: bipush        8              //8入棧
         5: istore_2                     //出棧一個,放入區域性變量表2的位置
         6: iload_1                      //載入區域性變量表1的位置,併入棧
         7: iload_2                      //載入區域性變量表2的位置,入棧
         8: iadd                         //取出棧中的數字求和,然後再將結果入棧
         9: istore_3                     //出棧一個,放入區域性變量表3的位置
        10: return                       //無返回值,結束                
 //區域性變量表
  LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  args   [Ljava/lang/String;
            3       8     1     i   B
            6       5     2     j   I
           10       1     3     k   I

8.2棧頂快取技術

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

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