002|重载与重写:静态分派、动态分派、协变返回类型与方法选择规则

Java语言基础与面向对象约 19 分钟阅读10,576 字符
知识库文档 3 / 251

阅读时间:40–50 分钟 | 难度:进阶 | 关键词:重载、重写、静态分派、动态分派、虚方法表、协变返回类型、JLS 方法选择


一、开篇:一段让人困惑的代码

先看一段面试高频出现的代码,你能准确说出输出结果吗?

java
public class DispatchDemo {
    static class Animal {}
    static class Dog extends Animal {}
    static class Cat extends Animal {}

    public void greet(Animal a) { System.out.println("Hello Animal"); }
    public void greet(Dog d)    { System.out.println("Hello Dog"); }
    public void greet(Cat c)    { System.out.println("Hello Cat"); }


    public static void main(String[ ] args) {

        DispatchDemo demo = new DispatchDemo();
        Animal dog = new Dog();
        Animal cat = new Cat();

        demo.greet(dog);  // 输出 ① ?
        demo.greet(cat);  // 输出 ② ?
        demo.greet((Dog) dog);  // 输出 ③ ?
    }
}

答案:① Hello Animal,② Hello Animal,③ Hello Dog。

如果你全部答对并且能解释清楚为什么——这篇文章的前半部分你可以快速浏览。如果不能,请仔细阅读。这道题触及了 Java 方法分派机制的核心:重载依赖编译期静态类型,重写依赖运行期实际类型。


二、基础概念:重载与重写的本质区别

2.1 重载(Overload)—— "同一个名字,不同的参数"

重载是同一个类中,多个方法拥有相同的方法名但参数列表不同(参数个数、类型或顺序不同)。

java
public class Calculator {
    public int add(int a, int b)          { return a + b; }
    public double add(double a, double b)  { return a + b; }
    public int add(int a, int b, int c)    { return a + b + c; }
}

核心特征:

  • 发生在编译期确定调用哪个方法 —— 即静态分派
  • 只与参数列表有关,返回值类型不参与重载判断
  • 可以改变访问修饰符、返回值类型、抛出的异常
  • 可以发生在同一个类或父子类之间

2.2 重写(Override)—— "子类重新定义父类行为"

重写是子类对父类中可访问方法的重新实现,方法签名(名称 + 参数列表)完全相同。

java
public class Animal {
    public void makeSound() { System.out.println("Some sound"); }
}

public class Dog extends Animal {
    @Override
    public void makeSound() { System.out.println("Woof!"); }
}

核心特征:

  • 发生在运行期确定调用哪个方法 —— 即动态分派
  • 方法签名必须与父类完全一致
  • 返回值类型可以与父类相同或是其子类型(协变返回类型)
  • 访问修饰符不能比父类更严格(可以更宽松)
  • 抛出异常不能比父类更宽泛
  • final、static、private 方法不能被重写

2.3 一句话总结

维度 重载(Overload) 重写(Override)
发生时机 编译期 运行期
分派方式 静态分派 动态分派
方法签名 方法名相同,参数列表不同 方法名和参数列表完全相同
返回值 可以不同 相同或协变子类型
访问修饰符 无限制 不能更严格
异常 无限制 不能更宽泛
发生范围 同一个类或父子类 父子类之间

252_fig1.png
252_fig1.png


三、深入核心:静态分派与动态分派

3.1 静态分派 —— 编译器说了算

静态分派(Static Dispatch)就是在编译期根据**引用类型(静态类型)**来决定调用哪个重载方法。这个决策在编译完成后就固化了,运行时不会改变。

回到开篇的例子:

java
Animal dog = new Dog();    // 静态类型 = Animal,实际类型 = Dog
demo.greet(dog);           // 编译器只看静态类型 Animal → 调用 greet(Animal)

关键点:变量 dog 的静态类型(编译期类型)是 Animal,而它的实际类型(运行期类型)是 Dog。重载方法的选择只看静态类型,不看实际类型。所以即使实际指向 Dog 对象,编译器也只认 Animal 类型,匹配 greet(Animal)。

java
// 静态类型 vs 实际类型
Animal a = new Dog();   // 静态类型: Animal, 实际类型: Dog
Dog d = new Dog();      // 静态类型: Dog,    实际类型: Dog

demo.greet(a);  // 静态类型 Animal → greet(Animal)
demo.greet(d);  // 静态类型 Dog     → greet(Dog)

编译器的重载解析过程分为三步(JLS §15.12.2):

  1. 第一阶段:不考虑自动装箱和拆箱,不考虑可变参数,找到所有可能适用的方法
  2. 第二阶段:考虑自动装箱和拆箱,但不考虑可变参数
  3. 第三阶段:考虑可变参数

三个阶段按顺序尝试,某个阶段找到了匹配就停止,不会进入下一阶段。这也是很多"奇怪"重载行为的根源。

252_fig2.png
252_fig2.png

3.2 动态分派 —— 运行时决定

动态分派(Dynamic Dispatch)在运行期根据实际类型查找方法实现。这是 Java 多态的基石。

java
Animal a = new Dog();
a.makeSound();  // 编译器看到 Animal.makeSound()
                // 运行时发现 a 实际是 Dog → 调用 Dog.makeSound()
                // 输出: Woof!

动态分派的执行过程:

  1. JVM 找到操作数栈顶对象的实际类型
  2. 在该实际类型的方法表中查找与被调用方法签名匹配的方法
  3. 如果没找到,沿继承链向上查找父类的方法表
  4. 找到后执行该方法的字节码

252_fig3.png
252_fig3.png

3.3 综合案例:静态分派 + 动态分派

java
public class CombinedDispatch {
    static class Parent {
        public void foo() { System.out.println("Parent.foo"); }
    }
    static class Child extends Parent {
        @Override
        public void foo() { System.out.println("Child.foo"); }
    }

    public void invoke(Parent p) {  // 重载方法 1
        System.out.print("Parent version → ");
        p.foo();  // 动态分派
    }
    public void invoke(Child c) {   // 重载方法 2
        System.out.print("Child version → ");
        c.foo();  // 动态分派
    }


    public static void main(String[ ] args) {

        CombinedDispatch cd = new CombinedDispatch();
        Parent obj = new Child();

        cd.invoke(obj);      // ①
        cd.invoke((Child) obj); // ②
    }
}

输出:

code
Parent version → Child.foo
Child version → Child.foo

分析:

  • ① cd.invoke(obj):静态分派看 obj 的静态类型 Parent → 选择 invoke(Parent) → 动态分派看实际类型 Child → 调用 Child.foo()
  • ② cd.invoke((Child) obj):强转后静态类型变 Child → 选择 invoke(Child) → 动态分派 → 仍调用 Child.foo()

四、JVM 底层:方法调用指令与虚方法表

4.1 五种方法调用指令

JVM 规范定义了五种方法调用字节码指令,每种对应不同的分派策略:

指令 全称 触发场景 分派方式
invokestatic 调用静态方法 static 方法调用 静态分派(编译期确定)
invokespecial 调用特殊方法 构造器 <init>、private 方法、super 调用 静态分派(编译期确定)
invokevirtual 调用虚方法 普通实例方法调用(除 private/final 外) 动态分派(运行时查虚方法表)
invokeinterface 调用接口方法 通过接口引用调用方法 动态分派(运行时查接口方法表)
invokedynamic 动态调用 lambda 表达式、MethodHandle、字符串拼接(Java 9+) 由 bootstrap method 决定

来看一个例子及其字节码:

java
public class BytecodeDemo {
    public static void staticMethod() {}      // invokestatic
    private void privateMethod() {}           // invokespecial
    public void virtualMethod() {}            // invokevirtual


    public static void main(String[ ] args) {

        BytecodeDemo obj = new BytecodeDemo(); // invokespecial (<init>)
        staticMethod();                        // invokestatic
        obj.privateMethod();                   // invokespecial
        obj.virtualMethod();                   // invokevirtual
    }
}

使用 javap -v 查看字节码会看到对应的指令。关键区别在于 invokevirtual 和 invokeinterface 需要运行时查表,而 invokestatic 和 invokespecial 的方法地址在编译/链接阶段就确定了。

252_fig4.png
252_fig4.png

4.2 虚方法表(vtable)

invokevirtual 不是每次都去类元数据里搜索方法 —— 那样太慢了。JVM 使用虚方法表(Virtual Method Table, vtable)来加速动态分派。

vtable 的结构:

  • 每个类在方法区(元空间)中维护一张虚方法表
  • 表中每个条目是一个方法指针,按方法签名排序
  • 子类的 vtable 继承父类的 vtable,然后覆盖被重写的方法条目
  • 非虚方法(private、static、final、构造器)不在 vtable 中

查找过程(以 invokevirtual 为例):

code
1. 获取操作数栈顶对象的实际类型(通过对象头中的 klass 指针)
2. 在该类型的 vtable 中,按固定索引查找目标方法
3. 如果索引超出子类 vtable 范围(父类新增方法),沿继承链向上查找

关键优化:子类重写父类方法时,覆盖 vtable 中相同索引位置的条目,而不是追加到末尾。所以 invokevirtual 的查找是 O(1) 的——直接按索引取指针。

举个例子:

code
Parent 类 vtable:
索引0: Object.toString()     ← 继承自 Object
索引1: Object.hashCode()     ← 继承自 Object
索引2: Parent.foo()

Child extends Parent 的 vtable:
索引0: Object.toString()     ← 未重写,保留
索引1: Object.hashCode()     ← 未重写,保留
索引2: Child.foo()           ← 重写!同索引覆盖
索引3: Child.bar()           ← 新方法,追加

252_fig5.png
252_fig5.png

4.3 itable —— 接口方法表

invokeinterface 的查找比 invokevirtual 更复杂,因为一个类可以实现多个接口,无法用简单的固定索引。JVM 使用 itable(Interface Method Table)来解决。

itable 的查找包含两步:

  1. 在类的 itable 中找到对应接口的子表
  2. 在子表中按索引查找目标方法

这也是为什么接口方法调用比类方法调用略慢——多了一次间接寻址。但现代 JVM 通过内联缓存(Inline Cache)大幅减少了这个开销。


五、协变返回类型(Covariant Return Type)

5.1 什么是协变返回类型

Java 5(JDK 1.5)引入了协变返回类型:子类重写父类方法时,可以返回父类方法返回类型的子类型。

java
class Animal {
    public Animal reproduce() {
        return new Animal();
    }
}

class Dog extends Animal {
    @Override
    public Dog reproduce() {  // 返回 Dog 而非 Animal —— 协变!
        return new Dog();
    }
}

// 调用方
Dog dog = new Dog();
Dog puppy = dog.reproduce();  // 不需要强制转型!

5.2 协变的底层实现 —— 桥接方法

问题:JVM 认为返回类型不同就是不同方法,那 Dog.reproduce() 返回 Dog 怎么还能算重写?

答案:编译器在子类中自动生成了一个桥接方法(Bridge Method)。

java
// 编译后等价于:
class Dog extends Animal {
    @Override
    public Animal reproduce() {   // 编译器自动生成的桥接方法
        return this.reproduce();  // 调用真正返回 Dog 的方法
    }

    public Dog reproduce() {      // 程序员写的"重写"方法(实际在 JVM 层面是新方法)
        return new Dog();
    }
}

桥接方法的签名与父类完全一致,满足 JVM 的重写检测;但它只是转发调用。这是 Java 泛型擦除、协变返回类型等特性的同一底层机制。

252_fig6.png
252_fig6.png

5.3 协变返回类型的实际应用

协变返回类型最大的价值在流式 API 和 Builder 模式中:

java
class GenericBuilder<T extends GenericBuilder<T>> {
    public T self() { return (T) this; }
}

class CarBuilder extends GenericBuilder<CarBuilder> {
    public CarBuilder color(String color) { /* ... */ return self(); }
    public CarBuilder engine(String engine) { /* ... */ return self(); }
}

// 使用
new CarBuilder()
    .color("red")    // 返回 CarBuilder,可以继续链式调用
    .engine("V8");   // 不需要强制转型

六、JLS 方法选择规则:从重载到"最具体方法"

6.1 重载解析的三阶段(JLS §15.12.2)

JLS 将重载方法的选择分为三个阶段,按优先级逐步放宽:

第一阶段(严格匹配,无装箱/拆箱/变长参数): 直接按参数个数和类型匹配,不涉及自动装箱、拆箱和变长参数。

第二阶段(允许装箱/拆箱): 如果第一阶段没找到,允许自动装箱(int → Integer)和拆箱(Integer → int)。

第三阶段(允许变长参数): 前两个阶段都没找到,允许将实参打包成变长参数的数组。

java
public void foo(int a)           { System.out.println("int"); }
public void foo(Integer a)       { System.out.println("Integer"); }
public void foo(int... a)        { System.out.println("int..."); }

foo(1);  // 第一阶段匹配 foo(int) → 输出 "int"
foo(Integer.valueOf(1));  // 第一阶段匹配 foo(Integer) → 输出 "Integer"

6.2 "最具体方法" 原则

当多个方法都适用时,编译器选择最具体的那个。一个方法比另一个更具体,当且仅当它的每个形式参数都能赋值给另一个方法的对应参数。

java
public void foo(CharSequence cs) { System.out.println("CharSequence"); }
public void foo(String s)        { System.out.println("String"); }

foo("hello");  // String 是 CharSequence 的子类型 → foo(String) 更具体 → 输出 "String"
foo((CharSequence) "hello");  // 强制声明为 CharSequence → 输出 "CharSequence"

关键规则:

  • 子类型比父类型更具体
  • 如果两个方法没有继承关系(如 foo(String) 和 foo(Integer)),则不构成"谁更具体",此时编译报错:ambiguous method call

252_fig7.png
252_fig7.png

6.3 模糊匹配导致编译失败

java
public void foo(String s, Integer i) { }
public void foo(Integer i, String s) { }

foo("hello", 42);     // 编译通过:匹配第一个
foo(42, "hello");     // 编译通过:匹配第二个
foo(null, null);      // 编译报错!两个方法都适用,无法判断哪个更具体

当实参为 null 时,它同时匹配 String 和 Integer,编译器无法确定,报错 reference to foo is ambiguous。


七、常见陷阱与避坑指南

7.1 陷阱一:自动装箱导致的重载歧义

java
public void remove(int index)       { System.out.println("remove by index"); }
public void remove(Integer element) { System.out.println("remove by element"); }

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);

list.remove(1);    // 调用哪个? → remove(int index),删除索引1的元素
list.remove(Integer.valueOf(1)); // → remove(Integer element),删除值为1的元素

List.remove(int) 和 List.remove(Object) 是 Java 集合 API 中最经典的"重载陷阱"。自动装箱只发生在第一阶段匹配无果后才进入第二阶段,而第一阶段 remove(int) 精确匹配,所以 remove(1) 永远不会走 remove(Integer)。

7.2 陷阱二:泛型擦除导致的桥接冲突

java
class Parent<T> {
    public void foo(T t) { System.out.println("Parent.foo"); }
}

class Child extends Parent<String> {
    @Override
    public void foo(String s) { System.out.println("Child.foo"); }
}

编译后,Parent.foo 的签名实际上是 foo(Object)(泛型擦除)。而 Child.foo 签名是 foo(String)。编译器自动生成桥接方法 Child.foo(Object) 转发到 Child.foo(String),确保 JVM 层面的重写检测通过。

7.3 陷阱三:静态方法的"隐藏"而非"重写"

java
class Parent {
    public static void hello() { System.out.println("Parent.hello"); }
}

class Child extends Parent {
    public static void hello() { System.out.println("Child.hello"); }
}

Parent p = new Child();
p.hello();  // 输出 Parent.hello!静态方法依赖引用类型,不依赖实际类型

static 方法不能被重写——它们在编译期就用 invokestatic 固化了调用目标。这叫方法隐藏(Method Hiding),不是重写。

7.4 陷阱四:private 方法的"重写"假象

java
class Parent {
    private void secret() { System.out.println("Parent.secret"); }
    public void call() { secret(); }
}

class Child extends Parent {
    // 这不是重写!这是 Child 自己的全新方法
    private void secret() { System.out.println("Child.secret"); }
}

new Child().call();  // 输出 Parent.secret!

private 方法对子类不可见,在子类中定义同名方法只是凑巧同名,与父类方法没有任何关系。父类中的 call() 通过 invokespecial 绑定了父类的 secret(),不受子类影响。

7.5 陷阱五:可变参数与重载的二义性

java
public void foo(int... nums)   { System.out.println("int..."); }
public void foo(Integer... nums) { System.out.println("Integer..."); }

foo(1, 2, 3);  // 编译报错!int... 和 Integer... 没有"谁更具体"

可变参数的方法在只有一个时走第一阶段精确匹配,但两个都是变长参数且没有明确的"更具体"关系时,编译失败。


252_fig8.png
252_fig8.png

八、生产实践建议

8.1 重载设计原则

  1. 避免使用相同参数数量的重载——尤其当参数类型有继承关系时,调用方容易混淆
  2. 参数类型保持正交——尽量不要有 foo(String) 和 foo(CharSequence) 这样的重载
  3. 如果必须重载相关类型,在文档中明确写出分派规则——让调用方在 IDE 中就能看到哪个方法会被选中
  4. 考虑使用不同方法名代替重载——如 valueOfInt() / valueOfString() 比 valueOf(int) / valueOf(String) 更清晰

8.2 重写设计原则

  1. 始终使用 **@Override** 注解——编译器帮你检查是否真的重写了父类方法,并捕获拼写错误
  2. 遵循 Liskov 替换原则(LSP)——子类重写不应改变父类方法的核心语义契约
  3. 不要在重写方法中改变参数校验的严格程度——要么相同,要么更宽松(符合 LSP)
  4. 考虑到 **equals()** 和 **hashCode()** 的契约——重写一个必须重写另一个
  5. 避免在构造器中调用可重写的方法——此时子类构造器还没执行,对象状态不完整

8.3 调试技巧:确认哪个方法被调用

遇到预期之外的方法分派行为时,可以:

java
// 1. 查看字节码确认编译期决策
javap -c -v YourClass.class

// 2. 运行时打调用栈
new Exception("Method dispatch trace").printStackTrace();

// 3. Arthas 动态监控
watch com.example.YourClass yourMethod '{params, returnObj}' -x 3

九、总结

  1. 重载 = 静态分派:编译期根据引用类型(静态类型)决定,invokestatic / invokespecial 固定调用目标
  2. 重写 = 动态分派:运行时根据实际类型查虚方法表(vtable),invokevirtual / invokeinterface 走虚方法分发
  3. 协变返回类型:子类返回更具体的类型,编译器通过桥接方法实现
  4. JLS 方法选择:三阶段重载解析 + "最具体方法"原则
  5. 注意:static 方法不被重写(隐藏),private 方法不被重写(不可见),字段不被重写(隐藏)

一句话:重载解决"编译期看到什么类型"的问题,重写解决"运行期实际是什么类型"的问题。


十、面试追问

以下问题从面试官视角设计,考察对方法分派机制的深入理解和边界场景的处理能力。

Q1:Java 是静态多分派、动态单分派的语言,这句话怎么理解?

Q2:下面代码输出什么?为什么?

java
class A {
    public void foo(A a) { System.out.println("A.foo(A)"); }
}
class B extends A {
    public void foo(A a) { System.out.println("B.foo(A)"); }
    public void foo(B b) { System.out.println("B.foo(B)"); }
}

A obj = new B();
obj.foo(obj);  // ?

Q3:**invokevirtual** 和 **invokeinterface** 的查找过程有什么本质区别?为什么接口方法调用通常比类虚方法调用慢?现代 JVM 如何优化?

Q4:下面代码能通过编译吗?如果能,**Child.foo(null)** 调用哪个方法?

java
class Parent {
    public void foo(String s) { }
}
class Child extends Parent {
    public void foo(String s) { }
    public void foo(Integer i) { }
}

Q5:泛型擦除后,为什么 **List.remove(int)** 和 **List.remove(Object)** 不冲突?分别说说它们背后的重载解析过程。

Q6:设计一个场景说明重载方法在编译期确定但运行时可能"失效"(例如通过反射调用)。

Q7:如果你需要设计一个"永不混淆"的重载方法命名规则给团队,你会定什么原则?跟面试官讨论一下合理性。

Q8:**default** 方法(接口默认方法)在多接口继承场景下的方法选择规则是什么?与类继承的重写有什么不同?


本文为"Java 技术专家与架构师面试:250 个高频考点总览"系列第 002 篇。下一篇预告:003|接口与抽象类:设计取舍、默认方法以及面向接口编程。

十(续)· 面试追问参考答案


A1:Java 是静态多分派、动态单分派的语言,这句话怎么理解?

参考答案:

分派(Dispatch) 指的是根据参数类型决定调用哪个方法的过程。按发生时机分为静态分派(编译期)和动态分派(运行期)。

静态多分派:编译期选择重载方法时,需要考虑两个维度——调用者的类型和所有参数的类型。因为涉及多个宗量(参数),所以称为"多分派"(Multi-Dispatch)。但这个决策在编译期就固化了(静态)。

java
class Parent {
    public void foo(Parent p) { System.out.println("P.foo(P)"); }
}
class Child extends Parent {
    public void foo(Parent p) { System.out.println("C.foo(P)"); }
    public void foo(Child c)  { System.out.println("C.foo(C)"); }
}

Parent a = new Child();
Parent b = new Child();
a.foo(b);  // 输出 "C.foo(P)"

分析:编译期静态分派,宗量一是 a 的静态类型 Parent(决定候选方法范围——只能看到 Parent 中定义的方法),宗量二是 b 的静态类型 Parent(决定重载版本)。这两个宗量都在编译期确定 → 静态多分派。

动态单分派:运行时执行虚方法调用,JVM 只根据一个宗量(方法接收者的实际类型)来决定调用哪个实现——这就是动态分派。参数的实际类型不影响方法选择(这点与有些语言如 C# 的 dynamic 不同)。

总结一句话:编译期同时看调用者的静态类型和参数的静态类型选重载版本(静态多分派);运行时只看接收者的实际类型选重写版本(动态单分派)。


A2:下面代码输出什么?为什么?

java
class A {
    public void foo(A a) { System.out.println("A.foo(A)"); }
}
class B extends A {
    public void foo(A a) { System.out.println("B.foo(A)"); }
    public void foo(B b) { System.out.println("B.foo(B)"); }
}

A obj = new B();
obj.foo(obj);  // ?

答案:输出 B.foo(A)

分析:

  1. 静态分派(选重载版本):obj 的静态类型是 A,编译器只能在 A 中查找候选方法。A 中只有一个 foo(A)。所以编译期确定调用签名为 foo(A)。注意:B.foo(B) 不在候选集里,因为编译器看的是引用类型 A。
  2. 动态分派(选重写版本):运行时 obj 的实际类型是 B,B 重写了 foo(A),所以 JVM 通过虚方法表找到 B.foo(A) 并执行。

常见误区:很多候选人会猜 B.foo(B),认为"既然实际类型是 B,参数也是 B,应该调用参数更匹配的版本"。这混淆了静态分派和动态分派的边界——重载只看静态类型,不看实际类型。


A3:**invokevirtual** 和 **invokeinterface** 的查找过程有什么本质区别?为什么接口方法调用通常比类虚方法调用慢?现代 JVM 如何优化?

参考答案:

查找过程的本质区别:

维度 invokevirtual invokeinterface
数据结构 vtable(虚方法表) itable(接口方法表)
查找方式 固定索引 O(1) 两步查找:先定位接口子表,再按索引取方法
继承关系 单继承,vtable 索引天然兼容 多实现,无法用固定索引覆盖所有接口
方法偏移量 编译期确定 vtable index 编译期确定接口内 index,但需先找到接口子表

为什么接口方法调用更慢:

  • invokevirtual 的 vtable index 是编译期确定的常量,一条 invokevirtual 指令直接关联到固定索引,运行时只需一次内存访问
  • invokeinterface 需要先从对象 klass 的 itable 中找到对应接口的子表(遍历接口列表),再在子表中按索引查找。多了一次间接寻址

现代 JVM 的优化:

  1. 内联缓存(Inline Cache):在调用点缓存上次解析的方法地址。如果下次调用的接收者类型相同,直接跳转,绕过查表。HotSpot 使用多态内联缓存支持少量不同类型的缓存。
  2. CHA(Class Hierarchy Analysis):如果 JIT 编译器能证明某个接口只有唯一的实现类,就可以将 invokeinterface 退化为直接调用甚至内联。
  3. 类型预测(Type Profile):JIT 收集运行时类型分布数据,如果某个接口调用 90% 都是同一个实现类,就生成"快速路径 + 慢速路径"的分支代码。
  4. 逃逸分析与标量替换:如果接口类型的对象没有逃逸出方法,直接拆解为标量,消除方法调用。

在实践中,经过 JIT 预热后,接口方法调用与类虚方法调用的性能差距通常<5%,不再是系统瓶颈。


A4:下面代码能通过编译吗?如果能,**Child.foo(null)** 调用哪个方法?

java
class Parent {
    public void foo(String s) { }
}
class Child extends Parent {
    public void foo(String s) { }
    public void foo(Integer i) { }
}

答案:代码能通过编译。但 new Child().foo(null) 编译会报错:reference to foo is ambiguous。

分析:

  1. 代码能编译:Child.foo(String) 是 Parent.foo(String) 的重写(签名完全一致),Child.foo(Integer) 是 Child 中的新重载方法。两者没有冲突——参数类型不同(String vs Integer)。
  2. 调用歧义:null 字面量可以被赋值给任何引用类型,它既是合法的 String 值,也是合法的 Integer 值。编译器在重载解析的第一阶段发现 foo(String) 和 foo(Integer) 都适用,且 String 和 Integer 之间没有继承关系,无法判断哪个"更具体"——因此报歧义错误。
  3. 如何解决:显式指定参数类型:
java
new Child().foo((String) null);  // 调用 Child.foo(String)
new Child().foo((Integer) null); // 调用 Child.foo(Integer)

延伸:这个问题的深层考点是 JLS §15.12.2.5 的"最具体方法"规则。两个方法都适用时,只有当其中之一的每个形式参数类型都能赋值给另一个的对应形式参数时,前者才"更具体"。String 和 Integer 都不满足这个条件,所以歧义。


A5:泛型擦除后,为什么 **List.remove(int)** 和 **List.remove(Object)** 不冲突?分别说说它们背后的重载解析过程。

参考答案:

这两个方法不冲突,因为它们是合法的、普通的 Java 方法重载——参数类型不同(int 是基本类型,Object 是引用类型),签名本就不同。泛型擦除只影响泛型参数,与这两个方法无关。

但真正的问题是:泛型擦除后,List<E> 中的其他方法签名会变成什么样?例如 add(E e) 擦除后变成 add(Object)。如果某个 List 子类同时有 add(Object) 和 add(int index, Object) 方法,它们因为参数个数不同而不冲突。

回到 **remove** 的两个重载版本的重载解析过程:

java
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);

list.remove(1);    // ① 调用 remove(int index)   → 删除索引 1,list → [1, 3]
list.remove(Integer.valueOf(1)); // ② 调用 remove(Object o) → 删除元素 1,list → [2, 3]
  • ① **list.remove(1)**:实参是 int。重载解析第一阶段精确匹配 remove(int),直接选中,不进入第二阶段(自动装箱)。
  • ② **list.remove(Integer.valueOf(1))**:实参是 Integer。第一阶段找不到 remove(Integer)(List 没有这个重载),进入第二阶段允许自动拆箱 → remove(int) 适用;同时 remove(Object) 也适用(Integer is-a Object)。此时第一阶段 remove(Object) 本身其实已经精确匹配了——JLS 规定第一阶段在精确匹配存在时会优先采用精确匹配的版本。所以第二阶段虽然 remove(int) 也适用,但第一阶段已经有了 remove(Object) 这个精确匹配,选它。

关键点:remove(int) 和 remove(Object) 的共存导致了一个 API 设计上的"丑陋角落"——调用者需要非常小心自己传的是 int 还是 Integer。这也是为什么很多团队规范要求使用 remove(index) 和 removeElement(obj) 这样更明确的命名,或者在 Code Review 中对 List.remove() 的调用特别关注。


A6:设计一个场景说明重载方法在编译期确定但运行时可能"失效"(例如通过反射调用)。

参考答案:

场景设计:通过反射调用重载方法,绕过编译期静态类型检查,直接指定要调用的方法签名。

java
public class ReflectionDispatchDemo {
    public void handle(String s) {
        System.out.println("handle(String): " + s);
    }
    public void handle(Object o) {
        System.out.println("handle(Object): " + o);
    }


    public static void main(String[ ] args) throws Exception {

        ReflectionDispatchDemo demo = new ReflectionDispatchDemo();

        // 场景一:正常调用 —— 静态分派,选 handle(String)
        demo.handle("hello");  // 输出: handle(String): hello

        // 场景二:反射调用 —— 绕过静态类型,直接指定方法签名
        // 故意调用 handle(Object),虽然我们传的是 String
        Method m = ReflectionDispatchDemo.class.getMethod("handle", Object.class);
        m.invoke(demo, "hello");  // 输出: handle(Object): hello
        // ↑ 编译期静态分派被"绕过"了!

        // 场景三:更隐蔽的例子 —— 通过接口引用
        Runnable task = new Runnable() {
            public void run() { System.out.println("anonymous run"); }
        };
        // demo.handle(task) 在编译期会选择 handle(Object)
        // 但如果通过反射精确指定 handle(Runnable) —— 虽然这个方法不存在
        // getMethod("handle", Runnable.class) → NoSuchMethodException!
    }
}

更真实的业务场景:框架/中间件中通过反射调用目标方法时,如果方法有重载版本,框架的 Method 查找逻辑必须显式匹配参数类型,否则可能调用到错误的版本。例如:

java
// Spring 框架中类似的场景
// Bean 有一个重载的 init 方法
public class MyService {
    public void init(ApplicationContext ctx) { /* 完整初始化 */ }
    public void init()                      { /* 简单初始化 */ }
}

// 如果 Spring 用 getMethod("init") 去反射调用,拿到的是无参版本
// 但业务可能期望调用有参版本完成完整初始化

核心启示:

  • 反射调用时,方法选择完全由 getMethod/getDeclaredMethod 传入的参数类型决定,编译器的静态分派规则不再参与
  • 框架设计者在反射调用有重载目标的方法时,必须显式指定参数类型,避免歧义
  • 这也是为什么很多框架(如 Spring)在反射调用前会先验证目标方法的唯一性

A7:如果你需要设计一个"永不混淆"的重载方法命名规则给团队,你会定什么原则?跟面试官讨论一下合理性。

参考答案:

我会制定以下规则,并按优先级排列:

规则 1:禁止"可隐式转换的参数类型"重载

java
// ❌ 禁止
void process(String s);
void process(CharSequence s);  // String is-a CharSequence,容易混淆

// ✅ 允许
void processString(String s);
void processCharSequence(CharSequence s);

理由:参数类型有继承关系时,调用方的静态类型决定了哪个方法被选中,而这个规律往往不是直觉能直接判断的。用不同方法名消除歧义。

规则 2:禁止相同参数数量的重载,除非参数类型正交(无继承关系且无可隐式转换)

java
// ❌ 禁止 —— int 和 Integer 可通过自动装箱互转
void remove(int index);
void remove(Integer value);

// ✅ 允许 —— 参数类型完全正交
void send(String message);
void send(int code);

// ⚠️ 灰区 —— 建议用不同方法名
void find(String name);
void find(Long id);
// 更好的写法:
void findByName(String name);
void findById(Long id);

规则 3:可变参数方法不与任何同参数类型的固定参数方法重载

java
// ❌ 禁止
void log(String msg);
void log(String... msgs);  // 单个 String 传参时走第一阶段精确匹配,不调用可变参数版本

// ✅ 允许:明确区分
void log(String msg);
void logBatch(String... msgs);

规则 4:所有重载方法必须有对应的 Javadoc **@see** 互相引用,并显式说明分派规则

java
/**
 * 按索引删除元素。
 * @param index 要删除的元素索引
 * @see #removeElement(Object) 按值删除元素
 */
void removeAt(int index);

/**
 * 按值删除第一个匹配元素。
 * @param element 要删除的元素值
 * @see #removeAt(int) 按索引删除元素
 */
void removeElement(Object element);

合理性讨论:

  • 优点:消除认知负担,降低 Bug 率,Code Review 效率更高
  • 缺点:方法名变长,IDE 补全列表更长;跟 JDK 标准 API 风格不一致(如 List.remove(int) vs List.remove(Object))
  • 折中:对内/核心模块严格遵循,对外的公共 API 可以适当放宽(因为有充分的文档和 IDE 提示);对 JDK 风格的已有包袱,用 Checkstyle/PMD 规则做静态扫描告警而非一刀切禁止

A8:**default** 方法(接口默认方法)在多接口继承场景下的方法选择规则是什么?与类继承的重写有什么不同?

参考答案:

default 方法的选择规则(JLS §9.4.1.3)遵循三条优先级:

  1. 类优先于接口:类/父类中的方法声明(无论是否重写)优先于任何接口中的 default 方法。
  2. 最具体接口优先:如果类没有声明该方法,且多个接口都提供了 default 实现,则选择继承链上"最具体"的接口的 default 实现(即子接口优先于父接口)。
  3. 歧义时编译报错:如果两个不相关的接口(没有继承关系)提供了相同的 default 方法签名,且类没有覆盖该方法,则编译报错,要求类显式选择或重写。

代码示例:

java
interface A {
    default void hello() { System.out.println("A"); }
}
interface B extends A {
    default void hello() { System.out.println("B"); }  // B 比 A 更具体
}
interface C {
    default void hello() { System.out.println("C"); }
}

// 场景1:类优先
class Parent {
    public void hello() { System.out.println("Parent"); }
}
class Child1 extends Parent implements A { }
new Child1().hello();  // 输出 "Parent" —— 类优先

// 场景2:最具体接口优先
class Child2 implements A, B { }
new Child2().hello();  // 输出 "B" —— B extends A,B 更具体

// 场景3:歧义 → 编译报错
class Child3 implements A, C {
    // 必须显式解决冲突:
    @Override
    public void hello() {
        A.super.hello();  // 显式选择 A 的实现
    }
}

与类继承重写的关键不同:

维度 类继承重写 default 方法
冲突来源 单继承,不会有同一方法多个版本 多实现,多个接口可能有同名 default 方法
歧义 不存在歧义——单继承链唯一 可能歧义——两个无继承关系的接口提供同名 default
解决方式 运行时虚方法表自动处理 编译期检查 + 显式指定 InterfaceName.super.method()
super 调用 super.method() InterfaceName.super.method() —— 必须指定接口名
访问控制 遵循访问修饰符 default 方法隐式为 public

设计启示:Java 8 引入 default 方法时面临的"钻石问题"(Diamond Problem)在 C++ 中是多继承的经典难题。Java 通过"类优先 + 编译期歧义检查"来规避运行时不确定性,把问题暴露在编译期而非运行时,这是 Java 语言设计的"安全优先"哲学的体现。