Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

繼承與多型

Java 動態分派的內部機制:從 vtable 到 bridge method

已讀過繼承與多型入門?這篇帶你鑽進 JVM 的分派層——方法表、四種 invoke 指令、欄位隱藏、預設方法仲裁與泛型橋接方法,把多型推導成一套可驗證的查表規則。

同一行 obj.pay(),JVM 到底跳去哪裡?

在入門篇裡,我們用一張薪資單看懂了「動態分派(dynamic dispatch)」:宣告型別是父類別、實際物件是子類別時,呼叫的會是子類別覆寫(override)後的方法。那是行為層面的直覺。但如果你已經能背出這個規則,下一個值得追問的問題是:obj.pay() 這一行被編譯成 bytecode 之後,JVM 在執行期究竟靠什麼資料結構決定要跳到哪一段機器碼?為什麼介面(interface)方法的分派比類別方法慢一點?為什麼明明覆寫了方法,欄位(field)卻不會被「覆寫」?

這篇進階篇不再重述「子類別會覆寫父類別」這種規則,而是直接鑽進分派的機制層:方法表(method table / vtable)、四種 invoke 指令、欄位隱藏(field hiding)、預設方法(default method)的衝突解析,以及泛型(generics)如何用 bridge method 偷偷補上型別安全。讀完你會發現,多型不是語言層的魔法,而是一套可以被精準推導的查表規則。

繼承與多型進階概念示意圖

方法表:動態分派的物理基礎

Java 的虛擬方法分派並不是執行期才「搜尋整個繼承鏈」。如果每次呼叫都要從子類別往上爬找方法,效能會很糟。實際上,JVM 在類別載入(class loading)的連結(linking)階段,就為每個類別建立一張 vtable(virtual method table),本質上是一個函式指標陣列。

關鍵性質是:子類別的 vtable 會「繼承並覆蓋」父類別的版面配置(layout)

舉例來說,父類別 Employee 的 vtable 在第 0 槽放 pay()、第 1 槽放 describe()。子類別 Manager 的 vtable 會沿用同樣的槽位編號——pay() 永遠在第 0 槽。如果 Manager 覆寫了 pay(),那麼第 0 槽就填入 Manager.pay 的位址;沒覆寫的 describe() 則第 1 槽仍指向 Employee.describe

class Employee {
    int pay() { return 30000; }              // vtable slot 0
    String describe() { return "員工"; }      // vtable slot 1
}

class Manager extends Employee {
    @Override int pay() { return 50000; }    // 覆蓋 slot 0
    // describe() 未覆寫,slot 1 沿用 Employee.describe
}

於是 emp.pay() 編譯後對應的 invokevirtual 指令,在執行期只做一件事:

  1. 從物件 header 取得它真正的類別(runtime type)。
  2. 找到該類別的 vtable。
  3. 跳到固定槽位 0 指向的函式。

注意第 3 步的「固定槽位」是重點。因為宣告型別是 Employee,編譯器在編譯期就知道 pay() 落在 slot 0;執行期不需要用方法名稱去搜尋,只要一次陣列索引(array indexing)即可。這就是為什麼虛擬分派幾乎是 $O(1)$,只比直接呼叫多一次間接跳轉(indirect jump)。

四種 invoke:編譯器選哪一個,決定了分派策略

許多學生以為 Java 只有「呼叫方法」一種動作,但 bytecode 層面其實有四種主要的方法呼叫指令,編譯器依方法的種類選用不同的一種:

指令 適用情境 分派方式
invokestatic static 方法 編譯期靜態綁定,無 vtable
invokespecial 建構子、privatesuper.xxx() 編譯期靜態綁定
invokevirtual 一般實例方法 執行期 vtable 查表
invokeinterface 透過介面型別呼叫 執行期 itable 查表

這張表解開了好幾個常見困惑。

為什麼 private 方法不能被覆寫? 因為 private 方法用 invokespecial,是靜態綁定的;它根本不進 vtable,子類別寫一個同名方法只是「另一個獨立方法」,不是覆寫。

為什麼 super.pay() 能精準呼叫父類別版本? 因為 super 呼叫走 invokespecial,繞過 vtable 的動態查表,直接綁定到父類別那一份。

invokevirtualinvokeinterface 差在哪? 類別有單一繼承,vtable 的槽位可以在編譯期固定。但介面是多重實作的——一個類別可以實作很多介面,無法保證某介面方法落在所有實作類別的同一個槽位。因此介面方法走 invokeinterface,執行期需要先在 itable(interface method table) 裡找到對應介面的區塊,再定位方法,比 invokevirtual 多一步間接查找。這就是「介面呼叫略慢」的根本原因(現代 JIT 會用 inline cache 大幅優化,但機制本質如此)。

看一個例子:用反組譯驗證分派指令

你可以親手驗證。把下面的程式編譯後用 javap -c 反組譯:

public class Dispatch {
    interface Payable { int pay(); }
    static class Emp implements Payable {
        public int pay() { return 30000; }
        private int secret() { return 1; }
        int callSecret() { return secret(); }
    }
    public static void main(String[] args) {
        Payable p = new Emp();
        Emp e = new Emp();
        p.pay();        // 介面型別呼叫
        e.pay();        // 類別型別呼叫
        e.callSecret(); // 內部呼叫 private
    }
}

執行 javac Dispatch.java && javap -c -p Dispatch\$Emp Dispatch 後,你會在 main 看到:

// p.pay()  → invokeinterface Payable.pay
// e.pay()  → invokevirtual  Emp.pay
// callSecret 內 → invokespecial Emp.secret

同一個 pay() 方法,因為宣告型別不同Payable 介面 vs Emp 類別),編譯器就產生不同的 invoke 指令。這證明了一件重要的事:分派策略由編譯期的靜態型別決定,而被呼叫的實作由執行期的動態型別決定。兩者各司其職。

欄位不會被覆寫:靜態綁定的陷阱

這是繼承中最容易踩雷的地方。方法是動態分派,但欄位(field)是靜態綁定的——欄位的存取由「宣告型別」決定,不看實際物件。

class Animal {
    String name = "動物";
    String getName() { return name; }
}
class Cat extends Animal {
    String name = "貓";                 // 欄位隱藏(field hiding),不是覆寫
    @Override String getName() { return name; }
}

Animal a = new Cat();
System.out.println(a.name);        // 動物  ← 看宣告型別 Animal
System.out.println(a.getName());   // 貓    ← 動態分派到 Cat.getName
System.out.println(((Cat) a).name);// 貓    ← 轉型後宣告型別變 Cat

為什麼會這樣?因為欄位存取在 bytecode 是 getfield,它帶有一個編譯期就決定好的欄位參照(field reference),連名稱帶宣告類別。物件其實同時持有兩個 name 欄位(Animal.nameCat.name 並存於記憶體),a.name 因為宣告型別是 Animal,存取的是 Animal.name 那一格。

這引出一個設計原則:永遠用方法(getter)存取可能被子類別改寫的狀態,不要直接讀寫繼承來的欄位。欄位隱藏幾乎總是設計失誤,而非有意的多型。

預設方法與菱形繼承:Java 8 之後的分派規則

Java 8 給介面加了預設方法(default method),等於讓介面也能提供實作。這立刻帶來一個古典問題:如果兩個介面提供了簽章相同的預設方法,類別同時實作兩者,該用哪一個?這就是「菱形問題(diamond problem)」的 Java 版本。

Java 用三條明確規則解決,依優先序:

  1. 類別優先(class wins):父類別(superclass)的具體方法永遠勝過介面的預設方法。
  2. 更具體的介面優先(most specific interface wins):若介面 B 繼承介面 A,B 的預設方法勝過 A。
  3. 無法仲裁則強制顯式覆寫:若兩個無繼承關係的介面提供同名預設方法,編譯器直接報錯,逼你在類別裡手動覆寫並指定要用哪一個。
interface A { default String hello() { return "A"; } }
interface B { default String hello() { return "B"; } }

class C implements A, B {
    // 不寫這個會編譯失敗:class C inherits unrelated defaults for hello()
    @Override
    public String hello() {
        return A.super.hello();   // 顯式選擇 A 的版本
    }
}

注意 A.super.hello() 這個特殊語法——它讓你在覆寫中明確呼叫某個介面的預設實作。Java 刻意不像 C++ 那樣讓編譯器「猜」一個預設選擇,而是把仲裁權交回程式設計者,這是「明確優於隱晦」的語言設計哲學。

泛型 + 繼承:bridge method 的隱形補丁

當泛型遇上覆寫,會冒出一個你看不見但確實存在的方法——bridge method(橋接方法)。這是型別抹除(type erasure)與多型協作的產物。

考慮一個泛型介面與它的具體實作:

interface Comparator<T> {
    int compare(T a, T b);
}
class IntComparator implements Comparator<Integer> {
    @Override
    public int compare(Integer a, Integer b) { return a - b; }
}

問題來了:泛型在執行期被抹除,Comparator<T>compare 在 bytecode 層其實是 compare(Object, Object)。但 IntComparator 寫的是 compare(Integer, Integer)——簽章不一樣!如果就這樣,vtable 的槽位對不上,多型會斷掉:透過 Comparator 介面呼叫 compare(Object, Object) 時找不到對應實作。

編譯器的解法是自動合成一個 bridge method,它的真實簽章是 compare(Object, Object),內容做型別轉換後轉呼叫你寫的那個版本:

// 編譯器自動產生(你看不到原始碼,但 javap 看得到):
public int compare(Object a, Object b) {       // bridge,對齊抹除後的簽章
    return compare((Integer) a, (Integer) b);  // 轉呼叫你寫的版本
}

於是 vtable 的「compare(Object,Object) 槽位」指向 bridge method,bridge 再轉到真正的實作,多型鏈就接通了。

動手算一下:用 reflection 數出 bridge method

你可以用反射親眼看到這個隱形方法:

import java.lang.reflect.Method;

for (Method m : IntComparator.class.getDeclaredMethods()) {
    System.out.printf("%s  isBridge=%b  params=%s%n",
        m.getName(), m.isBridge(),
        java.util.Arrays.toString(m.getParameterTypes()));
}

輸出會有兩個 compare

compare  isBridge=false  params=[class java.lang.Integer]   // 你寫的
compare  isBridge=true   params=[class java.lang.Object]    // 編譯器補的

這也解釋了一個進階陷阱:當你用反射去 getMethod("compare", Object.class, Object.class) 取得的是 bridge method,它的泛型資訊與註解可能不完整。要拿原始方法資訊,得過濾掉 isBridge() 為真的版本。

Liskov 替換原則:覆寫的正確性契約

機制講完了,回到設計層。多型能成立,前提是子類別「真的可以替換」父類別。這正是 Liskov 替換原則(Liskov Substitution Principle, LSP):任何使用父類別物件的地方,換成子類別物件,程式行為仍應正確。

LSP 對覆寫方法施加了一組契約(contract)約束,Java 編譯器只強制了型別層的一半,行為層的一半要靠你自律:

  • 回傳型別可協變(covariant return):子類別覆寫時回傳型別可以更具體(Java 5 後支援)。Animal reproduce() 可被覆寫成 Cat reproduce()
  • 參數型別不可放寬也不可收窄:Java 要求覆寫的參數型別完全一致(否則變成多載 overload,不是覆寫)。
  • 例外不可變寬(exception narrowing):覆寫方法宣告的 checked exception 只能是父類別所宣告例外的子型別或更少,不能丟出父類別沒宣告的新 checked exception。
  • 前置條件不可加強、後置條件不可削弱(行為契約,編譯器不檢查):子類別不能要求比父類別更嚴苛的輸入,也不能保證比父類別更弱的輸出。

經典的反例是 Rectangle / Square:如果 Square extends Rectangle 並讓 setWidth 同時改高度,那麼「設定寬度不影響高度」這個父類別後置條件就被削弱了,違反 LSP,導致以 Rectangle 寫的程式碼換成 Square 後出錯。教訓是:繼承表達的是「行為可替換」,不是「資料結構相似」。 正方形在數學上是矩形,在物件導向裡卻未必該繼承矩形。

重點回顧

  • 虛擬分派的物理基礎是 vtable:子類別沿用父類別的槽位版面,覆寫只是換掉某槽的函式指標,所以分派是 $O(1)$ 的查表,不是搜尋繼承鏈。
  • bytecode 有四種 invoke:static/special 是靜態綁定,virtual 走 vtable,interface 走 itable。分派策略由編譯期的宣告型別決定,被呼叫的實作由執行期的動態型別決定。
  • 方法動態分派,欄位靜態綁定。欄位隱藏(field hiding)幾乎都是設計失誤;可被子類別改寫的狀態一律用 getter 存取。
  • 預設方法的菱形衝突由「類別優先 → 更具體介面優先 → 否則強制顯式覆寫」三規則解決,並用 Interface.super.method() 顯式仲裁。
  • 泛型覆寫會合成 bridge method 對齊抹除後的簽章;多型與型別安全因此並存。設計繼承要守 LSP:行為可替換,而非結構相似。

深入探討(研究所視角)

從程式語言理論看,Java 的分派機制是單一分派(single dispatch):方法的選擇只取決於接收者(receiver,即 this)的執行期型別,參數型別在編譯期就固定了。這與多載解析(overload resolution)形成有趣對照——多載是純編譯期、依靜態型別選方法,本質是一種偽多型。把單一分派推到多參數型別共同決定方法的,是 多重分派(multiple dispatch / multimethods),常見於 Common Lisp(CLOS)、Julia、Clojure。Java 要模擬多重分派,得手動實作 visitor pattern——它本質是用兩次單一分派(double dispatch)拼出對「接收者 × 參數」兩個維度的分派。理解這點,你會明白 visitor pattern 不是設計花招,而是在補單一分派語言的表達力缺口。

在 JVM 實作層面,invokevirtual 的「一次間接跳轉」只是理論下界。實務上熱點方法(hot method)會被 JIT 編譯器以 inline cachedevirtualization 優化:若 JIT 透過 profiling 觀察到某呼叫點長期只命中單一型別(monomorphic call site),它會直接內聯(inline)目標方法、省去查表,並插入一個型別守衛(type guard);若觀察到兩三種型別(bimorphic / polymorphic),用小型 inline cache;型別太發散(megamorphic)才退回完整 vtable 查表。這解釋了一個違反直覺的現象:寫得「夠多型」的程式,在實測上常因為呼叫點型別穩定而被優化得極快,而看似單純的程式若呼叫點型別發散反而較慢。invokedynamic(Java 7 引入,原為動態語言設計,後被 lambda 與 string concatenation 採用)更進一步,把分派決策延後到執行期由 bootstrap method 與 CallSite 動態決定,是 JVM 分派機制的最高彈性層。

最後一個值得探索的方向是型別系統的變異性(variance)。Java 陣列是協變(covariant)的——Integer[] 可賦值給 Object[],但這個設計被公認是型別安全漏洞,因為它把型別錯誤從編譯期推遲到執行期,存進不相容元素時才丟 ArrayStoreException。泛型則改採不變(invariant)並用 use-site variance(? extends 協變、? super 逆變,對應 PECS 原則:Producer Extends, Consumer Super)讓開發者按需指定變異方向。延伸閱讀可比較 Scala 的 declaration-site variance(在定義端用 +T/-T 標註)與 Kotlin 的 out/in,思考一個問題:把變異標註放在型別「定義端」還是「使用端」,各自在表達力與心智負擔上付出什麼代價?這正是現代型別系統設計的核心張力之一。

AI 共讀助教正在陪你讀:Java 動態分派的內部機制:從 vtable 到 bridge method
嗨!我是這篇文章的共讀助教,只根據〈Java 動態分派的內部機制:從 vtable 到 bridge method〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。