1. 程式人生 > 其它 >05 - JVM 是如何執行方法呼叫的?(下)

05 - JVM 是如何執行方法呼叫的?(下)

我在讀博士的時候,最怕的事情就是被問有沒有新的 Idea。有一次我被老闆問急了,就隨口說了一個。

這個 Idea 究竟是什麼呢,我們知道,設計模式大量使用了虛方法來實現多型。但是虛方法的效能效率並不高,所以我就說,是否能夠在此基礎上寫篇文章,評估每一種設計模式因為虛方法呼叫而造成的效能開銷,並且在文章中強烈譴責一下?

當時呢,我老闆教的是一門高階程式設計的課,其中有好幾節課剛好在講設計模式的各種好處。所以,我說完這個 Idea,就看到老闆的神色略有不悅了,臉上寫滿了“小鄭啊,你這是捨本逐末啊”,於是,我就連忙挽尊,說我是開玩笑的。

在這裡呢,我犯的錯誤其實有兩個。第一,我不應該因為虛方法的效能效率,而放棄良好的設計。第二,通常來說,Java 虛擬機器中虛方法呼叫的效能開銷並不大,有些時候甚至可以完全消除。第一個錯誤是原則上的,這裡就不展開了。至於第二個錯誤,我們今天便來聊一聊 Java 虛擬機器中虛方法呼叫的具體實現。

首先,我們來看一個模擬出國邊檢的小例子。

abstract class Passenger {
  abstract void passThroughImmigration();
  @Override
  public String toString() { ... }
}
class ForeignerPassenger extends Passenger {
   @Override
   void passThroughImmigration() { /* 進外國人通道 */ }
}
class ChinesePassenger extends Passenger {
  @Override
  void passThroughImmigration() { /* 進中國人通道 */ }
  void visitDutyFreeShops() { /* 逛免稅店 */ }
}

Passenger passenger = ...
passenger.passThroughImmigration();

這裡我定義了一個抽象類,叫做 Passenger,這個類中有一個名為 passThroughImmigration 的抽象方法,以及重寫自 Object 類的 toString 方法。

然後,我將 Passenger 粗暴地分為兩種:ChinesePassenger 和 ForeignerPassenger。

兩個類分別實現了 passThroughImmigration 這個方法,具體來說,就是中國人走中國人通道,外國人走外國人通道。由於咱們儲蓄較多,所以我在 ChinesePassenger 這個類中,還特意添加了一個叫做 visitDutyFreeShops 的方法。

那麼在實際執行過程中,Java 虛擬機器是如何高效地確定每個 Passenger 例項應該去哪條通道的呢?我們一起來看一下。

1. 虛方法呼叫

在上一篇中我曾經提到,Java 裡所有非私有例項方法呼叫都會被編譯成 invokevirtual 指令,而介面方法呼叫都會被編譯成 invokeinterface 指令。這兩種指令,均屬於 Java 虛擬機器中的虛方法呼叫。

在絕大多數情況下,Java 虛擬機器需要根據呼叫者的動態型別,來確定虛方法呼叫的目標方法。這個過程我們稱之為動態繫結。那麼,相對於靜態繫結的非虛方法呼叫來說,虛方法呼叫更加耗時。

在 Java 虛擬機器中,靜態繫結包括用於呼叫靜態方法的 invokestatic 指令,和用於呼叫構造器、私有例項方法以及超類非私有例項方法的 invokespecial 指令。如果虛方法呼叫指向一個標記為 final 的方法,那麼 Java 虛擬機器也可以靜態繫結該虛方法呼叫的目標方法。

Java 虛擬機器中採取了一種用空間換取時間的策略來實現動態繫結。它為每個類生成一張方法表,用以快速定位目標方法。那麼方法表具體是怎樣實現的呢?

2. 方法表

在介紹那篇類載入機制的連結部分中,我曾提到類載入的準備階段,它除了為靜態欄位分配記憶體之外,還會構造與該類相關聯的方法表。

這個資料結構,便是 Java 虛擬機器實現動態繫結的關鍵所在。下面我將以 invokevirtual 所使用的虛方法表(virtual method table,vtable)為例介紹方法表的用法。invokeinterface 所使用的介面方法表(interface method table,itable)稍微複雜些,但是原理其實是類似的。

方法表本質上是一個數組,每個陣列元素指向一個當前類及其祖先類中非私有的例項方法。

這些方法可能是具體的、可執行的方法,也可能是沒有相應位元組碼的抽象方法。方法表滿足兩個特質:其一,子類方法表中包含父類方法表中的所有方法;其二,子類方法在方法表中的索引值,與它所重寫的父類方法的索引值相同。

我們知道,方法呼叫指令中的符號引用會在執行之前解析成實際引用。對於靜態繫結的方法呼叫而言,實際引用將指向具體的目標方法。對於動態繫結的方法呼叫而言,實際引用則是方法表的索引值(實際上並不僅是索引值)。

在執行過程中,Java 虛擬機器將獲取呼叫者的實際型別,並在該實際型別的虛方法表中,根據索引值獲得目標方法。這個過程便是動態繫結。

在我們的例子中,Passenger 類的方法表包括兩個方法:

  • toString
  • passThroughImmigration

它們分別對應 0 號和 1 號。之所以方法表調換了 toString 方法和 passThroughImmigration 方法的位置,是因為 toString 方法的索引值需要與 Object 類中同名方法的索引值一致。為了保持簡潔,這裡我就不考慮 Object 類中的其他方法。

ForeignerPassenger 的方法表同樣有兩行。其中,0 號方法指向繼承而來的 Passenger 類的 toString 方法。1 號方法則指向自己重寫的 passThroughImmigration 方法。

ChinesePassenger 的方法表則包括三個方法,除了繼承而來的 Passenger 類的 toString 方法,自己重寫的 passThroughImmigration 方法之外,還包括獨有的 visitDutyFreeShops 方法。

Passenger passenger = ...
passenger.passThroughImmigration();

這裡,Java 虛擬機器的工作可以想象為導航員。每當來了一個乘客需要出境,導航員會先問是中國人還是外國人(獲取動態型別),然後翻出中國人 / 外國人對應的小冊子(獲取動態型別的方法表),小冊子的第 1 頁便寫著應該到哪條通道辦理出境手續(用 1 作為索引來查詢方法表所對應的目標方法)。

實際上,使用了方法表的動態繫結與靜態繫結相比,僅僅多出幾個記憶體解引用操作:訪問棧上的呼叫者,讀取呼叫者的動態型別,讀取該型別的方法表,讀取方法表中某個索引值所對應的目標方法。相對於建立並初始化 Java 棧幀來說,這幾個記憶體解引用操作的開銷簡直可以忽略不計。

那麼我們是否可以認為虛方法呼叫對效能沒有太大影響呢?

其實是不能的,上述優化的效果看上去十分美好,但實際上僅存在於解釋執行中,或者即時編譯程式碼的最壞情況中。這是因為即時編譯還擁有另外兩種效能更好的優化手段:內聯快取(inlining cache)和方法內聯(method inlining)。下面我便來介紹第一種內聯快取。

3. 內聯快取

內聯快取是一種加快動態繫結的優化技術。它能夠快取虛方法呼叫中呼叫者的動態型別,以及該型別所對應的目標方法。在之後的執行過程中,如果碰到已快取的型別,內聯快取便會直接呼叫該型別所對應的目標方法。如果沒有碰到已快取的型別,內聯快取則會退化至使用基於方法表的動態繫結。

在我們的例子中,這相當於導航員記住了上一個出境乘客的國籍和對應的通道,例如中國人,走了左邊通道出境。那麼下一個乘客想要出境的時候,導航員會先問是不是中國人,是的話就走左邊通道。如果不是的話,只好拿出外國人的小冊子,翻到第 1 頁,再告知查詢結果:右邊。

在針對多型的優化手段中,我們通常會提及以下三個術語。

  1. 單態(monomorphic)指的是僅有一種狀態的情況。
  2. 多型(polymorphic)指的是有限數量種狀態的情況。二態(bimorphic)是多型的其中一種。
  3. 超多型(megamorphic)指的是更多種狀態的情況。通常我們用一個具體數值來區分多型和超多型。在這個數值之下,我們稱之為多型。否則,我們稱之為超多型。

對於內聯快取來說,我們也有對應的單態內聯快取、多型內聯快取和超多型內聯快取。單態內聯快取,顧名思義,便是隻快取了一種動態型別以及它所對應的目標方法。它的實現非常簡單:比較所快取的動態型別,如果命中,則直接呼叫對應的目標方法。

多型內聯快取則快取了多個動態型別及其目標方法。它需要逐個將所快取的動態型別與當前動態型別進行比較,如果命中,則呼叫對應的目標方法。

一般來說,我們會將更加熱門的動態型別放在前面。在實踐中,大部分的虛方法呼叫均是單態的,也就是隻有一種動態型別。為了節省記憶體空間,Java 虛擬機器只採用單態內聯快取。

前面提到,當內聯快取沒有命中的情況下,Java 虛擬機器需要重新使用方法表進行動態繫結。對於內聯快取中的內容,我們有兩種選擇。一是替換單態內聯快取中的紀錄。這種做法就好比 CPU 中的資料快取,它對資料的區域性性有要求,即在替換內聯快取之後的一段時間內,方法呼叫的呼叫者的動態型別應當保持一致,從而能夠有效地利用內聯快取。

因此,在最壞情況下,我們用兩種不同型別的呼叫者,輪流執行該方法呼叫,那麼每次進行方法呼叫都將替換內聯快取。也就是說,只有寫快取的額外開銷,而沒有用快取的效能提升。

另外一種選擇則是劣化為超多型狀態。這也是 Java 虛擬機器的具體實現方式。處於這種狀態下的內聯快取,實際上放棄了優化的機會。它將直接訪問方法表,來動態繫結目標方法。與替換內聯快取紀錄的做法相比,它犧牲了優化的機會,但是節省了寫快取的額外開銷。

具體到我們的例子,如果來了一隊乘客,其中外國人和中國人依次隔開,那麼在重複使用的單態內聯快取中,導航員需要反覆記住上個出境的乘客,而且記住的資訊在處理下一乘客時又會被替換掉。因此,倒不如一直不記,以此來節省腦細胞。

雖然內聯快取附帶內聯二字,但是它並沒有內聯目標方法。這裡需要明確的是,任何方法呼叫除非被內聯,否則都會有固定開銷。這些開銷來源於儲存程式在該方法中的執行位置,以及新建、壓入和彈出新方法所使用的棧幀。

對於極其簡單的方法而言,比如說 getter/setter,這部分固定開銷佔據的 CPU 時間甚至超過了方法本身。此外,在即時編譯中,方法內聯不僅僅能夠消除方法呼叫的固定開銷,而且還增加了進一步優化的可能性,我們會在專欄的第二部分詳細介紹方法內聯的內容。

總結與實踐

今天我介紹了虛方法呼叫在 Java 虛擬機器中的實現方式。

虛方法呼叫包括 invokevirtual 指令和 invokeinterface 指令。如果這兩種指令所宣告的目標方法被標記為 final,那麼 Java 虛擬機器會採用靜態繫結。

否則,Java 虛擬機器將採用動態繫結,在執行過程中根據呼叫者的動態型別,來決定具體的目標方法。

Java 虛擬機器的動態繫結是通過方法表這一資料結構來實現的。方法表中每一個重寫方法的索引值,與父類方法表中被重寫的方法的索引值一致。

在解析虛方法呼叫時,Java 虛擬機器會紀錄下所宣告的目標方法的索引值,並且在執行過程中根據這個索引值查詢具體的目標方法。

Java 虛擬機器中的即時編譯器會使用內聯快取來加速動態繫結。Java 虛擬機器所採用的單態內聯快取將紀錄呼叫者的動態型別,以及它所對應的目標方法。

當碰到新的呼叫者時,如果其動態型別與快取中的型別匹配,則直接呼叫快取的目標方法。

否則,Java 虛擬機器將該內聯快取劣化為超多型內聯快取,在今後的執行過程中直接使用方法表進行動態繫結。

在今天的實踐環節,我們來觀測一下單態內聯快取和超多型內聯快取的效能差距。為了消除方法內聯的影響,請使用如下的命令。

// Run with: java -XX:CompileCommand='dontinline,*.passThroughImmigration' Passenger
public abstract class Passenger {

  abstract void passThroughImmigration();
  
  public static void main(String[] args) {
    Passenger a = new ChinesePassenger();
    Passenger b = new ForeignerPassenger();
    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }
      Passenger c = (i < 1_000_000_000) ? a : b;
      c.passThroughImmigration();
    }
  }
}
class ChinesePassenger extends Passenger {
  @Override void passThroughImmigration() {} 
}
class ForeignerPassenger extends Passenger {
  @Override void passThroughImmigration() {}
}

作者:PP傑

出處:http://www.cnblogs.com/newber/

博學之,審問之,慎思之,明辨之,篤行之。