1. 程式人生 > >【深入理解 Java 虛擬機器筆記】虛擬機器位元組碼執行引擎

【深入理解 Java 虛擬機器筆記】虛擬機器位元組碼執行引擎

7.虛擬機器位元組碼執行引擎

執行引擎是 Java 虛擬機器最核心的組成部分之一。在 Java 虛擬機器規範中制定了虛擬機器位元組碼執行引擎的概念模型,這個概念模型成為各種虛擬機器執行引擎的統一外觀(Facade)。不同的虛擬機器實現,執行引擎可能會有解釋執行和編譯執行兩種,有可能兩種兼備。

從外觀來說,所有的 JVM 執行引擎都一樣:輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果。

執行時棧幀結構

棧幀(Stack Frame)是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中的虛擬機器棧(Virtual Machine Stack)的棧元素。棧幀儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址等資訊。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。

在編譯程式程式碼的時候,棧幀需要多大的區域性變量表,多深的運算元棧都已經完全確定了,並且寫入方法表的 Code 屬性中。

只有棧頂的棧幀才是有效的,稱為當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法稱為當前方法(Current Method)。執行引擎的所有位元組碼指令都只針對當前棧幀進行操作。

區域性變量表

區域性變量表(Local Variable Table)是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。在方法的 Code 屬性的 max_locals 資料項中確定了該方法所需要分配的區域性變量表的最大容量。

區域性變量表的容量以變數槽

(Variable Slot,下稱 Slot)為最小單位,虛擬機器規範中並沒有指明一個 Slot 佔用的記憶體空間,但導向性地說道每個 Slot 都應該能存放一個 boolean、byte、char、short、int、float、reference 或 returnAddress 型別的資料。它允許 Slot 的長度隨著處理器、作業系統或虛擬機器不同而變化。

其中第七種 reference 型別表示對一個物件例項的引用,虛擬機器規範沒有說明它的長度,但虛擬機器實現至少能通過這個引用:

  1. 從此引用中直接或間接地查詢到物件在 Java 堆中的資料存放的起始地址索引
  2. 通過該引用直接或間接查詢到物件所屬資料型別在方法區中儲存的型別資訊

而第八種 returnAddress 已經很少見了,它是為位元組碼指令 jsr、jsr_w 和 ret 服務的。

對於 64 位的資料型別,虛擬機器使用高位對齊的方式分配兩個連續的 Slot 空間。Java 明確的 64 位資料型別只有 long 和 double 兩種。

虛擬機器通過索引定位的方式使用區域性變量表,索引值是從 0 開始到區域性變量表最大的 Slot 數量。如果是 32 位資料型別的變數,索引 n 就代表使用第 n 個 slot;而如果是 64 位資料型別,則說明會同時使用 n 和 n+1 兩個 Slot,並且不允許單獨訪問其中某一個 Slot。

在方法執行時,虛擬機器是使用區域性變量表來完成引數值到引數變數列表的傳遞過程的,如果執行的是例項方法,第 0 位索引的 Slot 預設為當前例項的引用,通過“this”來訪問。

區域性變量表的 Slot 可以重用,方法體中定義的常量,作用域並不一定會覆蓋整個方法體, 如果當前位元組碼 PC 計數器的值已經超出了某個變數的作用域,那這個變數對應的 Slot 就可以交給其他變數使用。

可能還會有額外的副作用:

public static void main(String[] args) {
   {
      byte[] placeholder = new byte[64 * 1024 * 1024];
   }
   System.gc();
}

placeholder 的作用域被限制在花括號中,執行 System.gc() 後,記憶體卻沒有被回收,結果如下:

[GC (System.gc())  68869K->66304K(125952K), 0.0017614 secs]
[Full GC (System.gc())  66304K->66200K(125952K), 0.0071574 secs]

改動一下:

public static void main(String[] args) {
   {
      byte[] placeholder = new byte[64 * 1024 * 1024];
   }
   int a = 0;
   System.gc();
}

此時的執行結果:

[GC (System.gc())  68869K->66320K(125952K), 0.0014657 secs]
[Full GC (System.gc())  66320K->664K(125952K), 0.0071680 secs]

這是因為在未修改的例子中,程式碼離開了 placeholder 的作用域,但並沒有對區域性變量表的讀寫操作,placeholder 原本佔用的 Slot 並沒有被複用,所以作為 GC Roots 一部分的區域性變量表仍然保持著對它的關聯。所以手動將其設為 null(用來代替 int a = 0),便不見得是絕對無意義的操作,但作者的觀點是並不應當對賦 null 值太多依賴,因為賦 null 值可能會被 JIT 編譯優化後被消除掉。

類變數有兩次賦值過程,一次是準備階段,賦予系統初始值;另一次在初始化階段,賦予程式設計師設定的初始值(通過執行 <clinit>()方法)。但區域性變數不相同,如果不賦初始值,編譯器會檢測出並提示。

運算元棧

運算元棧(Operator Stack)也稱為操作棧,是後入先出(Last In First Out,LIFO)棧。其最大深度在編譯的時候寫入到 Code 屬性的 max_stacks 資料項中。運算元棧的元素可以是任意的 Java 資料型別。32位資料型別佔容量為1,64 位佔容量 2。在方法執行過程中,會有各種位元組碼往運算元棧中寫入和提取內容,例如,在做算術運算的時候是通過運算元棧來進行的。運算元棧的元素資料型別必須與位元組碼指令的序列嚴格匹配,例如,iadd 指令只能用於整數型加法,棧頂兩個元素必須為 int 型。

在大多虛擬機器實現中,令兩個棧幀出現一部分重疊。這樣在方法呼叫時可以共用一部分資料,無需進行額外的引數複製傳遞。Java 虛擬機器的解釋執行引擎是“基於棧的執行引擎”,而棧指的是運算元棧

動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用為了支援方法呼叫過程中的動態連線(Dynamic Linking)。

Class 檔案的常量池存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用作為引數。符號引用的一部分在類載入階段或第一次使用就轉化為直接引用,稱為靜態解析。另外一部分在每一次執行期間轉化為直接引用,稱為動態連線。

方法返回地址

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

  1. 執行引擎遇到任意一個方法返回的位元組碼指令,可能會有返回值傳遞給方法呼叫者,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)。
  2. 在方法執行中遇到異常,並且在方法體沒有處理,無論是虛擬機器內部的異常, 還是程式碼中使用 athrow 位元組碼指令產生的異常,只要在本方法的異常表中沒有對應的異常處理器,都會導致方法退出,這種退出稱為異常完成出口(Abrupt Method Invocation Completion)。並且不會給上層呼叫者產生返回值。

方法退出的過程實際上就等同於把當前棧幀出棧,所以退出時可能的操作有:恢復上層方法的區域性變量表和運算元棧。把返回值(如果有)壓入呼叫者棧幀的運算元棧中,調整 PC 計數器的值以指向方法呼叫指令後的指令等。

附加資訊

虛擬機器規範允許虛擬機器實現增加一些規範中沒有描述的資訊到棧幀中。

方法呼叫

方法呼叫不等同於方法執行,方法呼叫階段的唯一任務是確定被呼叫方法的版本(呼叫哪一個方法),暫時不涉及方法內部的具體執行過程。Java 方法呼叫過程需要在類載入期間甚至到執行期間才能確定方法的直接引用。

解析

所有方法呼叫中的目標方法在 Class 檔案中都是一個常量池中的符號引用。呼叫目標在程式程式碼寫好、編譯器進行編譯時就必須確定下來,這種方法的呼叫稱為解析(Resolution)。

符合“編譯器可知,執行期不變”這個要求的方法,主要包括靜態方法(與型別相關聯)和私有方法(在外部不可被訪問)兩大類,這兩種方法的特點決定它們不可能被重寫其他版本,所以適合在類載入階段進行解析。

Java 虛擬機器提供了 5 條方法呼叫位元組碼:

  1. invokestatic:呼叫靜態方法
  2. invokespcial:呼叫例項構造器 <init> 方法、私有方法和父類方法
  3. invokevirtual:呼叫所有虛方法
  4. invokeinterface:呼叫介面方法,會在執行時確定一個實現此介面的物件
  5. invokedynamic:先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法,前四條指令分派邏輯是固化在 Java 虛擬機器內部的,而 invokedynamic 指令是由使用者所設定的引導方法決定的

只要能被 invokestaticinvokespcial 指令呼叫的方法,都可以在解析階段中確定唯一的呼叫版本,符合這個條件的有靜態方法、私有方法、例項構造器、父類方法 4 類,它們在類載入的時候就會把符號引用解析為該方法的直接引用,這些方法可以稱為非虛方法,與之相反,其他方法稱為虛方法(除去 final 方法,雖然 final 方法也是通過 invokevirtual 指令呼叫,但它無法被覆蓋,所以它多型選擇的結果是唯一的)。

解析呼叫是靜態的過程,編譯期間就完全確定。而分派(Dispatch)呼叫則可能是靜態,有可能是動態,根據分派依據的宗量數可分為單分派和多分派,兩兩組合就構成了靜態單分派、靜態多分派、動態單分派和動態多分派。

分派

分派呼叫過程會揭示多型性特徵的最基本實現,如“重寫”和“過載”。

1.靜態分派

例子:

Human man = new Man();

Human 稱為變數的靜態型別(Static Type),或者叫外觀型別(Apparent Type),後面的 Man 稱為變數的實際型別(Actual Type),靜態型別和實際型別在程式中都可以發生一些變化,靜態型別僅在使用時變化,本身的靜態型別不會改變,並且最終的靜態型別是編譯器可知的;而實際型別變化的結果在執行期才可確定,編譯器不知道一個物件的實際型別是什麼,如:

//實際型別變化
Human man = new Man();
man = new Woman();
//靜態型別變化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

編譯器過載時是通過引數的靜態型別而不是實際型別來作為判定依據的。靜態型別是編譯可知的。因此,在編譯階段, Javac 編譯器會根據引數的靜態型別來決定使用哪個過載版本。

所有依賴靜態型別來定位執行版本的分派動作稱為靜態分派。靜態分派典型應用是方法過載。

過載方法匹配優先順序:

public class Overload {
   public static void sayHello(Object arg) {
      System.out.println("Hello Object!");
   }

   public static void sayHello(int arg) {
      System.out.println("Hello Object!");
   }

   public static void sayHello(long arg) {
      System.out.println("Hello Object!");
   }

   public static void sayHello(Character arg) {
      System.out.println("Hello Object!");
   }

   public static void sayHello(char arg) {
      System.out.println("Hello Object!");
   }

   public static void sayHello(char... arg) {
      System.out.println("Hello Object!");
   }

   public static void sayHello(Serializable arg) {
      System.out.println("Hello Object!");
   }

   public static void main(String[] args) {
      sayHello('a');
   }
}

程式碼執行:

Hello char!

但如果註釋掉 sayHello(char arg),輸出變為:

Hello int!

這次發生了自動型別轉換,'a' 還可以表示數字 97(字元 'a' 的 Unicode 數值為十進位制數字 97),再註釋掉 sayHello(int arg) ,輸出變為:

Hello long!

這時發生了兩次自動型別轉換,'a'轉型為整數 97 後,進一步轉型為長整數 97L 。自動轉型為按照 char - int - long - float - double 的順序轉型進行匹配,但不會匹配到 byte 和 short 型別,因為 char 到 byte 或 short 的轉型並不安全,我們繼續註釋掉 sayHello(long arg),輸出為:

Hello Character!

此時發生了自動裝箱,'a' 被包裝為它的封裝型別 java.lang.Character ,所以匹配到了引數型別為 Character 的過載,繼續註釋 sayHello(Character arg),輸出:

Hello Serializable!

出現這個結果是因為 java.lang.Serializablejava.lang.Character 類實現的一個介面,自動裝箱之後找不到裝箱類,所以找到裝箱類實現的介面型別,接著又發生自動轉型。char 可以轉型為 int,但 Character 不會轉型為 Integer,它只能轉型為它實現的介面或父類。Character 還實現了另外一個介面 java.lang.Comparable<Character>,如果同時出現兩個引數分別是 SerializableComparable<Character> 的過載方法,此時它們優先順序相同,所以會提示型別模糊,拒絕編譯,程式必須在呼叫時顯式指定靜態型別,如 sayHello((Comparable<Character>)'a');,才能編譯通過。

繼續註釋 sayHello(Serializable arg),輸出為:

Hello Object!

此時 char 裝箱後轉型為父類,如果有多個父類,按照繼承關係從下往上搜索,越上層,優先順序越低。即時方法呼叫傳入的引數值為 null 時,這個規則仍然適用。把 sayHello(Object arg) 註釋掉,輸出變為:

Hello char...!

變長引數的過載優先順序是最低的,此時 'a' 當做一個數組元素,但有些在單個引數能成立的自動轉型,如 char 轉型為 int,在變長引數中是不成立的。

解析與分派之間的關係並不是二選一的排他關係,它們是在不同層次上去選擇、確定目標方法的過程。靜態方法會在類載入期就進行解析,但靜態方法也可以擁有過載方法,選擇過載版本的過程也是通過靜態分派完成的。

2.動態分派

動態分派和重寫(Override)有很密切的關係。

舉個栗子:

public class DynamicDispatch {
   static abstract class Human {
      protected abstract void sayHello();
   }

   static class Man extends Human {

      @Override
      protected void sayHello() {
         System.out.println("Man Say Hello!");
      }
   }

   static class Woman extends Human {

      @Override
      protected void sayHello() {
         System.out.println("Woman Say Hello!");
      }
   }

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

其執行結果:

Man Say Hello!
Woman Say Hello!
Woman Say Hello!

顯然這裡不是依據靜態型別來決定, 因為靜態型別同樣都是 Human 的兩個變數 man 和 woman 在呼叫 sayHello() 方法時,執行了不同的行為,並且變數 man 在兩次呼叫中執行了不同的方法。原因很明顯, 兩個變數的實際型別不同。

其原因是 invokevirtual 指令的多型查詢過程,其解析過程大致分為:

  1. 找到運算元棧頂的第一個元素所指向的物件的實際型別,記作 C
  2. 如果在型別 C 中找到與常量中描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;否則返回 java.lang.IllegalAccessError 異常。
  3. 否則,按照繼承關係從下往上對 C 的各個父類進行第 2 步的搜尋和驗證過程
  4. 如果還是沒找到,則丟擲 java.lang.AbstractMethodError 異常

由於 invokevirtual 指令執行的第一步就是在執行期確定接收者的實際型別,所以指令都把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是 Java 重寫的本質。這種在執行期根據實際型別來確定方法之行版本的分派過程稱為動態分派

3.單分派與多分派

方法的接收者與方法的引數稱為方法的宗量,根據分派基於多少種宗量,可以把分派分為單分派與多分派。單分派是根據一個宗量來對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。

一個例子:

public class Dispatch {
   static class A {
   }

   static class B {
   }

   public static class Father {
      public void hardChoice(A args) {
         System.out.println("Father choose A");
      }

      public void hardChoice(B args) {
         System.out.println("Father choose B");
      }
   }

   public static class Son extends Father {
      @Override
      public void hardChoice(A args) {
         System.out.println("Son choose A");
      }

      @Override
      public void hardChoice(B args) {
         System.out.println("Son choose B");
      }
   }

   public static void main(String[] args) {
      Father father = new Father();
      Father son = new Son();
      father.hardChoice(new A());
      son.hardChoice(new B());
   }
}

執行結果:

Father choose A
Son choose B

先看編譯期間編譯器的選擇過程(靜態分派過程),此時選擇目標方法的依據:

  1. 靜態型別是 Father 還是 Son
  2. 方法引數是 A 還是 B

選擇結果的產物是產生了兩條 invokevirtual 指令,兩條指令的引數分別為常量池中指向 Father.hardChoice(A)Father.hardChoice(B) 的符號引用,因為是根據兩個宗量來進行選擇,所以 Java 的靜態分派是屬於多分派型別

再看執行階段虛擬機器的選擇(動態分派過程)。在執行 son.hardChoice(new B()) 這句程式碼所對應的 invokevirtual 指令時,由於編譯期已經決定目標方法簽名必須是 hardChoice(new B()),所以虛擬機器並不會關心傳遞過來的引數,因為引數的靜態型別和實際型別都對方法的選擇不會構成影響,唯一可以影響的只有方法的接收者的實際型別。只有一個宗量可以作為選擇依據,所以 Java 的動態分派屬於單分派型別

所以 Java 是一門靜態多分派、動態單分派的語言。

4.虛擬機器動態分派的實現

由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在類的方法元資料中搜索,因此在虛擬機器實現中基於效能的考慮,最常用的“穩定優化“手段就是為類在方法區中建立一個虛方法表(Virtual Method Table,也稱 vtable,與此對應的,在 invokeinterface 執行時也會用到介面方法表 Interface Method Table,簡稱 itable),使用方法表索引來代替元資料查詢以提高效能。

虛方法表中存放著各個方法的實際入口地址。如果方法沒有被重寫,子類的虛方法表地址入口和父類相同方法的入口一致,而子類重寫,地址入口會被替換成子類實現版本的入口地址。

方法表一般在類載入的連線階段進行初始化,準備了類的變數初始值後,虛擬機器會把該類的方法表也初始化完畢。

虛擬機器除了使用方法表以外,還會使用內聯快取(Inline Cache)和基於“型別繼承關係分析”(Class Hierarchy Analysis,CHA)技術的守護內聯(Guarded Inlining)兩種非穩定的“激進優化”手段。

基於棧的位元組碼解釋執行引擎

Java 虛擬機器的執行引擎在執行 Java 程式碼的時候都有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生代碼執行)兩種選擇。

解釋執行

只有確定了談論物件是某種具體的 Java 實現版本和執行引擎執行模式時,談解釋執行還是編譯執行才會比較確切。在 Java 語言中,Javac 編譯器完成了程式程式碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流過程。這一部分的動作是在 Java 虛擬機器之外進行的,而直譯器在虛擬機器內部,所以 Java 程式的編譯是半獨立的實現。

基於棧的指令集和基於暫存器的指令集

Java 編譯器輸出的指令流,基本上是一種基於棧的指令集架構(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它們依賴運算元棧進行工作。與此相對的是基於暫存器的指令集,最典型的就是 x86 的二進位制指令集,也就是我們主流 PC 機直接支援的指令集架構,依賴暫存器進行工作。

舉個例子,用兩種指令集計算“1+1”的結果,基於棧:

iconst_1
iconst_1
iadd
istore_0

兩條 iconst_1 連續把兩個常量 1 壓入棧後,iadd把棧頂兩個值出棧、相加,然後把結果放回棧,最後 istore_0 把棧頂的值放到區域性變量表的第 0 個 Slot 中,而基於暫存器:

mov eax,1
add eax,1

mov 指令把 eax 暫存器的值設為 1,然後 add 指令將這個值加 1,結果儲存在 eax 暫存器中。

基於棧的指令集主要優點在於可移植,因為暫存器由硬體直接提供,程式直接依賴這些硬體暫存器則不可避免地受到硬體的約束。棧架構的指令集還有程式碼相對更加緊湊、編譯器實現更加簡單等優點。

但棧架構指令集的主要缺點是執行速度相對來說會稍慢一點。

基於棧的直譯器執行過程

準備一段 Java 程式碼:

public int calc() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
}

javap 命令檢視它的位元組碼指令:

 public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1
        12: iload_2
        13: iadd
        14: iload_3
        15: imul
        16: ireturn
}

本章小結

本章中,分析了虛擬機器在執行程式碼時,如何找到正確的方法,如何執行方法內的位元組碼,以及執行程式碼時涉及的記憶體結構。