《Java 编程入门》8.3 多态与动态分派

从 LibraryService 只持有 LibraryRepository 接口却能调用到内存实现这件事出发,讲清编译时类型与运行时类型、invokevirtual 与 invokeinterface 的查找过程、字段隐藏和构造器调用可覆盖方法的陷阱,并用罚金策略与 javap 输出把动态分派落到可验证的代码上。

本节目标:能说清「变量声明成接口,为什么能调用到实现类的方法」,能读懂 invokevirtual 与 invokeinterface 的差别,并避开字段隐藏、构造器调用可覆盖方法这两个经典陷阱。
适用版本:JDK 25 LTS(Temurin 25.0.5);字节码形态在 JDK 21 上一致。

一个每天都在发生的问题

8.2 的 LibraryService 构造器接收 LibraryRepository:

public LibraryService(LibraryRepository repository, FinePolicy finePolicy) {
    this.repository = repository;
    this.finePolicy = finePolicy;
}

运行时传进来的是 InMemoryLibraryRepository。repository.findByIsbn(isbn) 这一行,编译时只知道「某个实现了 LibraryRepository 的对象」,运行时却必须执行内存实现里的那段循环。JVM 是怎么从接口方法找到具体实现的?

这个问题不只关乎接口。8.1 的 Member member = new StaffMember(...) 也一样:变量是 Member,对象是 StaffMember,member.loanDays() 却返回 30。理解这套查找规则,才能解释为什么「面向接口编程」在 Java 里能成立,也才能看懂某些看似诡异的输出。

编译时类型与运行时类型

每个引用变量都有两个类型:

  • 编译时类型:声明时写的类型,决定你能调用哪些方法、编译器怎么检查参数。
  • 运行时类型:变量实际指向的对象的类型,决定实例方法最终执行哪一份代码。
Member reader = new Member("M-001", "林一");
Member staff = new StaffMember("S-001", "周岚", "采编部");

for (Member member : new Member[]{reader, staff}) {
    System.out.printf("%s limit=%d days=%d%n",
            member.describe(), member.borrowLimit(), member.loanDays());
}

本机 JDK 25 输出:

林一(M-001) limit=3 days=14
周岚(S-001) [采编部] limit=10 days=30

循环变量的编译时类型是 Member,所以编译器只允许调用 Member 上声明过的方法;如果写 member.department(),编译直接失败。但 borrowLimit() 和 loanDays() 在运行时按对象的实际类型查找,staff 走的是 StaffMember 的覆写版本。

这就是多态:同一段调用代码,按运行时类型执行不同实现。 它带来两个直接好处:调用方不需要写 if (member instanceof StaffMember);新增子类时,调用方代码不用改。

方法表:JVM 怎么找到实现

把 8.2 的 LibraryService 和 FinePolicy 编译后,用 javap -c -p 看调用点(节选):

 5: invokeinterface #35,  2  // InterfaceMethod com/plume/library/repository/LibraryRepository.findByIsbn:(Ljava/lang/String;)Ljava/util/Optional;
46: invokevirtual  #77      // Method com/plume/library/domain/Book.title:()Ljava/lang/String;
64: invokevirtual  #83      // Method com/plume/library/domain/Member.id:()Ljava/lang/String;
70: invokevirtual  #88      // Method com/plume/library/domain/Member.loanDays:()I

这四条指令把两种查找方式都覆盖了:

  • invokevirtual 用于类实例方法。指令里写着 Member.loanDays,但这只是编译期的符号引用;运行时 JVM 会根据接收者的实际类,在方法表(vtable)里找到 loanDays 的入口。StaffMember 覆写了 loanDays,表里对应槽位指向子类实现,所以执行的是子类版本。Book.title()、Member.id() 同理。
  • invokeinterface 用于接口方法。指令里写着 LibraryRepository.findByIsbn,运行时按实际实现类(这里是 InMemoryLibraryRepository)的方法表查找。接口不能像类那样用固定偏移的 vtable,JVM 需要按接口方法签名在实现类的方法表里查找。不同 JVM 有不同优化(比如接口方法表缓存),但语义是一样的:按运行时类型找。
  • invokespecial 用于构造器、private 方法和 super.xxx()。这些调用不参与动态分派,编译期就定死了目标。
  • invokestatic 用于静态方法,同样在编译期解析,和对象无关。

所以「方法看运行时类型」更准确的说法是:只有通过 invokevirtual / invokeinterface 调用的实例方法才动态分派。 构造器、私有方法、静态方法、super 调用都不在此列。

方法表的概念可以先这样理解:每个类在加载、链接后,JVM 会为它准备一张方法入口表,覆写的方法占同一个槽位,指向子类实现;没有覆写的方法沿用父类入口。调用时先拿到对象的实际类,再查表。真实实现比这复杂(内联缓存、去优化、接口方法解析),但排障时这个模型够用。

字段没有多态

方法会动态分派,字段不会。字段在编译期就按变量的声明类型解析:

class Parent {
    String name = "parent";

    String who() {
        return "parent";
    }
}

class Child extends Parent {
    String name = "child";

    @Override
    String who() {
        return "child";
    }
}
Parent parent = new Child();
System.out.println("field name = " + parent.name);
System.out.println("method who() = " + parent.who());

本机 JDK 25 输出:

field name = parent
method who() = child

parent.name 编译成 getfield Parent.name,直接读父类那块字段;parent.who() 编译成 invokevirtual Parent.who,运行时查表找到 Child.who。字段隐藏(field hiding)在实际项目里很少是刻意设计的,但它解释了为什么「用父类引用拿到子类字段」会失败——不要靠字段表达多态,用方法。

构造器里调用可覆盖方法

第二个陷阱更隐蔽。父类构造器里调用一个可覆盖方法,方法会分派到子类版本,但此时子类字段还没初始化:

class Base {
    Base() {
        init();
    }

    void init() {
        System.out.println("Base.init");
    }
}

class Derived extends Base {
    int value = 42;

    @Override
    void init() {
        System.out.println("Derived.init value=" + value);
    }
}
new Derived();

本机 JDK 25 输出:

Derived.init value=0

执行顺序是:分配对象内存并把字段置零 → 调用 Derived 构造器 → 隐式 super() → Base 构造器 → init() 动态分派到 Derived.init → 此时 value 还是 0 → 回到 Derived 构造器执行字段初始化,value 才变成 42。

规避办法很简单:构造器里只调用 final、private 或 static 方法,它们不参与动态分派。如果确实需要在对象构造完成后做初始化,用工厂方法或单独的 start()/initialize(),不要放在构造器里。

重载与覆写:两个不同的选择时机

重载(overload)和覆写(override)经常被放在一起比较,但它们的选择时机完全不同。覆写看运行时类型,重载看编译时类型:

static void print(Member member) {
    System.out.println("Member");
}

static void print(StaffMember staff) {
    System.out.println("StaffMember");
}
Member staff = new StaffMember("S-001", "周岚", "采编部");
print(staff);

本机 JDK 25 输出:

Member

变量声明为 Member,编译器在编译期就从两个重载里选了 print(Member),并把调用点写成 invokestatic print(Member)。即使运行时对象是 StaffMember,也不会改选另一个重载。想让员工走专属逻辑,要么在 print(Member) 里做动态分派,要么把方法设计成覆写而不是重载。

这也是为什么「用重载表达多态」通常行不通:重载在编译期定型,无法随运行时类型变化。

在 library 里落地

LibraryService 的多态体现在两个字段上:repository 和 finePolicy。业务方法只写接口调用:

public Loan borrow(String isbn, Member member, LocalDate today) {
    Book book = repository.findByIsbn(isbn)
            .orElseThrow(() -> new IllegalArgumentException("没有这本书: " + isbn));
    if (repository.findActiveLoanByIsbn(isbn).isPresent()) {
        throw new IllegalStateException("这本书已被借出: " + book.title());
    }
    Loan loan = new Loan(isbn, member.id(), today, today.plusDays(member.loanDays()));
    repository.saveLoan(loan);
    return loan;
}

把不同实现传进来,同一段代码走不同路径:

LibraryRepository memory = new InMemoryLibraryRepository();
LibraryService service = new LibraryService(memory, new StandardFinePolicy());

memory 的编译时类型是 LibraryRepository,运行时类型是 InMemoryLibraryRepository;service.borrow 里的 repository.findByIsbn 通过 invokeinterface 找到内存实现。

罚金策略更直观。FinePolicy 是抽象类,fineCents 是 final 模板方法,内部调用 dailyRateCents() 和 graceDays() 这两个可覆盖方法。员工策略把每日费率改成 25 分、宽限改成 3 天:

public final class StaffFinePolicy extends FinePolicy {
    @Override
    protected long dailyRateCents() {
        return 25;
    }

    @Override
    protected int graceDays() {
        return 3;
    }
}
Loan loan = new Loan("9787536692930", "M-001",
        LocalDate.of(2026, 10, 12), LocalDate.of(2026, 10, 26));
LocalDate returnedOn = LocalDate.of(2026, 11, 15);

List<FinePolicy> policies = List.of(new StandardFinePolicy(), new StaffFinePolicy());
for (FinePolicy policy : policies) {
    System.out.printf("%s -> %d%n",
            policy.getClass().getSimpleName(), policy.fineCents(loan, returnedOn));
}

本机 JDK 25 输出:

StandardFinePolicy -> 1000
StaffFinePolicy -> 425

同一份 fineCents 代码,逾期 20 天:普通会员按 20 天 × 50 分收 1000 分;员工有 3 天宽限,按 17 天 × 25 分收 425 分。循环变量的类型是 FinePolicy,调用的是抽象类里的模板方法,模板方法里的 dailyRateCents() 又分派到具体子类。这是「模板方法 + 动态分派」的组合。

如果以后要加「学生会员」,只需要新增一个 StudentFinePolicy 子类,LibraryService 和上面的循环都不用改。这就是多态带来的扩展点。

常见错误与边界

以为静态方法也会多态。 静态方法属于类,不属于对象:

class Parent {
    static String kind() {
        return "parent";
    }
}

class Child extends Parent {
    static String kind() {
        return "child";
    }
}
Parent parent = new Child();
System.out.println(parent.kind());

输出是 parent。Child.kind() 不是覆写,而是隐藏;编译器按 Parent 类型解析,invokestatic 直接调用 Parent.kind。静态方法不要用来表达可替换行为。

私有方法不会被覆写。 子类写一个同名同参数的 private 方法,只是新方法,父类引用调用不到它;如果子类方法加了 @Override,编译会报「方法不会覆盖或实现超类型的方法」。

重载在编译期就选好了。 如果同时有 print(Member) 和 print(StaffMember),调用 print(staff) 时编译器按变量的声明类型选 print(Member),不会因为运行时是员工就改选另一个。重载看编译时类型,覆写看运行时类型,两者不要混。

super.method() 不参与动态分派。 它编译成 invokespecial,直接调用父类实现。在子类覆写方法里需要复用父类逻辑时用它,但别指望它再往下分派。

小结

  • 编译时类型决定「能调用什么」,运行时类型决定「实例方法执行哪一份」。
  • invokevirtual 查类方法表,invokeinterface 查接口实现;invokespecial、invokestatic 不动态分派。
  • 字段没有多态,只有方法有;字段隐藏会让人误以为「父类引用拿到了子类数据」。
  • 构造器里调用可覆盖方法会看到子类字段的默认值,构造器只应调用 final、private、static 方法。
  • LibraryService 依赖 LibraryRepository 和 FinePolicy 两个抽象,换实现不改业务代码。
  • 新增会员类型或罚金策略时,扩展点是子类,而不是在业务方法里加 if。

类型关系和分派规则讲完了,第 9 章换一个方向:不再新增子类,而是用 record、enum、sealed 把「数据」和「有限状态」写得更短、更安全。下一节先看 record 如何替掉 Book 里那几十行样板代码。

阅读导航:上一节:8.2 接口、default 方法与抽象类 · 下一节:9.1 record:数据载体的最短写法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发