程序刚开始变长时,最常见的写法是把读取输入、计算、判断和输出全部塞进 main。它当然可以运行,但任何一处规则发生变化,都要在一大段代码里寻找落点。方法的价值,就是把这条长流程切成一组边界清楚的小能力:每个方法接收明确的输入,完成一件可描述的事,再返回结果或产生约定好的效果。
这一章不把方法只当作“语法模板”。我们会沿着一次真实调用的全过程,理解声明、实参与形参、按值传递、局部作用域、调用栈、返回值、重载、可变参数、数组与对象参数、递归、包和标准 API;最后再把这些机制收束为一套可以直接用于设计、调试和测试的方法写法。
代码约定:带 public class 的代码块可以单独保存并运行;其余片段用于聚焦当前机制。示例优先使用 static 方法,让注意力集中在方法本身。实例方法会在学习类与对象时进一步展开。
本文按 Java SE 26 的正式语言规则讲解。所有参数传递都是按值传递;“传对象”并不会切换成另一套传递机制,它复制的是引用这个值。
假设收银程序要计算订单应付金额。把所有规则直接堆在 main 中,短期看似省事,后来却很难回答几个简单问题:折扣从哪里开始算?负金额由谁拒绝?同一规则能否给网页端和批处理程序复用?测试时能否只验证计算,而不模拟键盘输入?
把计算提取成方法后,边界立刻清楚起来:
public class CheckoutDemo {
public static void main(String[] args) {
double payable = calculatePayable(260.0, true);
System.out.printf("应付:%.2f 元%n", payable);
}
static double calculatePayable(double amount, boolean member) {
if (amount < 0) {
throw new IllegalArgumentException("订单金额不能为负数");
}
double discountRate = member ? 0.90 : 1.00;
return amount * discountRate;
}
}从调用者的角度看,calculatePayable 像一个黑盒:交给它订单金额和会员状态,就得到应付金额。调用者不必知道折扣率存在哪个局部变量中,但必须知道这份契约:
amount 表示非负订单金额,member 表示是否为会员。IllegalArgumentException。“黑盒”不是不关心实现质量,而是把接口和实现分开思考。接口回答“如何正确使用”,实现回答“内部如何完成”。只要契约不变,内部算法可以优化,调用方不需要跟着改。
先观察声明首行:
static double calculatePayable(double amount, boolean member) {
方法声明不只是一行语法:它把输入、返回类型、能力名称、实现边界和异常规则连接成调用者可以依赖的契约。
声明首行和方法体共同定义了能力。只记住括号和大括号还不够;真正值得先写下来的,是一句可验证的话,例如:
给定非负订单金额和会员状态,返回应用会员折扣后的金额;负金额被拒绝。
这句话会反过来约束方法的名字、参数、返回类型和测试。
一项需求往往不能一次拆到最细。可以先写出高层步骤,再继续拆分最复杂的步骤:
生成结算结果
├── 校验订单
├── 计算商品小计
├── 计算折扣
├── 计算运费
└── 格式化结果好的拆分不是“每三行提取一个方法”,而是让每个方法都对应一个稳定概念。若一个方法同时读取文件、计算价格、修改库存并发送通知,它就拥有四个变化原因;任何一条规则调整,都可能牵动整段实现。相反,calculateDiscount 这样的边界容易命名、复用和单独测试。
判断是否值得提取方法,可以问三个问题:这段逻辑能否用一句动词短语命名?是否会被重复使用?是否值得脱离周边流程单独测试?任意一个答案为“是”,通常就有清晰的提取理由。
方法不会因为被声明就自动执行。声明只是把能力放进类中;运行到方法调用表达式时,Java 才会计算实参、进入方法体,最后把控制权交还给调用位置。
double fee = ShippingRules.shippingFee(128.0, true);这行代码包含四个连续动作:
ShippingRules 类中的 shippingFee。128.0 和 true。return 后,把返回值交给调用表达式,再继续赋值。同一个类中的静态方法可以直接写方法名;不同类中的静态方法,优先写成 类名.方法名(...),让能力的归属一眼可见:
public class ShippingRules {
public static double shippingFee(double amount, boolean member) {
if (amount < 0) {
throw new IllegalArgumentException("金额不能为负数");
}
if (member || amount >= 199) {
return 0.0;
}
return 12.0;
public class ShippingApp {
public static void main(String[] args) {
double fee = ShippingRules.shippingFee(128.0, true);
System.out.println("运费:" + fee);
}
}返回类型不是装饰。它决定调用表达式能否产生值:
static int square(int value) {
return value * value;
}
static void printDivider() {
System.out.println("----------------");
}square(6) 的结果可以赋值、参与计算或作为另一个调用的实参。执行 printDivider() 只产生约定好的输出,不能写成 int x = printDivider();。
void 方法也可以使用不带表达式的 return; 提前结束:
static void printPositive(int value) {
if (value <= 0) {
return;
}
System.out.println(value);
}非 void 方法则必须保证每一条能够正常走到方法末尾的路径都返回兼容类型的值。下面的写法无法通过编译,因为 score < 0 时没有返回值:
static String level(int score) {
if (score >= 90) {
return "优秀";
}
if (score >= 0) {
return "继续加油";
}
// 缺少对负分的处理
}更好的版本把非法输入先挡在门外,再覆盖所有合法路径:
static String level(int score) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("分数必须在 0 到 100 之间");
}
if (score >= 90) {
return "优秀";
}
return "继续加油";
}这类开头就结束异常路径的判断叫守卫子句。它减少嵌套,并让后续代码在“输入已经合法”的前提下阅读。
return 会立即结束当前这一次方法调用。写在它后面且永远无法到达的语句会造成编译错误。它只返回一层调用者,不会直接结束整个程序;除非返回的正是 main,并且其他非守护线程也已结束。
声明中的参数叫形参,调用时括号里的表达式叫实参:
static double addTax(double price, double rate) { // price、rate 是形参
return price * (1 + rate);
}
double total = addTax(80 + 20, 0.06); // 两个表达式是实参实参不必是变量,它可以是字面量、算术表达式或另一个方法调用。Java 从左到右计算它们,然后把结果分别复制给新的形参变量。形参只属于这一次调用;下一次调用会得到另一组形参。
下面的 increase 改变的是形参 value,不是 main 中的 score:
public class PrimitiveParameterDemo {
public static void main(String[] args) {
int score = 70;
increase(score);
System.out.println(score); // 70
}
static void increase(int value) {
value += 10;
System.out.println(value); // 80
可以把调用瞬间想成下面两只独立的盒子:
调用者 score: 70
│ 复制数值 70
▼
形参 value: 70 → 方法内部改成 80value 改成 80 后,score 那只盒子仍是 70。因此,试图用下面的方法交换两个基本类型变量不会成功:
static void swap(int left, int right) {
int temp = left;
left = right;
right = temp;
}调用 swap(a, b) 只交换两份副本。若需要把计算结果交还调用者,最直接的方式是 return:
static int increasedByTen(int value) {
return value + 10;
}
score = increasedByTen(score);两个相同类型的参数尤其容易被传反:
static boolean isWithin(int value, int min, int max) {
return value >= min && value <= max;
}
boolean valid = isWithin(75, 0, 100);方法名和参数名应该让顺序容易判断。若参数很多、布尔参数语义不清,继续增加位置参数会让调用变成猜谜:
createReport(true, false, true, 20, 3);此时应考虑把相关配置收拢为有名字的对象,或拆成含义更具体的方法。Java 不提供“形参默认值”语法,也不会根据形参名匹配实参;位置和类型必须符合方法声明。
不要在多个实参中塞入彼此依赖的自增、赋值等副作用。Java 虽然规定从左到右求值,但 savePair(nextId(), nextId()) 仍会让读者额外模拟状态。先把中间值算清楚,再调用方法更容易检查。
一个方法可以调用另一个方法,同一个方法也可以被反复调用。为了避免不同调用的形参和局部变量互相覆盖,运行时会为每次调用保存独立的执行状态。理解时可以把它看成一个栈帧:里面记录本次调用的参数、局部变量、计算中的临时状态以及返回位置。
public class CallStackDemo {
public static void main(String[] args) {
double result = checkout(120.0);
System.out.println(result);
}
static double checkout(double amount) {
double tax = calculateTax(amount);
return amount + tax;
}
执行到最深处时,可以这样阅读:
栈顶 calculateTax: amount=120.0, rate=0.06
checkout: amount=120.0, 等待 tax
栈底 main: 等待 resultcalculateTax 返回 7.2 后,它的帧结束;checkout 用这个值初始化 tax,算出 127.2 后返回;最后 main 恢复执行。即使三个方法都把参数命名为 amount,它们也属于不同调用帧。

调用向上压入新的栈帧,返回则弹出当前帧并恢复上一层;同名形参也分别保存在各自的调用中。
下面的交互实验把这条调用链拆成八步。逐步推进时,同时观察高亮代码与右侧栈帧:新调用怎样压栈、局部变量保存在哪里、返回值又怎样交回等待中的调用者。
生命周期描述变量何时存在,作用域描述源码中哪里可以用这个名字。常见规则是:
for 初始化部分声明的变量,只在该 for 的初始化、条件、更新和循环体中可见。static int countPassing(int[] scores) {
int count = 0;
for (int score : scores) {
if (score >= 60) {
String mark = "及格";
count++;
System.out.println(score + ":" + mark);
}
// mark 在这里不可见
}
Java 不允许在作用域重叠时再次声明同名局部变量或形参。字段则可以被局部变量或形参遮蔽,此时应显式说明要访问哪一个字段;但比起到处依赖字段,更好的第一选择通常是用参数输入、用返回值输出。
类的静态字段能被多个静态方法访问,有时会被口头称为全局变量:
static int totalRequests = 0;
static void recordRequest() {
totalRequests++;
}这种共享状态有合法用途,但它把输入和输出藏在方法签名之外。调用 recordRequest() 看不出它会修改什么,并发调用还需要同步策略。若一个值只服务于一次计算,就让它留在局部变量中;若方法需要外部数据,就优先通过参数明确传入。
局部变量不会像字段那样自动获得默认值。读取局部变量前,编译器必须能证明当前路径已经为它赋值。把变量声明得离首次使用更近,通常更容易满足这条规则,也更容易读。
同一个类中可以存在多个同名方法,只要它们的参数列表足以区分。这叫方法重载:
static String formatAmount(int yuan) {
return yuan + " 元";
}
static String formatAmount(double yuan) {
return String.format("%.2f 元", yuan);
}
static String formatAmount(double yuan, String currency) {
return String.format("%.2f %s", yuan, currency);
}调用 formatAmount(20) 时选择 int 版本,formatAmount(20.5) 选择 double 版本。编译器根据方法名、实参数量、类型以及允许的方法调用转换寻找适用候选。
方法签名的核心是方法名和形参类型序列。下面两个声明不能同时存在:
static int parse(String text) {
return Integer.parseInt(text);
}
static double parse(String text) { // 编译错误:签名与上一个方法相同
return Double.parseDouble(text);
}调用 parse("42") 时,Java 不能靠接收变量类型猜应该使用哪一个返回类型。若语义确实不同,应使用 parseInt、parseDouble 这样更具体的名字。
可以用三个阶段建立实用心智模型:
每一阶段找到候选后,还要从中选择最具体的方法;如果没有唯一最具体者,调用就是二义性的。
static void pick(long value) {
System.out.println("long");
}
static void pick(Integer value) {
System.out.println("Integer");
}
pick(7); // 输出 long:基本类型拓宽在允许装箱的阶段之前另一个经典边界是 null:
static void show(String value) { }
static void show(Integer value) { }
// show(null); // 编译错误:String 和 Integer 互不比对方更具体重载最好表达同一概念的自然变体。若两个版本的副作用、异常规则或含义完全不同,仅仅因为“名字能复用”而重载,调用者反而更难预测。
当同类参数数量确实不固定,可以把最后一个形参声明为 类型...:
static double average(double... values) {
if (values.length == 0) {
throw new IllegalArgumentException("至少需要一个数值");
}
double sum = 0;
for (double value : values) {
sum += value;
}
return sum / values.length;
}调用方式可以很自然:
double a = average(80, 90, 100);
double b = average(72.5, 88.0);
double[] scores = {60, 75, 90};
double c = average(scores);方法体中的 values 是一个 double[]。调用者既可以给出零个或多个独立实参,也可以直接传入兼容数组。一个方法最多有一个可变参数,而且它必须位于参数列表最后,否则编译器无法判断后续实参应归给谁。
固定参数版本通常比可变参数版本更早被考虑:
static void log(String message) { }
static void log(String... messages) { }
log("启动"); // 选择固定参数版本重载和可变参数组合得太密集时,很容易出现二义性,尤其是 null、装箱类型和多个可变参数候选同时出现的场景。公开 API 应优先追求调用点清楚,而不是展示尽可能多的重载技巧。
下面的实验把“编译器选中了哪个重载”拆成可以观察的筛选过程。先预测四个调用的结果,再逐阶段查看固定参数、装箱或拆箱、可变参数以及最具体候选如何影响结论。
数组和对象没有改变“按值传递”规则。差别在于:基本类型变量保存数值本身,而引用类型变量保存一个指向对象的引用值。调用方法时,Java 复制这份引用,于是调用者变量与形参最初指向同一个对象。
public class ArrayParameterDemo {
public static void main(String[] args) {
int[] scores = {60, 70, 80};
changeFirst(scores);
System.out.println(scores[0]); // 100
replace(scores);
System.out.println(scores[0]); // 仍是 100
}
两种行为要严格区分:
values[0] = 100 通过复制来的引用找到同一个数组,并修改数组内容,所以调用者能观察到变化。values = new int[] {...} 只让形参改指向新数组,没有改写调用者的 scores 变量,所以方法返回后 scores 仍指向原数组。对象参数完全相同。方法可以通过引用调用对象的方法、修改可变对象状态,但给形参重新赋引用不会让调用者变量改指向。

两边遵守的是同一条规则:形参得到值的副本。区别只在于,引用值的副本仍能找到同一个可变对象。
下面两个方法都能把分数加一,但契约不同:
static void addBonusInPlace(int[] scores) {
for (int i = 0; i < scores.length; i++) {
scores[i]++;
}
}
static int[] withBonus(int[] scores) {
int[] result = java.util.Arrays.copyOf(scores, scores.length);
for (int i = 0; i
addBonusInPlace 原地修改输入,适合调用者明确授权修改的场景;withBonus 返回副本,调用前后的原数组保持不变,更容易局部推理。方法名、注释和测试应说明选择了哪一种,而不是让调用者靠猜。
若方法要长期保存传入数组,也要考虑调用者之后仍能修改它。可以在边界处使用 Arrays.copyOf 防御性复制。复制一段连续数组时,System.arraycopy 也很高效,但源下标、目标下标和长度必须合法。
static int sum(int[] values) {
if (values == null) {
throw new IllegalArgumentException("values 不能为 null");
}
int total = 0;
for (int value : values) {
total += value;
}
return total;
}null 表示没有数组对象,new int[0] 表示存在一个长度为零的数组。二者的业务含义可能不同。与其让空指针在方法深处偶然出现,不如在边界处明确接受、转换或拒绝 null。
看到“Java 对象参数是引用传递”时要立刻纠正:复制引用值不等于复制对象,也不等于把调用者变量本身交给方法。判断结果时分别追踪“形参指向哪里”和“被指向对象的内容是否改变”。
方法可以直接或间接调用自身,这叫递归。递归适合描述“把问题缩小成同类子问题”的结构,例如目录树、表达式树、二分查找和分治算法。它不是一种神秘循环,而是许多次彼此独立的方法调用。
计算非负整数阶乘可以写成:
public class FactorialDemo {
public static void main(String[] args) {
System.out.println(factorial(5)); // 120
}
static long factorial(int n) {
if (n < 0) {
throw new IllegalArgumentException("n 不能为负数");
}
正确递归至少需要三个要素:
n <= 1 时直接返回,不再递归。factorial(n - 1) 让参数持续靠近基本情况。n 与子问题结果相乘。factorial(4) 展开时,每层都有自己的 n:
factorial(4)
└── 4 * factorial(3)
└── 3 * factorial(2)
└── 2 * factorial(1)
└── 1返回阶段再逐层合并为 2、6、24。这也解释了为什么递归能工作:上一层调用没有消失,它的栈帧正在等待下一层结果。

递归有两条同样重要的生命线:向下时问题规模必须持续缩小,触及基本情况后才能向上逐层组合结果。
二分查找更能体现“缩小同类问题”。它要求数组已经按升序排列,每次只保留一半区间:
static int binarySearch(int[] sorted, int target) {
if (sorted == null) {
throw new IllegalArgumentException("数组不能为 null");
}
return binarySearch(sorted, target, 0, sorted.length - 1);
}
private static int binarySearch(
int[] sorted, int target, int low, int
low > high 表示候选区间为空,是基本情况;其余路径都把区间缩小到左半或右半。若数组没有预先升序排列,方法可能正常返回却给出错误结论,因此“数组已排序”也是契约中的前置条件。
下面虽然写了基本情况,参数却朝错误方向移动:
static int brokenCountdown(int n) {
if (n == 0) {
return 0;
}
return brokenCountdown(n + 1);
}对正数调用时,n 离 0 越来越远。调用不断创建新栈帧,最终通常以 StackOverflowError 结束。设计递归时,不只要问“出口是什么”,还要给出一个可度量的规模,并证明每一步都严格缩小。
阶乘也能写成循环:
static long factorialLoop(int n) {
if (n < 0) {
throw new IllegalArgumentException("n 不能为负数");
}
long result = 1;
for (int value = 2; value <= n; value++) {
result *= value;
}
return result;
}当问题本身是线性重复,循环通常更省调用开销,也没有深调用栈风险;当数据天然嵌套或算法本身分治,递归往往更贴近问题结构。无论选择哪一种,都要测试最小输入、典型输入、非法输入和可能触及容量上限的输入。
还要区分两个上限:调用层数受栈空间限制,long 阶乘还会很快数值溢出。递归正确并不意味着返回类型拥有无限容量。
StackOverflowError 通常说明调用层次过深或递归不收敛,不应把它当作普通业务分支捕获后继续。先检查基本情况、参数变化方向和输入规模,再决定是否改为循环或显式数据结构。
static 方法属于类,不依赖某个特定实例:
double root = Math.sqrt(81.0);
int position = java.util.Arrays.binarySearch(new int[] {2, 5, 8}, 5);Math.sqrt 和 Arrays.binarySearch 都通过类名调用。静态上下文没有“当前对象”,因此不能直接使用 this、super 或未指定实例的实例字段与实例方法。
常见方法声明会组合访问级别和 static:
public:在满足模块与包可见性等条件时,可供其他类调用。protected:同包可访问,并为继承关系提供额外访问规则。private:只在声明它的顶层类或相关嵌套范围内使用,适合隐藏实现步骤。对外只暴露稳定能力,把辅助步骤留为 private,能减少调用者依赖:
public class PriceRules {
public static double finalPrice(double amount, boolean member) {
requireNonNegative(amount);
return member ? amount * 0.9 : amount;
}
private static void requireNonNegative(double amount) {
if (amount < 0) {
throw new
包把相关类型组织到命名空间中。包声明必须位于源文件开头附近:
package com.example.checkout;
import java.util.Arrays;
public class ScoreTools {
public static int highest(int[] scores) {
if (scores == null || scores.length == 0) {
throw new IllegalArgumentException("至少需要一个分数");
}
int[] copy = Arrays.copyOf(scores, scores.length);
import java.util.Arrays; 只是让当前编译单元可以用简单名 Arrays,它不会“加载全部代码”,也不会把方法复制进当前类。java.lang 中的常用类型会被自动导入,所以 String、Math 通常不需要显式 import。
静态导入能省去类名:
import static java.lang.Math.sqrt;
double distance = sqrt(81.0);少量、含义明确时可以使用;如果同一文件静态导入许多不同类的方法,调用来源会变得不清楚。教学和业务代码中保留 Math.sqrt 往往更易读。
遇到标准库方法,不要只凭名字猜行为。阅读顺序可以固定为:
static,应该通过类还是对象调用。null 规则。例如 Arrays.sort(array) 会原地修改数组;若要保留原顺序,应先复制。又如 Arrays.binarySearch 要求待搜索范围已经按兼容顺序排序,找不到时的返回值也不只是固定的 -1。正确调用 API 的前提,是读完契约而不是只看到方法名。
main(String[] args) 本身也是方法:public 允许启动器访问,static 表示不必先创建对象,void 表示不向启动器返回 Java 值,String[] args 接收命令行参数。它并不是方法语法之外的特殊例外。
优先复用经过验证的标准 API,例如 Math 的数值方法、Arrays 的排序与查找。复用不是少写几行这么简单,它还能让代码使用共同术语,并把大量边界处理交给已有契约。
语法正确的方法不一定容易维护。一个成熟的方法还需要清楚的名字、稳定的契约、合理的职责、可预测的异常,以及覆盖边界的测试。可以把设计过程固定为六步:
下面的程序根据订单金额、会员状态和目的地区域计算报价。顶层方法像目录,细节交给更小的方法:
public class ShippingQuoteApp {
public static void main(String[] args) {
double quote = quote(268.0, true, "REMOTE");
System.out.printf("最终应付:%.2f 元%n", quote);
}
/**
* 计算订单折扣后再加运费的最终应付金额。
*
* @param amount 商品原始金额,必须大于或等于 0
* @param member
quote 负责组织步骤,而不是埋进所有细节。每个辅助方法都有窄而稳定的职责:
requireNonNegative 只校验金额。normalizeZone 负责处理空值、空白、大小写和允许集合。applyDiscount 只计算折扣。shippingFee 只根据折后金额和区域计算运费。这种结构让测试可以直接瞄准规则。例如金额恰好为 199.0 时是否免运费,应该用方法调用验证,而不需要模拟整套输入输出。
前置条件描述调用前必须成立的事实,例如“金额非负”;后置条件描述正常返回后保证成立的事实,例如“返回值等于折后金额加运费,且不会修改输入字符串”。
有三种常见处理方式:
不要用一个模糊的 return -1 同时表示“输入非法”“没有结果”和“真实计算结果”。返回值与异常各自应有单一、可读的含义。
传统 Javadoc 注释紧贴在声明前,以 /** 开始。方法文档应该优先写契约,而不是逐行翻译实现:
@param 说明参数含义、单位、范围和 null 规则。void 方法用 @return 说明返回值及特殊结果。@throws 说明异常在什么条件下发生。如果实现从循环改成公式,文档通常不必变化;如果参数允许范围或副作用变了,文档和测试就必须同步更新。注释与行为冲突时,过期注释比没有注释更危险。
对 quote,至少应该覆盖:
调试失败调用时,先读异常类型和消息,再从栈轨迹最靠近自己代码的位置开始。栈轨迹中的一串方法名正是调用链:它告诉你错误在哪一层被发现、由哪一层调用而来。
完成一个方法后做最后检查:名字能否概括一件事;参数是否最少且顺序自然;返回值与副作用是否明确;所有正常路径是否返回;非法输入是否一致处理;数组或对象是否被意外修改;重载是否可能二义;递归是否收敛;文档和测试是否覆盖边界。