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

04 - JVM 是如何執行方法呼叫的?(上)

前不久在寫程式碼的時候,我不小心踩到一個可變長引數的坑。你或許已經猜到了,它正是可變長引數方法的過載造成的。(注:官方文件建議避免過載可變長引數方法,見[1]的最後一段)。

我把踩坑的過程放在了文稿裡,你可以點選檢視。

void invoke(Object obj, Object... args) { ... }
void invoke(String s, Object obj, Object... args) { ... }

invoke(null, 1);    // 呼叫第二個invoke方法
invoke(null, 1, 2); // 呼叫第二個invoke方法
invoke(null, new Object[]{1}); // 只有手動繞開可變長引數的語法糖,
                               // 才能呼叫第一個invoke方法

當時情況是這樣子的,某個 API 定義了兩個同名的過載方法。其中,第一個接收一個 Object,以及宣告為 Object…的變長引數;而第二個則接收一個 String、一個 Object,以及宣告為 Object…的變長引數。

這裡我想呼叫第一個方法,傳入的引數為 (null, 1)。也就是說,宣告為 Object 的形式引數所對應的實際引數為 null,而變長引數則對應 1。

通常來說,之所以不提倡可變長引數方法的過載,是因為 Java 編譯器可能無法決定應該呼叫哪個目標方法。

在這種情況下,編譯器會報錯,並且提示這個方法呼叫有二義性。然而,Java 編譯器直接將我的方法呼叫識別為呼叫第二個方法,這究竟是為什麼呢?

帶著這個問題,我們來看一看 Java 虛擬機器是怎麼識別目標方法的。

過載與重寫

在 Java 程式裡,如果同一個類中出現多個名字相同,並且引數型別相同的方法,那麼它無法通過編譯。也就是說,在正常情況下,如果我們想要在同一個類中定義名字相同的方法,那麼它們的引數型別必須不同。這些方法之間的關係,我們稱之為過載。

小知識:這個限制可以通過位元組碼工具繞開。也就是說,在編譯完成之後,我們可以再向class檔案中新增方法名和引數型別相同,而返回型別不同的方法。當這種包括多個方法名相同、引數型別相同,而返回型別不同的方法的類,出現在Java編譯器的使用者類路徑上時,它是怎麼確定需要呼叫哪個方法的呢?當前版本的Java編譯器會直接選取第一個方法名以及引數型別匹配的方法。並且,它會根據所選取方法的返回型別來決定可不可以通過編譯,以及需不需要進行值轉換等。

過載的方法在編譯過程中即可完成識別。具體到每一個方法呼叫,Java 編譯器會根據所傳入引數的宣告型別(注意與實際型別區分)來選取過載方法。選取的過程共分為三個階段:

  1. 在不考慮對基本型別自動裝拆箱(auto-boxing,auto-unboxing),以及可變長引數的情況下選取過載方法;
  2. 如果在第 1 個階段中沒有找到適配的方法,那麼在允許自動裝拆箱,但不允許可變長引數的情況下選取過載方法;
  3. 如果在第 2 個階段中沒有找到適配的方法,那麼在允許自動裝拆箱以及可變長引數的情況下選取過載方法。

如果 Java 編譯器在同一個階段中找到了多個適配的方法,那麼它會在其中選擇一個最為貼切的,而決定貼切程度的一個關鍵就是形式引數型別的繼承關係。

在開頭的例子中,當傳入 null 時,它既可以匹配第一個方法中宣告為 Object 的形式引數,也可以匹配第二個方法中宣告為 String 的形式引數。由於 String 是 Object 的子類,因此 Java 編譯器會認為第二個方法更為貼切。

除了同一個類中的方法,過載也可以作用於這個類所繼承而來的方法。也就是說,如果子類定義了與父類中非私有方法同名的方法,而且這兩個方法的引數型別不同,那麼在子類中,這兩個方法同樣構成了過載。

那麼,如果子類定義了與父類中非私有方法同名的方法,而且這兩個方法的引數型別相同,那麼這兩個方法之間又是什麼關係呢?

如果這兩個方法都是靜態的,那麼子類中的方法隱藏了父類中的方法。如果這兩個方法都不是靜態的,且都不是私有的,那麼子類的方法重寫了父類中的方法。

眾所周知,Java 是一門面向物件的程式語言,它的一個重要特性便是多型。而方法重寫,正是多型最重要的一種體現方式:它允許子類在繼承父類部分功能的同時,擁有自己獨特的行為。

打個比方,如果你經常漫遊,那麼你可能知道,撥打 10086 會根據你當前所在地,連線到當地的客服。重寫呼叫也是如此:它會根據呼叫者的動態型別,來選取實際的目標方法。

JVM 的靜態繫結和動態繫結

接下來,我們來看看 Java 虛擬機器是怎麼識別方法的。

Java 虛擬機器識別方法的關鍵在於類名、方法名以及方法描述符(method descriptor)。前面兩個就不做過多的解釋了。至於方法描述符,它是由方法的引數型別以及返回型別所構成。在同一個類中,如果同時出現多個名字相同且描述符也相同的方法,那麼 Java 虛擬機器會在類的驗證階段報錯。

可以看到,Java 虛擬機器與 Java 語言不同,它並不限制名字與引數型別相同,但返回型別不同的方法出現在同一個類中,對於呼叫這些方法的位元組碼來說,由於位元組碼所附帶的方法描述符包含了返回型別,因此 Java 虛擬機器能夠準確地識別目標方法。

Java 虛擬機器中關於方法重寫的判定同樣基於方法描述符。也就是說,如果子類定義了與父類中非私有、非靜態方法同名的方法,那麼只有當這兩個方法的引數型別以及返回型別一致,Java 虛擬機器才會判定為重寫。

對於 Java 語言中重寫而 Java 虛擬機器中非重寫的情況,編譯器會通過生成橋接方法[2]來實現 Java 中的重寫語義。

由於對過載方法的區分在編譯階段已經完成,我們可以認為 Java 虛擬機器不存在過載這一概念。因此,在某些文章中,過載也被稱為靜態繫結(static binding),或者編譯時多型(compile-time polymorphism);而重寫則被稱為動態繫結(dynamic binding)。

這個說法在 Java 虛擬機器語境下並非完全正確。這是因為某個類中的過載方法可能被它的子類所重寫,因此 Java 編譯器會將所有對非私有例項方法的呼叫編譯為需要動態繫結的型別。

確切地說,Java 虛擬機器中的靜態繫結指的是在解析時便能夠直接識別目標方法的情況,而動態繫結則指的是需要在執行過程中根據呼叫者的動態型別來識別目標方法的情況。

具體來說,Java 位元組碼中與呼叫相關的指令共有五種。

  1. invokestatic:用於呼叫靜態方法。
  2. invokespecial:用於呼叫私有例項方法、構造器,以及使用 super 關鍵字呼叫父類的例項方法或構造器,和所實現介面的預設方法。
  3. invokevirtual:用於呼叫非私有例項方法。
  4. invokeinterface:用於呼叫介面方法。
  5. invokedynamic:用於呼叫動態方法。

由於 invokedynamic 指令較為複雜,我將在後面的篇章中單獨介紹。這裡我們只討論前四種。

我在文章中貼了一段程式碼,展示了編譯生成這四種呼叫指令的情況。

interface 客戶 {
  boolean isVIP();
}

class 商戶 {
  public double 折後價格(double 原價, 客戶 某客戶) {
    return 原價 * 0.8d;
  }
}

class 奸商 extends 商戶 {
  @Override
  public double 折後價格(double 原價, 客戶 某客戶) {
    if (某客戶.isVIP()) {                         // invokeinterface      
      return 原價 * 價格歧視();                    // invokestatic
    } else {
      return super.折後價格(原價, 某客戶);          // invokespecial
    }
  }
  public static double 價格歧視() {
    // 咱們的殺熟演算法太粗暴了,應該將客戶城市作為隨機數生成器的種子。
    return new Random()                          // invokespecial
           .nextDouble()                         // invokevirtual
           + 0.8d;
  }
}

在程式碼中,“商戶”類定義了一個成員方法,叫做“折後價格”,它將接收一個 double 型別的引數,以及一個“客戶”型別的引數。這裡“客戶”是一個介面,它定義了一個介面方法,叫“isVIP”。

我們還定義了另一個叫做“奸商”的類,它繼承了“商戶”類,並且重寫了“折後價格”這個方法。如果客戶是 VIP,那麼它會被給到一個更低的折扣。

在這個方法中,我們首先會呼叫“客戶”介面的”isVIP“方法。該呼叫會被編譯為 invokeinterface 指令。

如果客戶是 VIP,那麼我們會呼叫奸商類的一個名叫“價格歧視”的靜態方法。該呼叫會被編譯為 invokestatic 指令。如果客戶不是 VIP,那麼我們會通過 super 關鍵字呼叫父類的“折後價格”方法。該呼叫會被編譯為 invokespecial 指令。

在靜態方法“價格歧視”中,我們會呼叫 Random 類的構造器。該呼叫會被編譯為 invokespecial 指令。然後我們會以這個新建的 Random 物件為呼叫者,呼叫 Random 類中的 nextDouble 方法。該呼叫會被編譯為 invokevirutal 指令。

對於 invokestatic 以及 invokespecial 而言,Java 虛擬機器能夠直接識別具體的目標方法。

而對於 invokevirtual 以及 invokeinterface 而言,在絕大部分情況下,虛擬機器需要在執行過程中,根據呼叫者的動態型別,來確定具體的目標方法。

唯一的例外在於,如果虛擬機器能夠確定目標方法有且僅有一個,比如說目標方法被標記為 final[3][4],那麼它可以不通過動態型別,直接確定目標方法。

呼叫指令的符號引用

在編譯過程中,我們並不知道目標方法的具體記憶體地址。因此,Java 編譯器會暫時用符號引用來表示該目標方法。這一符號引用包括目標方法所在的類或介面的名字,以及目標方法的方法名和方法描述符。

符號引用儲存在 class 檔案的常量池之中。根據目標方法是否為介面方法,這些引用可分為介面符號引用和非介面符號引用。我在文章中貼了一個例子,利用“javap -v”列印某個類的常量池,如果你感興趣的話可以到文章中檢視。

// 在奸商.class的常量池中,#16為介面符號引用,指向介面方法"客戶.isVIP()"。而#22為非介面符號引用,指向靜態方法"奸商.價格歧視()"。
$ javap -v 奸商.class ...
Constant pool:
...
  #16 = InterfaceMethodref #27.#29        // 客戶.isVIP:()Z
...
  #22 = Methodref          #1.#33         // 奸商.價格歧視:()D
...

上一篇中我曾提到過,在執行使用了符號引用的位元組碼前,Java 虛擬機器需要解析這些符號引用,並替換為實際引用。

對於非介面符號引用,假定該符號引用所指向的類為 C,則 Java 虛擬機器會按照如下步驟進行查詢。

  1. 在 C 中查詢符合名字及描述符的方法。
  2. 如果沒有找到,在 C 的父類中繼續搜尋,直至 Object 類。
  3. 如果沒有找到,在 C 所直接實現或間接實現的介面中搜索,這一步搜尋得到的目標方法必須是非私有、非靜態的。並且,如果目標方法在間接實現的介面中,則需滿足 C 與該介面之間沒有其他符合條件的目標方法。如果有多個符合條件的目標方法,則任意返回其中一個。

從這個解析演算法可以看出,靜態方法也可以通過子類來呼叫。此外,子類的靜態方法會隱藏(注意與重寫區分)父類中的同名、同描述符的靜態方法。

對於介面符號引用,假定該符號引用所指向的介面為 I,則 Java 虛擬機器會按照如下步驟進行查詢。

  1. 在 I 中查詢符合名字及描述符的方法。
  2. 如果沒有找到,在 Object 類中的公有例項方法中搜索。
  3. 如果沒有找到,則在 I 的超介面中搜索。這一步的搜尋結果的要求與非介面符號引用步驟 3 的要求一致。

經過上述的解析步驟之後,符號引用會被解析成實際引用。對於可以靜態繫結的方法呼叫而言,實際引用是一個指向方法的指標。對於需要動態繫結的方法呼叫而言,實際引用則是一個方法表的索引。具體什麼是方法表,我會在下一篇中做出解答。

總結與實踐

今天我介紹了 Java 以及 Java 虛擬機器是如何識別目標方法的。

在 Java 中,方法存在過載以及重寫的概念,過載指的是方法名相同而引數型別不相同的方法之間的關係,重寫指的是方法名相同並且引數型別也相同的方法之間的關係。

Java 虛擬機器識別方法的方式略有不同,除了方法名和引數型別之外,它還會考慮返回型別。

在 Java 虛擬機器中,靜態繫結指的是在解析時便能夠直接識別目標方法的情況,而動態繫結則指的是需要在執行過程中根據呼叫者的動態型別來識別目標方法的情況。由於 Java 編譯器已經區分了過載的方法,因此可以認為 Java 虛擬機器中不存在過載。

在 class 檔案中,Java 編譯器會用符號引用指代目標方法。在執行呼叫指令前,它所附帶的符號引用需要被解析成實際引用。對於可以靜態繫結的方法呼叫而言,實際引用為目標方法的指標。對於需要動態繫結的方法呼叫而言,實際引用為輔助動態繫結的資訊。

在文中我曾提到,Java 的重寫與 Java 虛擬機器中的重寫並不一致,但是編譯器會通過生成橋接方法來彌補。今天的實踐環節,我們來看一下兩個生成橋接方法的例子。你可以通過“javap -v”來檢視 class 檔案所包含的方法。

  1. 重寫方法的返回型別不一致:
interface Customer {
  boolean isVIP();
}

class Merchant {
  public Number actionPrice(double price, Customer customer) {
    ...
  }
}

class NaiveMerchant extends Merchant {
  @Override
  public Double actionPrice(double price, Customer customer) {
    ...
  }
}
  1. 範型引數型別造成的方法引數型別不一致:
interface Customer {
  boolean isVIP();
}

class Merchant<T extends Customer> {
   public double actionPrice(double price, T customer) {
      ...
   }
}

class VIPOnlyMerchant extends Merchant<VIP> {
   @Override
   public double actionPrice(double price, VIP customer) {
    ...
   }
}

  1. https://docs.oracle.com/javase/8/docs/technotes/guides/language/varargs.html ↩︎

  2. https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html ↩︎

  3. https://wiki.openjdk.java.net/display/HotSpot/VirtualCalls ↩︎

  4. https://wiki.openjdk.java.net/display/HotSpot/InterfaceCalls ↩︎

作者:PP傑

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

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