方法
Java 初级 · 基础语法
方法是封装可复用逻辑的基本单元。你调用一个方法时,JVM 做了什么?参数怎么传进去?为什么「交换两个 int」换不回来?这些问题的答案,藏在栈帧和方法调用指令里。本节把方法的「定义、调用、参数传递、重载、递归」从语法讲到字节码层面。
一、方法签名与栈帧:每次调用都建一个「工作台」
方法签名 = 修饰符 返回类型 方法名(参数列表)。调用方法时,JVM 会在当前线程的JVM 栈上压入一个栈帧(Stack Frame),这个栈帧就是该方法独享的「工作台」,包含:
- 局部变量表:存放参数和方法内的局部变量(基本类型直接存值,引用类型存地址)。
- 操作数栈:方法执行时做运算的中转站(见 hello-world 的字节码示例)。
- 返回地址:方法结束后回到调用处的位置。
方法返回(return/ireturn 等)时,栈帧弹出,控制权回到调用者。递归就是「一个方法反复往栈上压自己的新栈帧」——没有终止条件就会栈溢出。
二、四种方法调用指令(字节码视角)
javac 按方法种类生成不同的调用指令,这正是「静态分派 vs 动态分派」和「重载 vs 重写」的底层分水岭:
| 指令 | 适用场景 | 分派时机 |
|---|---|---|
invokestatic | static 方法 | 编译期确定(静态绑定) |
invokespecial | 构造方法、私有方法、super 调用 | 编译期确定 |
invokevirtual | 普通实例方法 | 运行期按对象实际类型(动态绑定,支撑多态) |
invokeinterface | 接口方法 | 运行期(类似 virtual,走接口分派) |
记忆要点:static、构造、private 在编译期就锁定目标(invokestatic/invokespecial);而普通实例方法用 invokevirtual,运行时才根据对象真实类型决定调用哪个实现——这正是下一节「多态」能成立的根本原因。
三、参数传递:Java 只有「值传递」
这是被误解最多的一点。结论先给:Java 永远只传值。区别只在于「传的是什么值」:
- 基本类型:传的是「值的副本」。方法里改形参,不影响外面的原变量。
- 引用类型:传的是「地址的副本」。方法里通过这个地址能改对象内部,但给形参重新
new/赋值,不影响外面的引用。
static void changeInt(int x) { x = 100; } // 改的是副本
static void changeArr(int[] a) { a[0] = 100; } // 改的是「同一对象」的内容
static void rePoint(String s) { s = "new"; } // 只改了副本指向,原引用不变
public static void main(String[] args) {
int n = 1; changeInt(n); // n 仍为 1
int[] arr = {1}; changeArr(arr); // arr[0] 变为 100(同一对象)
String t = "old"; rePoint(t); // t 仍为 "old"
}为什么「交换两个 int」做不到:
static void swap(int a, int b) { int t = a; a = b; b = t; }
// 调用 swap(x, y) 后,x、y 不变——因为 a、b 只是 x、y 的副本引用类型「传地址副本」的精确含义
「能改对象内容」≠「能换对象」。形参 a 和外面的 arr 都指向同一数组,所以 a[0]=100 看得见;但 a = new int[5] 只是让形参指向新数组,外面的 arr 毫发无伤。任何「通过方法交换两个引用」的企图,在 Java 里都不可能成功——除非把引用装进一个对象/数组再改其字段。
四、重载(overload):编译期就定好
重载是「同一类里方法名相同、参数列表不同(类型/个数/顺序)」。return 类型、修饰符不算区分依据。重载在编译期通过「最匹配的参数类型」静态决议:
public int add(int a, int b) { return a + b; }
public int add(int a, int b, int c) { return a + b + c; } // 参数个数不同 → 重载
// public double add(int a, int b) { ... } // ❌ 仅返回类型不同,编译报错调用 add(1, 2) 时,编译器直接选了「两参版本」。注意重载和「重写(override)」完全不同:重载是编译期静态绑定、同类内;重写是运行期动态绑定、父子类间(见面向对象一节)。
重载决策的「最匹配」规则
当存在多个可匹配的重载(如 add(int,int) 与 add(long,long)),编译器选「不需要转换或转换代价最小」的那个。传入 char 时可能隐式提升为 int,这点要留心,避免调到意料之外的方法。
五、可变参数:语法糖,本质是数组
类型... 名 必须放在参数列表最后,编译器会把它变成一个数组:
static int sum(int... nums) { // 编译后等价于 int sum(int[] nums)
int s = 0;
for (int v : nums) s += v;
return s;
}
sum(1, 2, 3); // 编译器生成 new int[]{1,2,3}
sum(new int[]{1, 2, 3}); // 也可直接传数组可变参数的两个约束
- 只能放最后:
int... a, int b非法。 - 每次调用若传离散值,会隐式
new一个数组——在极高频循环里有一点开销,可预先传数组。另外重载时f(int...)与f(int[])不能同时存在(本质是同一签名)。
六、递归:方法调用自身,必须有终止条件
递归 = 方法直接或间接调用自己,配合「基准情形(base case)」收敛:
static long factorial(int n) {
if (n <= 1) return 1; // 基准情形,防止无限递归
return n * factorial(n - 1); // 向基准情形推进
}递归在底层就是不断压栈帧(见第一节示意图)。每层都有独立栈帧,所以 n 在各层互不影响。代价是:每层都占栈空间,递归过深会 StackOverflowError;某些场景(如斐波那契朴素递归)存在大量重复计算,应改用循环或记忆化。
递归 vs 循环怎么选
只有当问题「结构天然递归」(树、链表、分治)且深度可控时用递归,代码最简洁。像阶乘这种线性问题,循环既快又省栈,更稳妥。Java 没有尾递归优化,别指望「尾递归不会爆栈」。
七、常见误区与注意点
WARNING
- 以为 Java 有引用传递:其实只有值传递;引用类型传的是「地址副本」,所以能改对象内容,但重分配形参不影响实参。
- 重载只看参数不看返回类型:仅返回类型不同的两个方法不构成重载,编译报错。
- 递归无终止条件:导致
StackOverflowError,务必先写基准情形。 - 可变参数放错位置:必须放参数列表最后。
- 混淆
return与「结束程序」:return只结束当前方法,回到调用处继续往下走。
八、最佳实践
- 方法单一职责:一个方法只做一件事,名字见名知意。
- 优先用
return提前返回(guard clause),减少嵌套if层级,提升可读性。 - 需要返回多个结果,返回对象或用集合,而非靠「改入参」制造副作用(副作用难追踪)。
- 递归只用于结构天然递归、深度可控的场景,否则用循环。
- 想看底层就
javap -c,观察invokestatic/invokevirtual等调用指令,理解静态/动态绑定。
📚 官方文档与参考资料
- Java SE 21 文档:https://docs.oracle.com/en/java/javase/21/
- Java 语言规范 · 方法调用:https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.12
- OpenJDK 项目:https://openjdk.org/
✏️ 练习与自测
- 写
max(int a, int b)返回较大值,再重载max(int a, int b, int c);用javap -c观察两个方法在 class 里是两个独立方法。 - 写
swap(int a, int b)试图交换,调用后打印证明没交换成功,并解释「地址副本」原理。 - 用递归写
sumToN(int n)(1~n 累加),给出终止条件;再用循环版本对比,说明各自优劣。 - 写
concat(String sep, String... parts)用可变参数把多个字符串拼成sep分隔的一行;故意把String...放前面观察编译错误。
本节小结
方法调用时 JVM 压入一个栈帧(局部变量表 + 操作数栈 + 返回地址)。方法调用分四类指令:static/构造/私有走 invokestatic/invokespecial(编译期静态绑定),实例方法走 invokevirtual(运行期按真实类型动态绑定,支撑多态)。Java 只有值传递——基本类型传值副本,引用类型传地址副本(能改对象内容但换不了引用)。重载靠参数列表在编译期决议,可变参数本质是数组且必须放最后,递归必须有终止条件否则栈溢出。