Java 基础体系 · 第 38/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 方法与重载:参数传递、可变参数、解析规则和 API 设计

Java 中的方法同时承担三件事:

  1. 封装一段可复用的行为;
  2. 通过参数和返回值建立类型边界;
  3. 通过重载为同一操作提供不同的调用形式。

但“调用一个方法”并不是简单地按名字查找。编译器需要先判断哪些方法可能适用,再按照固定的转换阶段筛选,最后在多个候选者中选择最具体的方法。可变参数、自动装箱、泛型、null、Lambda 和方法引用都会影响这个过程。

本文以 Java SE 25 / Java Language Specification 25 的规则为准,重点解释方法声明、参数传递、可变参数、重载解析和 API 设计之间的关系。

方法的基本结构

一个方法声明通常由以下部分组成:

public static int add(int left, int right) {
    return left + right;
}

可以拆分为:

  • public:访问权限;
  • static:方法属于类,而不是某个对象实例;
  • int:返回类型;
  • add:方法名;
  • (int left, int right):形式参数列表;
  • 方法体:执行逻辑并产生结果。

调用时提供的值称为实际参数,也常称为实参:

int result = add(2, 3);

这里:

  • leftright 是形式参数;
  • 23 是实际参数;
  • result 接收返回值。

实例方法和静态方法

实例方法需要通过对象调用:

class Counter {
    private int value;

    void increment() {
        value++;
    }

    int current() {
        return value;
    }
}

Counter counter = new Counter();
counter.increment();
System.out.println(counter.current());

实例方法隐含一个调用者对象,也就是通常所说的 thiscurrent() 实际上读取的是当前 Counter 对象的状态。

静态方法属于类:

class MathUtil {
    static int square(int value) {
        return value * value;
    }
}

int result = MathUtil.square(5);

静态方法没有隐含的 this,不能直接访问实例字段或调用实例方法。

静态方法和实例方法可以同名,只要参数列表形成合法的重载关系;但是它们不能仅靠 static 与否区分:

class Example {
    void run(int value) {
    }

    // 编译错误:不能只通过 static 区分
    // static void run(int value) {
    // }
}

返回值不是方法签名的一部分

以下声明不能共存:

class Invalid {
    int parse(String text) {
        return 0;
    }

    // 编译错误:仅返回类型不同
    // long parse(String text) {
    //     return 0L;
    // }
}

Java 方法重载不能仅根据返回类型区分。因为调用表达式可能处于语句上下文中:

parse("123");

此时调用者根本没有使用返回值,编译器也就无法根据目标类型选择方法。

在 Java 语言规范中,方法签名主要由方法名、类型参数以及形式参数类型组成,而不包括返回类型、throws 子句和访问修饰符。

方法调用中的参数传递:Java 始终按值传递

Java 只有一种参数传递方式:按值传递

所谓按值传递,是指调用方法时,方法接收的是实际参数值的一份副本。对于对象变量,这个值本身是对象引用,因此常见表述是“传递对象引用的副本”。

这两种情况必须分开理解:

  • 基本类型变量中保存的是数值;
  • 引用类型变量中保存的是引用值,而不是对象本身。

基本类型参数

static void change(int value) {
    value = 100;
}

int number = 10;
change(number);

System.out.println(number); // 10

执行过程是:

  1. number 的值是 10
  2. 调用 change(number) 时,把数值 10 复制给形式参数 value
  3. 方法内部把 value 改成 100
  4. value 是局部参数副本,number 不受影响。

可以用下面的关系表示:

number  = 10
value   = 10   // 调用时复制

方法内:
value   = 100

方法结束:
number 仍为 10

因此,Java 方法不能通过修改基本类型参数来修改调用者的局部变量。

引用类型参数

static void rename(StringBuilder builder) {
    builder.append(" world");
}

StringBuilder text = new StringBuilder("hello");
rename(text);

System.out.println(text); // hello world

这里不是“按引用传递”。实际过程是:

调用者变量 text  ─────┐
                      ├──> 同一个 StringBuilder 对象
方法参数 builder ─────┘

调用时复制的是引用值,因此 textbuilder 指向同一个对象。方法通过 builder 修改对象状态,调用者随后观察到了变化。

但方法不能通过给参数重新赋值来替换调用者变量:

static void replace(StringBuilder builder) {
    builder = new StringBuilder("new object");
}

StringBuilder text = new StringBuilder("old object");
replace(text);

System.out.println(text); // old object

执行过程为:

调用前:
text    ──> old object

进入方法后:
builder ──> old object

执行 builder = new StringBuilder(...):
builder ──> new object
text    ──> old object

参数变量 builder 被改为指向新对象,但这不会反向修改调用者变量 text

数组参数也是引用值

数组是对象,因此数组参数遵循同样的规则:

static void changeFirst(int[] values) {
    values[0] = 99;
}

static void replaceArray(int[] values) {
    values = new int[] {7, 8, 9};
}

int[] numbers = {1, 2, 3};

changeFirst(numbers);
System.out.println(numbers[0]); // 99

replaceArray(numbers);
System.out.println(numbers[0]); // 99

changeFirst 修改了同一个数组对象,所以调用者能看到变化;replaceArray 只改变了参数副本的指向。

final 参数的作用

final 参数不能在方法体内被重新赋值:

static void print(final String text) {
    // text = "other"; // 编译错误
    System.out.println(text);
}

对于引用类型,final 只限制引用变量本身,不能使对象不可变:

static void append(final StringBuilder builder) {
    builder.append("x"); // 合法
    // builder = new StringBuilder(); // 不合法
}

因此,final StringBuilder 表示“不能让参数改指向其他对象”,不表示“StringBuilder 对象不能修改”。

参数表达式的求值顺序

方法调用中的实际参数表达式按从左到右的顺序求值。方法调用者表达式也会先被求值。

static int next(String name, int[] counter) {
    System.out.println(name);
    return counter[0]++;
}

static void accept(int first, int second) {
    System.out.println(first + ", " + second);
}

int[] counter = {1};
accept(
    next("first", counter),
    next("second", counter)
);

输出为:

first
second
1, 2

求值过程是:

  1. 先求第一个参数,打印 first,返回 1
  2. 再求第二个参数,打印 second,返回 2
  3. 最后进入 accept

重载选择本身在编译期完成,但实际参数表达式的运行时求值发生在真正进入方法之前。不要把“选择哪个重载”和“参数表达式何时执行”混为一谈。

什么是方法重载

方法重载是指在同一个类中使用相同的方法名,声明多个参数列表不同的方法。

class Formatter {
    String format(int value) {
        return Integer.toString(value);
    }

    String format(long value) {
        return Long.toString(value);
    }

    String format(String value) {
        return value;
    }
}

这些方法的参数类型不同,因此可以共存。

以下差异不能形成重载:

  • 只改变返回类型;
  • 只改变访问权限;
  • 只增加或删除 static
  • 只改变 throws 子句;
  • 只改变参数名。

例如:

void save(String value) {
}

// 参数名不同,不是重载
// void save(String text) {
// }

重载与重写不同

重载发生在编译期,关注同一个类或继承体系中有哪些参数列表不同的方法。

重写发生在运行期多态调用中,子类用兼容签名替换父类实例方法的实现。

class Animal {
    void speak() {
        System.out.println("animal");
    }
}

class Dog extends Animal {
    @Override
    void speak() {
        System.out.println("dog");
    }
}

Animal animal = new Dog();
animal.speak(); // dog

这里 speak() 没有重载;实际执行哪个实现由运行时对象类型决定。

如果同时存在重载,编译期先选定签名,运行期再针对这个签名执行重写后的实现:

class Printer {
    void print(Object value) {
        System.out.println("Object");
    }

    void print(String value) {
        System.out.println("String");
    }
}

Object value = "text";
new Printer().print(value); // Object

变量的静态类型是 Object,所以编译期选择 print(Object);即使对象实际是 String,也不会改选 print(String)

重载解析的总体流程

当编译器看到:

printer.print(value);

它大致执行以下步骤:

  1. 根据名字、可见性和调用形式找出潜在候选方法;
  2. 判断每个候选方法是否能通过某种参数转换接收这些实参;
  3. 按转换成本分阶段筛选;
  4. 如果剩下多个候选方法,选择最具体的方法;
  5. 如果没有唯一最优方法,报告编译错误。

Java 方法调用的重载解析主要分为三个阶段:

  1. 严格调用上下文:不依赖自动装箱、拆箱和可变参数打包;
  2. 宽松调用上下文:允许装箱、拆箱等方法调用转换;
  3. 可变参数阶段:允许把若干实参打包成数组。

核心原则是:只要前一个阶段已经找到适用方法,后面的阶段就不再参与选择。

第一阶段:优先使用不需要装箱的匹配

class OverloadDemo {
    static void choose(int value) {
        System.out.println("int");
    }

    static void choose(Integer value) {
        System.out.println("Integer");
    }

    public static void main(String[] args) {
        choose(10);
    }
}

输出:

int

字面量 10 的类型是 int。调用 choose(int) 使用恒等转换;调用 choose(Integer) 需要自动装箱。

由于 choose(int) 在第一阶段已经适用,编译器不会进入需要装箱的阶段。

反过来:

Integer value = 10;
choose(value);

此时:

  • choose(Integer) 可以直接接收 Integer
  • choose(int) 需要拆箱。

因此选择 choose(Integer)

基本类型的扩大转换

static void choose(long value) {
    System.out.println("long");
}

static void choose(Integer value) {
    System.out.println("Integer");
}

choose(10);

输出:

long

intlong 是基本类型扩大转换,属于前面的严格阶段;intInteger 需要装箱,属于后面的宽松阶段。

这也是一个常见结果:

static void process(long value) {
    System.out.println("long");
}

static void process(Object value) {
    System.out.println("Object");
}

process(1);

选择 process(long),而不是先把 1 装箱为 Integer,再扩大引用转换为 Object。编译器优先使用不需要装箱的方案。

引用类型的最具体匹配

static void print(Object value) {
    System.out.println("Object");
}

static void print(String value) {
    System.out.println("String");
}

print("hello");

两个方法都可以接收字符串:

  • StringObject 是扩大引用转换;
  • StringString 是恒等匹配。

因此选择参数类型更具体的 print(String)

从类型关系看:

String <: Object

StringObject 的子类型,所以 print(String)print(Object) 更具体。

需要多个转换时的优先级

static void test(long value) {
    System.out.println("long");
}

static void test(Object value) {
    System.out.println("Object");
}

test(1);

候选路径分别是:

int -> long
int -> Integer -> Object

第一条只需要基本类型扩大转换,第二条需要装箱再扩大引用转换。第一阶段已经选出 test(long),所以输出:

long

这不是简单按“目标类型看起来更近”决定,而是先按调用转换阶段筛选。

最具体方法与歧义

如果多个候选方法处于同一阶段,编译器需要判断某个方法是否比其他方法更具体。

可以确定唯一方法

static void send(Object value) {
    System.out.println("Object");
}

static void send(CharSequence value) {
    System.out.println("CharSequence");
}

static void send(String value) {
    System.out.println("String");
}

send("abc"); // String

类型关系为:

String
  └── 实现 CharSequence
  └── 继承 Object

String 可以转换为 CharSequenceObject,但反过来不成立,因此 send(String) 最具体。

两个无关类型会导致歧义

static void handle(String value) {
}

static void handle(Integer value) {
}

// handle(null); // 编译错误:调用有歧义

null 可以转换为任意引用类型,所以两个方法都适用。但 StringInteger 互相都不是对方的子类型,编译器无法选出更具体的方法。

可以通过显式转换消除歧义:

handle((String) null);
handle((Integer) null);

这会把调用表达式的静态类型明确为对应类型。

如果 API 同时提供大量引用类型重载,null 调用就可能变得脆弱。调用者被迫使用类型转换,通常说明 API 的入口设计需要重新评估。

可变参数:数组语法上的调用便利

可变参数允许方法接收数量不固定的同类型参数:

static int sum(int... values) {
    int result = 0;
    for (int value : values) {
        result += value;
    }
    return result;
}

调用方式:

System.out.println(sum());             // 0
System.out.println(sum(1));            // 1
System.out.println(sum(1, 2, 3, 4));   // 10

声明中的:

int... values

本质上等价于:

int[] values

可变参数必须是最后一个参数,而且一个方法最多只能有一个可变参数:

static void log(String level, String... messages) {
    for (String message : messages) {
        System.out.println(level + ": " + message);
    }
}

调用:

log("INFO");
log("INFO", "start", "connected");

编译器会把可变参数调用转换成数组形式,概念上类似于:

log("INFO", new String[] {"start", "connected"});

可变参数方法也可以直接接收数组

static void show(String... values) {
    System.out.println(values.length);
}

show("a", "b");                  // 2
show(new String[] {"a", "b"});   // 2
show();                          // 0

当传入一个 String[] 时,可以直接把它作为数组参数传入,而不是再创建一层数组。

可变参数方法在重载解析中有一个重要细节:它的声明参数类型实际上是数组类型,因此某些调用可以在前面的固定参数阶段直接适用。

static void read(String... values) {
    System.out.println("varargs");
}

read(new String[] {"a", "b"});

这里并没有把多个字符串重新打包成数组;实参本身已经是 String[]。而下面的调用才需要可变参数阶段进行打包:

read("a", "b");

可变参数与固定参数重载

static void print(String value) {
    System.out.println("fixed");
}

static void print(String... values) {
    System.out.println("varargs");
}

print("hello");

输出:

fixed

print(String) 是固定参数方法,可以在前面的阶段适用;print(String...) 在这里若按多个独立参数打包,需要可变参数阶段。因此固定参数方法优先。

但显式传数组时:

print(new String[] {"hello"});

只有 print(String...) 能接收 String[],因此调用可变参数方法。

空参数和数组参数

static void inspect(String... values) {
    System.out.println(values == null ? "null" : values.length);
}

inspect();                    // 0
inspect(new String[0]);       // 0
inspect((String[]) null);     // null

无参数调用会得到一个长度为零的数组;显式传入 (String[]) null 则方法参数本身为 null

以下调用容易产生警告或歧义:

inspect(null);

null 可以直接作为 String[] 传入,但编译器可能发出“非可变参数调用”的警告,因为它把 null 当作数组参数,而不是当作一个元素。若意图明确,应写成:

inspect((String[]) null); // 传入空引用数组
inspect((String) null);   // 传入一个元素,该元素为 null

两者语义不同:

(String[]) null  -> values == null
(String) null    -> values 是长度为 1 的数组,values[0] == null

可变参数的运行时成本和副作用

调用可变参数方法时,通常需要创建数组:

sum(1, 2, 3);

概念上类似于:

sum(new int[] {1, 2, 3});

这会带来数组分配和元素写入。一次调用的开销通常不应被夸大为必然的性能问题,JIT 编译器可能进行逃逸分析和优化;但在高频、低延迟、批处理或内存敏感路径中,仍应确认实际分配行为,而不是仅凭语法判断。

如果调用者已经有数组,直接传数组可以避免由调用语法额外构造另一份参数数组:

int[] batch = loadBatch();
sum(batch);

泛型、可变参数与堆污染

泛型数组不能直接创建:

// 编译错误
// List<String>[] lists = new List<String>[10];

因此下面的泛型可变参数方法会涉及不可具体化类型:

static <T> void collect(List<T>... lists) {
    for (List<T> list : lists) {
        System.out.println(list.size());
    }
}

编译器通常会对这种声明发出与堆污染相关的警告。原因是运行时数组的组件类型信息不足,某些不安全操作可能破坏泛型类型约束。

@SafeVarargs 只能在方法确实安全时使用。它适用于静态方法、构造器,以及不可被进一步重写的实例方法,例如 finalprivate 方法。它不是“关闭警告”的通用注解。

安全的例子:

@SafeVarargs
static <T> List<T> combine(List<T>... parts) {
    List<T> result = new ArrayList<>();
    for (List<T> part : parts) {
        result.addAll(part);
    }
    return result;
}

这个方法只读取每个列表并把元素复制到新列表中,没有把可变参数数组暴露出去,也没有向其中写入不兼容类型。

危险的做法包括:

  • 把可变参数数组保存到长期状态中;
  • 把它作为 Object[] 暴露给外部;
  • 向泛型可变参数数组写入未经验证的对象;
  • 返回这个数组,让调用者修改其内容。

@SafeVarargs 不会改变运行时类型,也不会自动修复堆污染;它只是由作者向编译器和调用者声明“该方法体已经经过安全性保证”。

重载解析与装箱、拆箱

自动装箱和拆箱会让看似直观的重载产生意外结果。

static void convert(int value) {
    System.out.println("int");
}

static void convert(Integer value) {
    System.out.println("Integer");
}

convert(1);                    // int
convert(Integer.valueOf(1));   // Integer

对于 Integer 变量:

Integer value = 1;
convert(value);

优先选择 convert(Integer),因为不需要拆箱。

如果只有下面两个重载:

static void convert(long value) {
    System.out.println("long");
}

static void convert(Object value) {
    System.out.println("Object");
}

Integer value = 1;
convert(value);

分析路径为:

convert(long)   : Integer -> int -> long
convert(Object) : Integer -> Object

Integer -> Object 是扩大引用转换,属于更早的严格调用阶段;Integer -> int -> long 需要拆箱后再扩大基本类型。因此输出:

Object

这类结果说明:重载解析不是按照“数值类型的直觉距离”执行,而是严格遵守转换阶段。

null 与基本类型重载

基本类型不能接收 null

static void use(int value) {
}

static void use(Integer value) {
}

use(null); // 只能匹配 Integer

如果两个参数都是引用类型,例如 IntegerLong,则会产生歧义:

static void use(Integer value) {
}

static void use(Long value) {
}

// use(null); // 编译错误

如果一个 API 需要表达“没有值”,使用引用类型或专门的参数对象通常比同时暴露多个基本类型重载更清晰。

泛型方法参与重载解析

泛型方法的类型参数通常通过参数推断确定:

static <T> T first(T left, T right) {
    return left;
}

String value = first("a", "b");

编译器可以根据参数和目标上下文推断 T

但泛型方法不能通过擦除后相同的签名与另一个方法共存:

class Invalid {
    <T> void process(T value) {
    }

    // 与上面方法擦除后冲突
    // void process(Object value) {
    // }
}

类型擦除后,process(T) 的参数可能变成 Object,因此两个声明无法形成可实现的不同方法签名。

重载泛型方法时还必须注意类型推断可能导致歧义。尤其是在参数是 Lambda、方法引用或多态表达式时,编译器需要结合目标函数类型进行推断,结果未必符合仅看字面参数类型的直觉。

Lambda、方法引用与重载歧义

Lambda 表达式没有独立的、固定的函数类型。它必须放在目标类型上下文中,才能确定是哪个函数式接口。

interface TextHandler {
    void handle(String text);
}

interface NumberHandler {
    void handle(Integer number);
}

static void register(TextHandler handler) {
    System.out.println("text");
}

static void register(NumberHandler handler) {
    System.out.println("number");
}

// register(value -> System.out.println(value)); // 可能产生歧义

Lambda 的参数没有显式类型时,两个函数式接口都可能成为候选目标类型。可以通过显式参数类型或强制转换消除歧义:

register((String text) -> System.out.println(text));
register((Integer number) -> System.out.println(number));

register((TextHandler) text -> System.out.println(text));

方法引用同样如此:

static void accept(TextHandler handler) {
}

static void accept(NumberHandler handler) {
}

static void consume(String text) {
}

static void consume(Integer number) {
}

// accept(Example::consume); // 可能有歧义

如果方法引用同时适合多个函数式接口,调用者需要显式指定目标类型。

这也是 API 设计中应谨慎增加函数式接口重载的原因:传统参数调用可能没有问题,但 Lambda 调用会突然变得无法编译。

多态表达式和目标类型

某些表达式的类型依赖上下文,称为多态表达式。Lambda、方法引用以及部分泛型方法调用属于这类场景。

例如:

static void execute(java.util.function.Function<String, Integer> function) {
    System.out.println(function.apply("123"));
}

static void execute(java.util.function.ToIntFunction<String> function) {
    System.out.println(function.applyAsInt("123"));
}

// execute(Integer::parseInt); // 可能产生歧义

Integer::parseInt 可以适配多个函数式接口:

  • Function<String, Integer> 返回 Integer
  • ToIntFunction<String> 返回 int

虽然自动装箱可以连接这两种返回类型,但重载解析可能无法选出唯一的最佳目标类型。显式转换可以解决:

execute((java.util.function.Function<String, Integer>) Integer::parseInt);
execute((java.util.function.ToIntFunction<String>) Integer::parseInt);

如果库 API 预计会被大量 Lambda 或方法引用调用,使用不同方法名有时比增加函数式接口重载更稳定。

重载解析的完整示例

下面的程序可以直接使用 Java 25 编译运行:

public class OverloadResolutionDemo {
    static void call(Object value) {
        System.out.println("Object");
    }

    static void call(String value) {
        System.out.println("String");
    }

    static void call(long value) {
        System.out.println("long");
    }

    static void call(Integer value) {
        System.out.println("Integer");
    }

    static void varargs(String... values) {
        System.out.println("varargs length = " + values.length);
    }

    public static void main(String[] args) {
        call("text");
        call(1);

        Integer number = 1;
        call(number);

        varargs("a", "b");
        varargs(new String[] {"x", "y", "z"});
    }
}

预期输出:

String
long
Integer
varargs length = 2
varargs length = 3

逐个解释:

  1. call("text")

    String 精确匹配 call(String),比扩大到 Object 更具体,因此输出 String

  2. call(1)

    1intint -> long 是严格阶段允许的扩大基本类型转换;int -> Integer 需要装箱;int -> Object 需要装箱再扩大引用。因此选择 call(long)

  3. call(number)

    number 的静态类型是 Integercall(Integer) 精确匹配,所以输出 Integer

  4. varargs("a", "b")

    两个独立字符串需要被打包为一个 String[],使用可变参数调用。

  5. varargs(new String[] {...})

    已经有一个 String[],直接作为数组参数传入。

方法声明中的参数约束

参数名称只服务于方法体和文档

参数名不参与重载:

void open(String path) {
}

void close(String path) {
}

openclose 能共存是因为方法名不同,而不是因为参数名不同。

编译器通常不会强制参数名在重载之间保持一致,但公共 API 应保持语义一致。例如都表示文件路径时,不要在不同重载中随意使用 pathnamelocation,否则会增加文档和调用理解成本。

参数类型必须是可赋值的目标类型

调用方法时,实际参数必须能通过允许的调用转换赋给形式参数。不能把任意类型强制转换为目标类型而不承担风险:

static void read(String text) {
}

// read(new Object()); // 编译错误

显式强制转换可以让代码通过编译,但也可能导致运行时异常:

Object value = 123;
String text = (String) value; // ClassCastException

重载解析优先处理合法的静态类型转换,不会因为开发者“相信运行时对象会是另一个类型”而改变规则。

throws 不参与重载

void load(String path) throws java.io.IOException {
}

// 不能仅通过 throws 区分
// void load(String path) throws java.sql.SQLException {
// }

受检异常会影响调用者是否必须处理异常,但不会影响方法重载选择。

方法调用可能失败的几个层次

方法相关错误不都发生在同一阶段。

编译期:找不到适用方法

static void send(String text) {
}

// send(1); // 编译错误

int 不能通过合法方法调用转换变成 String

编译期:调用有歧义

static void send(String text) {
}

static void send(Integer value) {
}

// send(null); // 编译错误

多个方法适用,但没有唯一最具体的方法。

编译期:访问权限错误

class Service {
    private void execute() {
    }
}

// 其他类中不能调用 service.execute()

方法即使参数完全匹配,若不可访问也不能调用。

运行期:方法体抛出异常

static int divide(int left, int right) {
    return left / right;
}

divide(10, 0); // ArithmeticException

重载解析成功不代表方法业务执行一定成功。

运行期:引用对象状态或内容不满足前置条件

static int length(String text) {
    return text.length();
}

length(null); // NullPointerException

参数类型合法,但值不满足方法契约。公共 API 应明确说明是否接受 null、空集合、空字符串和非法范围。

API 设计:何时使用重载

重载适合表达“同一个操作,不同的输入表现形式”,例如:

class MessageBuilder {
    void add(String text) {
        add(text, 0);
    }

    void add(String text, int priority) {
        // 核心实现
    }
}

这里的无优先级版本只是默认参数值的便利入口,两个方法的语义一致。

实现上通常让一个重载委托给另一个重载:

void add(String text) {
    add(text, 0);
}

这样可以避免两个入口各自维护一套稍有差异的逻辑。

重载不适合表达完全不同的行为:

// 不推荐:名字相同,但行为概念不同
void process(File file) {
}

void process(User user) {
}

如果两个操作的领域语义不同,即使参数类型不同,也可以考虑使用更明确的方法名,例如 importFileloadUser

避免仅因装箱而增加重载

下面的 API 容易让调用者困惑:

void setLimit(int value) {
}

void setLimit(Integer value) {
}

调用者需要明确区分:

  • int 重载是否表示“必有值”;
  • Integer 重载中的 null 是否表示“取消限制”;
  • 传入 Integer 变量时是否应该拆箱;
  • null 是否具有特殊业务含义。

如果业务上确实存在“缺省值”和“明确数值”两种状态,使用专门的参数对象或明确命名通常更清楚:

void setLimit(int value) {
}

void clearLimit() {
}

或者:

record LimitOptions(Integer value) {
}

避免无意义的引用类型重载

以下设计对 null 不友好:

void send(String value) {
}

void send(CharSequence value) {
}

void send(Object value) {
}

虽然非空字符串调用通常能选出 send(String),但当变量静态类型、泛型推断或 null 参与调用时,理解成本会增加。

重载数量还会影响:

  • 方法引用和 Lambda 的目标类型推断;
  • Kotlin、Scala 等其他 JVM 语言的调用体验;
  • 反射和代码生成工具的选择逻辑;
  • API 文档的可读性。

可变参数应表达“零个或多个同类元素”

适合的例子:

static void addTags(String... tags) {
}

不适合用可变参数模拟结构完全不同的参数:

// 不推荐:调用者无法知道每个位置的语义
static void configure(Object... values) {
}

如果参数有固定语义,使用显式参数或配置对象更安全:

static void configure(String host, int port, boolean secure) {
}

当参数数量可能持续增长时,可以使用不可变配置对象:

record ConnectionOptions(String host, int port, boolean secure) {
}

这样新增配置字段不会不断增加重载和位置参数的组合。

API 演进:增加重载可能改变源代码含义

增加一个重载通常不会改变已经编译完成的调用指令,因为字节码调用指令包含目标方法的类名、方法名和描述符。例如原来调用的是:

process(Object)

后来新增:

process(String)

已有二进制代码通常仍然按原来的描述符调用 process(Object)。但是,重新编译源代码时,重载解析可能发生变化。

原 API:

void process(Object value) {
    System.out.println("object");
}

调用:

process("text");

重新编译后,如果 API 增加:

void process(String value) {
    System.out.println("string");
}

同一份源代码将改为调用 process(String)

因此,新增重载可能带来源代码兼容性变化,即代码仍然能编译,但行为改变。对公共库而言,重载不是完全无影响的扩展。

添加与可变参数相关的重载也可能改变调用结果:

void log(Object value) {
}

void log(Object... values) {
}

某些调用会从固定参数方法切换到数组或可变参数方法,尤其是在传入数组、空参数或 null 时更需要检查。

调试重载问题的方法

遇到“调用歧义”或“调用到了意外重载”时,可以按以下顺序诊断。

先查看实参的静态类型

Object value = "text";
printer.print(value);

不要只看运行时值是字符串;重载选择首先依据表达式的编译期类型。可以临时拆分表达式并声明明确类型:

String text = "text";
printer.print(text);

如果结果改变,问题通常就在静态类型和重载集合之间。

把隐式转换改成显式转换

printer.print((String) value);
printer.print((Object) value);

这不仅能解决歧义,也能验证编译器原本会选择哪一个签名。

查看是否发生了装箱、拆箱或可变参数打包

把调用候选按路径写出来:

int -> int       // 恒等
int -> long      // 扩大基本类型
int -> Integer   // 装箱
int -> Object    // 装箱后扩大引用
int -> int[]     // 不成立,除非通过可变参数打包为数组

如果前面已经有恒等或扩大基本类型匹配,后续装箱和可变参数阶段通常不会参与。

检查 null 和 Lambda 是否缺少目标类型

对于 null

method((String) null);

对于 Lambda:

register((TextHandler) text -> handle(text));

显式目标类型可以把“多个可能的调用”缩减为一个确定调用。

编译后确认实际描述符

在需要确认二进制调用目标时,可以使用 JDK 自带工具查看字节码:

javac OverloadResolutionDemo.java
javap -c -p OverloadResolutionDemo

javap -c 会显示方法调用指令及其描述符。描述符中的参数类型和返回类型能够证明编译器最终绑定到哪个重载。

这与运行时对象类型是两个不同问题:

  • 字节码中的调用描述符体现编译期选择;
  • 实例方法的重写实现可能在运行时再发生动态分派。

设计方法签名时需要保持的边界

一个稳定的方法 API 至少需要明确以下问题:

  • 参数是必填值,还是允许 null
  • 空数组与 null 数组是否有不同含义;
  • 可变参数是否允许零个元素;
  • 参数对象是否会被方法修改;
  • 返回对象是否与输入对象共享可变状态;
  • 方法是否会阻塞、抛出受检异常或修改外部状态;
  • 重载之间是否只改变输入形式,还是改变了行为;
  • 新增重载是否会影响已有源代码的绑定结果。

例如下面两个方法的契约不同:

static void append(StringBuilder builder) {
    builder.append("x");
}

static String appended(StringBuilder builder) {
    return builder.toString() + "x";
}

前者修改传入对象,后者返回新字符串。即使它们都处理 StringBuilder,也不应仅靠重载表达这种行为差异;不同的方法名更准确地暴露了副作用和结果形式。

总结

Java 25 中,方法调用可以用一条完整链路理解:

方法声明
  -> 形成候选集合
  -> 检查参数类型兼容性
  -> 严格调用阶段
  -> 宽松调用阶段
  -> 可变参数阶段
  -> 选择最具体方法
  -> 生成具体调用描述符
  -> 运行时执行并进行必要的动态分派

最容易出错的几个原则是:

  1. Java 始终按值传递;对象参数传递的是引用值的副本;
  2. 返回类型、异常列表和参数名不能形成重载;
  3. 不需要装箱的匹配优先于需要装箱的匹配;
  4. 固定参数调用优先于真正的可变参数打包;
  5. null 能匹配引用类型,但不一定能在多个无关引用类型之间作出选择;
  6. Lambda 和方法引用依赖目标类型,函数式接口重载可能导致歧义;
  7. 可变参数本质上是数组,并可能带来数组分配、空引用和泛型堆污染问题;
  8. 新增重载通常保持已有二进制调用,但可能改变重新编译后的源代码绑定结果。

掌握这些规则后,方法重载就不再是“编译器凭感觉选一个”,而是可以按照参数静态类型、转换阶段和类型特异性逐步推导出的确定过程。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。