Skip to content

方法 ​

Java 初级 · 基础语法

方法是封装可复用逻辑的基本单元。你调用一个方法时,JVM 做了什么?参数怎么传进去?为什么「交换两个 int」换不回来?这些问题的答案,藏在栈帧和方法调用指令里。本节把方法的「定义、调用、参数传递、重载、递归」从语法讲到字节码层面。

一、方法签名与栈帧:每次调用都建一个「工作台」 ​

方法签名 = 修饰符 返回类型 方法名(参数列表)。调用方法时,JVM 会在当前线程的JVM 栈上压入一个栈帧(Stack Frame),这个栈帧就是该方法独享的「工作台」,包含:

  • 局部变量表:存放参数和方法内的局部变量(基本类型直接存值,引用类型存地址)。
  • 操作数栈:方法执行时做运算的中转站(见 hello-world 的字节码示例)。
  • 返回地址:方法结束后回到调用处的位置。

方法返回(return/ireturn 等)时,栈帧弹出,控制权回到调用者。递归就是「一个方法反复往栈上压自己的新栈帧」——没有终止条件就会栈溢出。

二、四种方法调用指令(字节码视角) ​

javac 按方法种类生成不同的调用指令,这正是「静态分派 vs 动态分派」和「重载 vs 重写」的底层分水岭:

指令适用场景分派时机
invokestaticstatic 方法编译期确定(静态绑定)
invokespecial构造方法、私有方法、super 调用编译期确定
invokevirtual普通实例方法运行期按对象实际类型(动态绑定,支撑多态)
invokeinterface接口方法运行期(类似 virtual,走接口分派)

记忆要点:static、构造、private 在编译期就锁定目标(invokestatic/invokespecial);而普通实例方法用 invokevirtual,运行时才根据对象真实类型决定调用哪个实现——这正是下一节「多态」能成立的根本原因。

三、参数传递:Java 只有「值传递」 ​

这是被误解最多的一点。结论先给:Java 永远只传值。区别只在于「传的是什么值」:

  • 基本类型:传的是「值的副本」。方法里改形参,不影响外面的原变量。
  • 引用类型:传的是「地址的副本」。方法里通过这个地址能改对象内部,但给形参重新 new/赋值,不影响外面的引用。
java
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」做不到:

java
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 类型、修饰符不算区分依据。重载在编译期通过「最匹配的参数类型」静态决议:

java
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,这点要留心,避免调到意料之外的方法。

五、可变参数:语法糖,本质是数组 ​

类型... 名 必须放在参数列表最后,编译器会把它变成一个数组:

java
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});            // 也可直接传数组

可变参数的两个约束

  1. 只能放最后:int... a, int b 非法。
  2. 每次调用若传离散值,会隐式 new 一个数组——在极高频循环里有一点开销,可预先传数组。另外重载时 f(int...) 与 f(int[]) 不能同时存在(本质是同一签名)。

六、递归:方法调用自身,必须有终止条件 ​

递归 = 方法直接或间接调用自己,配合「基准情形(base case)」收敛:

java
static long factorial(int n) {
    if (n <= 1) return 1;            // 基准情形,防止无限递归
    return n * factorial(n - 1);     // 向基准情形推进
}

递归在底层就是不断压栈帧(见第一节示意图)。每层都有独立栈帧,所以 n 在各层互不影响。代价是:每层都占栈空间,递归过深会 StackOverflowError;某些场景(如斐波那契朴素递归)存在大量重复计算,应改用循环或记忆化。

递归 vs 循环怎么选

只有当问题「结构天然递归」(树、链表、分治)且深度可控时用递归,代码最简洁。像阶乘这种线性问题,循环既快又省栈,更稳妥。Java 没有尾递归优化,别指望「尾递归不会爆栈」。

七、常见误区与注意点 ​

WARNING

  • 以为 Java 有引用传递:其实只有值传递;引用类型传的是「地址副本」,所以能改对象内容,但重分配形参不影响实参。
  • 重载只看参数不看返回类型:仅返回类型不同的两个方法不构成重载,编译报错。
  • 递归无终止条件:导致 StackOverflowError,务必先写基准情形。
  • 可变参数放错位置:必须放参数列表最后。
  • 混淆 return 与「结束程序」:return 只结束当前方法,回到调用处继续往下走。

八、最佳实践 ​

  1. 方法单一职责:一个方法只做一件事,名字见名知意。
  2. 优先用 return 提前返回(guard clause),减少嵌套 if 层级,提升可读性。
  3. 需要返回多个结果,返回对象或用集合,而非靠「改入参」制造副作用(副作用难追踪)。
  4. 递归只用于结构天然递归、深度可控的场景,否则用循环。
  5. 想看底层就 javap -c,观察 invokestatic/invokevirtual 等调用指令,理解静态/动态绑定。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 写 max(int a, int b) 返回较大值,再重载 max(int a, int b, int c);用 javap -c 观察两个方法在 class 里是两个独立方法。
  2. 写 swap(int a, int b) 试图交换,调用后打印证明没交换成功,并解释「地址副本」原理。
  3. 用递归写 sumToN(int n)(1~n 累加),给出终止条件;再用循环版本对比,说明各自优劣。
  4. 写 concat(String sep, String... parts) 用可变参数把多个字符串拼成 sep 分隔的一行;故意把 String... 放前面观察编译错误。

本节小结

方法调用时 JVM 压入一个栈帧(局部变量表 + 操作数栈 + 返回地址)。方法调用分四类指令:static/构造/私有走 invokestatic/invokespecial(编译期静态绑定),实例方法走 invokevirtual(运行期按真实类型动态绑定,支撑多态)。Java 只有值传递——基本类型传值副本,引用类型传地址副本(能改对象内容但换不了引用)。重载靠参数列表在编译期决议,可变参数本质是数组且必须放最后,递归必须有终止条件否则栈溢出。