Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

繼承與多型

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〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。