本节目标:能说清「变量声明成接口,为什么能调用到实现类的方法」,能读懂
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:数据载体的最短写法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。