元数据卡
- 前置知识:变量与类型(第2章)、控制流(第4章)
- 预计时间:50 分钟
- 核心难度:
- 阅读模式: 高度专注
- 可选跳过:如果只求应用,可跳过"栈帧拆解"部分,直接看代码和练习
- 完成标志:能够写出带参数和返回值的方法,理解调用时栈的压入弹出过程,能画出简单调用栈
本章分层
- 必读:方法定义与调用、参数传递、返回值、方法重载
- 选读:方法重载的工程运用、
void与返回类型的抉择- 深水区:调用栈帧结构与压栈/弹栈过程
本章不会要求你掌握
- 递归的深入分析(见第5+章《递归深入》)
- 值传递 vs 引用对象的深入区别(将在第7章面向对象后深入)
你在哪
老陈的兵器铺里今天特别安静。你推门进去,看见他正在炉前凝神——不是在打铁,是在等你。
变量村的兵器铺里,老陈师傅正在打一把新兵器。
你站在一旁看了半天。你学会了变量(把你的工具装在口袋里),学会了条件判断(做决定),学会了循环(重复做一件事)。你已经能写出一直在跑、一直在改变的程序了。
但你还只会把代码写成一整块——从上到下,像竹竿一样。如果你想做两件不一样的事,你得把同样的代码写两遍。如果想做一件复杂的事,你得把所有步骤揉在一个大熔炉里。
"一味蛮干,"老陈头也不抬,"你见过哪个铁匠打一把剑要从挖矿开始?"
你的任务
你面前有五个问题:你的代码越来越长,读到后面忘了前面;你想让一段逻辑反复用,只能复制粘贴;你想给"加工"这件事交给一个人做,自己只传材料进去——它帮你做完了再还给你。
这章的任务就是学会拆解——把一个大任务拆成有名字的小任务,每个小任务只管一件事,接收它需要的东西,返回它做出的成果。
拆完后,你会看到编译器帮你搭了一座临时的高塔——每一次调用,一层一层往上搭,做完了一层一层往下拆。那座塔叫调用栈。
遭遇战 → 获得技能
第一幕:重复的噩梦
假设你在写一个程序:显示三次欢迎信息。
public class Welcome {
public static void main(String[] args) {
System.out.println("====== 欢迎来到变量村 ======");
System.out.println("冒险者,你准备好了吗?");
System.out.println("====== 欢迎来到变量村 ======");
System.out.println("再次提醒:带好你的干粮和水");
System.out.println("====== 欢迎来到变量村 ======");
System.out.println("最后说一次:前路危险!");
}
}语言:Java 21 如何运行:保存为 Welcome.java,运行 javac Welcome.java && java Welcome预期输出:
====== 欢迎来到变量村 ======
冒险者,你准备好了吗?
====== 欢迎来到变量村 ======
再次提醒:带好你的干粮和水
====== 欢迎来到变量村 ======
最后说一次:前路危险!你的第一反应可能是:"不对,我应该写一个循环。"好,用循环改写:
public class Welcome {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
System.out.println("====== 欢迎来到变量村 ======");
}
System.out.println("冒险者,你准备好了吗?");
System.out.println("再次提醒:带好你的干粮和水");
System.out.println("最后说一次:前路危险!");
}
}好多了。但假设——这个欢迎横幅要同时出现在十个不同的地方,每种场合的文字不同。
你发现自己还在复制三行重复的结构。真正的问题是:你没法把一个"动作"打包成一个有名字的东西。
而方法(method)就是那个"有名字的东西"。
第二幕:定义你第一个方法
打开你的编辑器。别复制,自己打一遍——手打才能感觉到节奏:
public class Blacksmith {
public static void main(String[] args) {
showBanner();
System.out.println("老陈师傅正在打铁。");
showBanner();
}
// ↓↓↓ 看你师傅这里 ↓↓↓
static void showBanner() {
System.out.println("====== 铁匠铺 ======");
System.out.println(" 老陈在此");
System.out.println("====================");
}
}语言:Java 21 如何运行:javac Blacksmith.java && java Blacksmith预期输出:
====== 铁匠铺 ======
老陈在此
====================
老陈师傅正在打铁。
====== 铁匠铺 ======
老陈在此
====================注意命令行里的细节。你只是在 main 里调用了两次 showBanner(),程序就跑到了另一个地方,执行里面的三条 println,再回来。
方法的基本语法:
static void 方法名() {
// 你想做的事
}static:现在先记住它——"这个方法属于类本身,不需要创建对象就能调用"。第7章会拆解它。void:这个方法不返回任何东西。它做完了事,没有"成果"给你。- 方法名:小驼峰命名,动词开头。
showBanner、calculateDamage、isReady。
🐍 Python 差异:
pythondef show_banner(): print("====== 铁匠铺 ======") print(" 老陈在此") print("====================") # 调用 show_banner()Python 用
def关键字,没有static和返回类型声明。方法名用蛇形命名(snake_case)。不需要用分号——但这对你来说是好事还是坏事?答案是:省了打字,丢了视觉边界感。
第三幕:传参数进去
你师傅说:"展示个横幅有啥用?你得能告诉我——给谁打、打什么。"
方法接收参数,就像老陈接收材料——你把矿石扔过去,他才知道要炼什么刀。
public class Forge {
public static void main(String[] args) {
craftSword("铁剑", 3);
craftSword("钢刀", 5);
craftSword("秘银匕首", 2);
}
static void craftSword(String name, int days) {
System.out.println("开始打造:" + name);
System.out.println("预计需要 " + days + " 天");
System.out.println(name + " 打造完成!");
System.out.println("---");
}
}语言:Java 21 预期输出:
开始打造:铁剑
预计需要 3 天
铁剑 打造完成!
---
开始打造:钢刀
预计需要 5 天
钢刀 打造完成!
---
开始打造:秘银匕首
预计需要 2 天
秘银匕首 打造完成!
---参数的本质:craftSword(String name, int days) 声明了两个"形参"(parameter)。你调用时传进去的值("铁剑", 3)叫"实参"(argument)。形参就像在方法入口处声明了两个临时变量——调用时被赋值为你传进来的值。
// 你写的调用:
craftSword("铁剑", 3);
// 实际发生的事(编译器视角):
// String name = "铁剑";
// int days = 3;
// 然后执行 craftSword 内部的代码** C++ 差异:**
cpp`#include <iostream>` void craftSword(const std::string& name, int days) { // const & 是传引用 std::cout << "开始打造:" << name << std::endl; }C++ 默认也是值传递。但 C++ 多了一个选择:传引用。你在
name前面加const &,表示"我不复制一份,直接看你原来的——但我不改它"。Java 没有"传引用"这个选项(尽管后面会看到引用类型变量的值本身是地址——但那是"传值地址",不是真正意义上的传引用)。
第四幕:返回值——你做出东西得还给我
把矿石扔进去炼,炼完了你得把剑拿回来。
public class Forge {
public static void main(String[] args) {
String sword1 = forgeSword("铁", 3);
String sword2 = forgeSword("钢", 5);
System.out.println("你得到了:" + sword1);
System.out.println("你得到了:" + sword2);
}
static String forgeSword(String material, int quality) {
// 根据材料和品质组合名字
String result = quality >= 4
? "精良" + material + "剑"
: "普通" + material + "剑";
return result; // ← 返回成果
}
}如何运行:javac Forge.java && java Forge预期输出:
你得到了:普通铁剑
你得到了:精良钢剑void 变成了 String——"这个方法运行完毕后,会还给你一个 String 类型的值"。
然后在方法体内,你需要 return 那个值。return 做了两件事:
- 计算出结果并返回给调用者
- 立即结束方法——后面的代码不会执行
static String checkAge(int age) {
if (age < 18) {
return "未成年,不能领取冒险执照";
// System.out.println("这行永远不会执行"); ← 编译器会报错
}
return "欢迎领取冒险执照";
}🐍 Python 差异:
pythondef forge_sword(material: str, quality: int) -> str: result = f"{'精良' if quality >= 4 else '普通'}{material}剑" return resultPython 用
-> str标记返回类型(3.5+),但这只是类型提示——你不写也照样跑。Java 的String是编译期强制检查的,你要是声明了String却没return,编译器直接拒绝编译。
第五幕:方法重载 —— 同一个名字,不同的兵器
"师傅,打剑是 forgeSword,打盾牌难道要叫 forgeShield?"
"不用,"老陈笑了,"就叫 forge。传进来的东西不同,我做的自然不同。"
public class Forge {
public static void main(String[] args) {
System.out.println(forge("铁", 3)); // 打铁剑
System.out.println(forge("钢", 5)); // 打钢剑
System.out.println(forge("橡木", 4)); // 打木盾
System.out.println(forge("龙鳞", 2, 10)); // 打龙鳞甲,+10防御
}
static String forge(String material, int level) {
return level >= 4
? "精良" + material + "剑"
: "普通" + material + "剑";
}
// 同名方法:参数数量不同
static String forge(String material, int level, int defense) {
return "防御+" + defense + "的" + material + "甲(等级" + level + ")";
}
}预期输出:
普通铁剑
精良钢剑
精良橡木剑
防御+10的龙鳞甲(等级2)这叫方法重载(method overloading):同一个方法名,不同参数列表(数量不同/类型不同/顺序不同)。编译器根据你调用时传的参数,自己决定用哪个版本。
规则一字不改:
- 必须参数列表不同(数量、类型、顺序)
- 光返回类型不同不够——编译器只看你传了什么,不看你要什么类型
// ❌ 编译错误:仅返回类型不同
static int calc(int a) { return a * 2; }
static double calc(int a) { return a * 2.0; }** C++ 差异:**
C++ 的重载规则和 Java 几乎一样。但 C++ 还支持默认参数,这可以替代一部分重载:
cppstd::string forge(const std::string& material, int level = 3) { ... } // 可以只传一个参数调用: forge("铁")Java 没有默认参数——重载是唯一的替代方案。不过 Java 22 开始预览了,但在惯用法上默认参数还不是官方特性。
🏔 深入冒险:栈帧与调用栈
第六幕:藏在背后的高塔
你调用方法的时候,电脑做了什么?
不是"跑到另一个地方执行",不是"跳过去"。是一个精确的、可预测的结构——你每次调用方法,JVM 在当前线程的调用栈上压入一个栈帧。
栈帧 = 一个方法调用的"快照",包含:
- 局部变量表(你方法里声明的所有变量)
- 操作数栈(做运算时临时放值的地方)
- 方法返回地址(执行完后回到哪一行)
打个比方:你走进老陈的铁匠铺,把剑坯递过去(压栈)。老陈开始打铁(执行方法)。打完了把剑还给你(返回值)。你走出铁匠铺(出栈),回到你原来的位置。
如果你在 forge 里又调用了 checkMaterial——那老陈的活没干完,他得先停下来,让你去找材料检验员。检验员检验完了,回来说"钢的,没问题",老陈再继续打。
这就形成了一座高塔:
main 调用 forge("钢", 5)
│
├─ 压入栈帧:forge("钢", 5)
│ 执行到一半,发现需要检查材料强度
│
├─ 压入栈帧:checkStrength("钢")
│ 执行完毕,返回 "硬度8"
│ 弹出栈帧 ← 检查员走了
│
├─ 继续执行 forge,拿到结果
│ 执行完毕,返回 "精良钢剑"
│ 弹出栈帧 ← 铁匠也走了
│
└─ main 继续让我们用代码看到它:
public class StackDemo {
public static void main(String[] args) {
System.out.println("1. main 开始");
int result = add(3, 4);
System.out.println("4. result = " + result);
System.out.println("5. main 结束");
}
static int add(int a, int b) {
System.out.println("2. add 开始: a=" + a + ", b=" + b);
int sum = a + b;
System.out.println("3. add 结束, sum=" + sum);
return sum;
}
}预期输出:
1. main 开始
2. add 开始: a=3, b=4
3. add 结束, sum=7
4. result = 7
5. main 结束输出顺序反映的是调用栈的压入弹出顺序。如果嵌套更深:
public class StackDepth {
public static void main(String[] args) {
System.out.println("A: main");
first();
System.out.println("F: main");
}
static void first() {
System.out.println("B: first");
second();
System.out.println("E: first");
}
static void second() {
System.out.println("C: second");
third();
System.out.println("D: second");
}
static void third() {
System.out.println("D: third"); // 注意:这里的 D 和上面的 D 是巧合
}
}预期输出:
A: main
B: first
C: second
D: third
D: second ← 从这里开始往回弹
E: first
F: main看到了吗?打印的顺序是 A→B→C→D→(最深的先结束)→D→E→F。栈是后进先出——最后调用的方法,最先执行完并弹出。
🏔 深入冒险:值传递的真相
第七幕:你不是真的在改它
有一个很隐蔽的知识,你必须在现在就遇到——否则以后 debug 时你会疯掉。
Java 只有值传递(pass-by-value)。你传给方法的任何东西,都是它的副本。
public class ValuePass {
public static void main(String[] args) {
int x = 10;
System.out.println("调用前: x = " + x);
changeValue(x);
System.out.println("调用后: x = " + x); // 还是 10!
}
static void changeValue(int num) {
num = 100; // 改的是副本
System.out.println("方法内: num = " + num);
}
}预期输出:
调用前: x = 10
方法内: num = 100
调用后: x = 10x 没有被方法改掉。因为 num 是 x 的值的一个复制品,它们在内存里的位置不同。
"那数组和对象呢?"你问。
import java.util.Arrays;
public class ValuePassRef {
public static void main(String[] args) {
int[] arr = {1, 2, 3};
System.out.println("调用前: " + Arrays.toString(arr));
changeArray(arr);
System.out.println("调用后: " + Arrays.toString(arr)); // 变了!
}
static void changeArray(int[] nums) {
nums[0] = 99; // 改的是数组里的元素
}
}预期输出:
调用前: [1, 2, 3]
调用后: [99, 2, 3]"数组被改了!"你喊到,"这不是引用传递吗?"
不是。 来,看这个:
public class ValuePassRef2 {
public static void main(String[] args) {
int[] arr = {1, 2, 3};
System.out.println("调用前: " + Arrays.toString(arr));
reassignArray(arr);
System.out.println("调用后: " + Arrays.toString(arr)); // 还是 [1,2,3]!
}
static void reassignArray(int[] nums) {
nums = new int[]{100, 200, 300}; // 让副本指向新数组
System.out.println("方法内: " + Arrays.toString(nums));
}
}预期输出:
调用前: [1, 2, 3]
方法内: [100, 200, 300]
调用后: [1, 2, 3]看到没?你可以改数组内容(因为副本指向同一个数组对象),但不能让方法的 nums 指向一个新数组来改变 main 里的 arr。因为 nums 本身是 arr 的地址值的副本。
用老陈的话说:"你拿着地图的副本,可以找到那座山,在山里挖矿。但你换一张地图,原来的地图不会跟着变。"
🐍 Python 差异: 完全一样。Python 也是值传递——"传对象引用,值是引用的副本"。你做了赋值操作
nums = [100, 200, 300],也只是让局部变量指向另一个对象。** C++ 差异:** C++ 给了你第三种选择——真正的引用传递:
cppvoid reassignArray(int*& nums) { // 指针的引用 nums = new int[3]{100, 200, 300}; } // 这么调用后,外面的指针确实被改了但这是 C++ 独有的能力,Java 和 Python 都不支持。
常见陷阱
陷阱一:无限递归——栈击穿了
🏁 本章只做预告:递归的完整原理与深入分析在第5+章《递归深入》中展开。 这里只展示其症状——希望你能意识到"没有终止条件的递归会耗尽调用栈"。
public class Boom {
public static void main(String[] args) {
dive(); // 💥 崩溃
}
static void dive() {
System.out.println("下潜…");
dive(); // 又调自己
}
}运行后你会看到一长串 "下潜…",然后程序崩溃,报错:
Exception in thread "main" java.lang.StackOverflowError调用栈是有限的。 每个栈帧占一点内存(约几十到几百字节)。当你无限压栈——每压一帧,离边界近一步——最终栈内存耗尽,JVM 扔出一句 StackOverflowError。
一个正常 Java 程序的调用栈深度是:
- 默认大约 1000-2000 层(取决于 JVM 参数和栈帧大小)
- 可以用
-Xss调整:java -Xss2m Boom
实战教训:写递归一定要有终止条件。
陷阱二:方法太长
一个方法写了一二百行?你遇到了典型的"大方法"反模式。
// ❌ 不要这样
static void processOrder() {
// 验证
// 计算价格
// 计算折扣
// 更新库存
// 发送邮件
// 记录日志
// 生成收据
// ... 二百行代码
}每做一个独立的事情,就提取成一个方法。一个方法只做一件事,名字就说明它做了什么:
// ✅ 这样
static void processOrder() {
validateOrder();
calculatePrice();
updateInventory();
sendConfirmation();
generateReceipt();
}这就像老陈的铁匠铺——不是你一个人又挖矿又炼铁又打剑又磨剑。每道工序一个人,分工明确。
陷阱三:魔法数字
// ❌
double total = price * 0.85;
if (score > 60) { ... }这些数字是什么意思?半年后你自己也看不懂。用方法封装起来:
// ✅
static double applyDiscount(double price) {
return price * getDiscountRate();
}
static double getDiscountRate() {
return 0.85; // 85折
}一个小技巧:如果你发现一段代码需要加注释才能解释它在做什么——提取它成一个方法,方法名就是注释。
通关挑战
- 🗡 热身(5 分钟,必做)
- 写一个方法
isEven(int n),返回true如果 n 是偶数。在main里循环打印 1 到 10 的奇偶判断。 - 写一个方法
max(int a, int b, int c)返回三个数中最大的。 - 重载
greet方法:greet(String name)和greet(String name, String title),输出不同的问候语。
- 挑战(30 分钟,选做)
写一个程序,模拟老陈牌计算器:定义
add、sub、mul、div四个方法,从main传入两个数,输出四则运算结果。修改下面这个有 bug 的程序——它试图交换两个变量,但没有成功。解释为什么,并修复它:
public class Swap {
public static void main(String[] args) {
int a = 5, b = 10;
swap(a, b);
System.out.println("a=" + a + ", b=" + b); // 期望 a=10, b=5
}
static void swap(int x, int y) {
int temp = x;
x = y;
y = temp;
}
}提示:值传递。swap 方法的修改只在它的栈帧里生效。考虑用数组或者在 main 里直接交换。
- 写一个程序,依次调用三个嵌套方法(A→B→C),在每层打印当前深度(可以用一个
depth参数传递)。观察输出顺序。
- 观察
写一个递归程序(比如计算 1 到 n 的累加),在不同的 n 值下运行,观察报 StackOverflowError 的临界点。尝试用 java -Xss256k Sum 调小栈空间,看看临界值的变化。
验收标准
- 你能写出带参数和返回值的方法
- 你知道
void表示不返回 - 你能解释"值传递"——为什么
swap(a, b)无法在方法内部交换外部变量 - 你能画出
A→B→C嵌套调用的调用栈变化 - 你能解释方法重载的规则(同名、不同参数列表)
- 你知道
StackOverflowError的原因——调用栈满
常见卡点
"我方法里的变量和 main 里的变量重名了会怎样?" 它们是不同的。每个方法的变量在自己的栈帧里,同名也没关系——栈帧之间是隔离的。这叫"局部变量的作用域(scope)"。
"为什么方法必须是 static?" 现在记住:main 是 static 的,所以它直接调用的方法也必须是 static 的(否则一个静态的 main 不能调用非静态的东西)。第7章会解开这个谜。
"我传了个大数组进去,是不是复制了一份?" 不,传的是数组引用的值(地址)。数组本身没有复制。你传了一个小地址纸片,但指向的是同一个大数组。
现在不需要理解
public、private、protected这些访问修饰符的确切含义(第7章封装会细讲)static的完整含义(同上)- 方法和函数的区别(在 OOP 中,方法属于类/对象;函数独立于类)
- 递归(下章马上讲,别急)
- 可变参数(
void foo(int... nums))——现在先不要用,容易混淆
旅人笔记
方法让你把一段逻辑打包成一个有名字的小任务。参数是输入,返回值是输出。Java 只有值传递——你传的是副本。每次方法调用,JVM 在调用栈上压入一个栈帧;调用结束后弹出。方法重载:同名不同参数。记住一句话:一个方法做一件事。
→ 下一站预告
你现在学会了把任务拆成方法——然后一个方法调另一个、再调另一个。但如果一个方法调用自己呢?听起来像俄式套娃,也像通往无限深渊的阶梯。它会压爆你的调用栈吗?它比循环更强大还是更危险?下一章,我们走进递归。