Skip to content

元数据卡

  • 前置知识:变量与类型(第2章)、控制流(第4章)
  • 预计时间:50 分钟
  • 核心难度:
  • 阅读模式: 高度专注
  • 可选跳过:如果只求应用,可跳过"栈帧拆解"部分,直接看代码和练习
  • 完成标志:能够写出带参数和返回值的方法,理解调用时栈的压入弹出过程,能画出简单调用栈

本章分层

  • 必读:方法定义与调用、参数传递、返回值、方法重载
  • 选读:方法重载的工程运用、void 与返回类型的抉择
  • 深水区:调用栈帧结构与压栈/弹栈过程

本章不会要求你掌握

  • 递归的深入分析(见第5+章《递归深入》)
  • 值传递 vs 引用对象的深入区别(将在第7章面向对象后深入)

你在哪

老陈的兵器铺里今天特别安静。你推门进去,看见他正在炉前凝神——不是在打铁,是在等你。

变量村的兵器铺里,老陈师傅正在打一把新兵器。

你站在一旁看了半天。你学会了变量(把你的工具装在口袋里),学会了条件判断(做决定),学会了循环(重复做一件事)。你已经能写出一直在跑、一直在改变的程序了。

但你还只会把代码写成一整块——从上到下,像竹竿一样。如果你想做两件不一样的事,你得把同样的代码写两遍。如果想做一件复杂的事,你得把所有步骤揉在一个大熔炉里。

"一味蛮干,"老陈头也不抬,"你见过哪个铁匠打一把剑要从挖矿开始?"

你的任务

你面前有五个问题:你的代码越来越长,读到后面忘了前面;你想让一段逻辑反复用,只能复制粘贴;你想给"加工"这件事交给一个人做,自己只传材料进去——它帮你做完了再还给你。

这章的任务就是学会拆解——把一个大任务拆成有名字的小任务,每个小任务只管一件事,接收它需要的东西,返回它做出的成果。

拆完后,你会看到编译器帮你搭了一座临时的高塔——每一次调用,一层一层往上搭,做完了一层一层往下拆。那座塔叫调用栈

遭遇战 → 获得技能

第一幕:重复的噩梦

假设你在写一个程序:显示三次欢迎信息。

java
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预期输出

====== 欢迎来到变量村 ======
冒险者,你准备好了吗?
====== 欢迎来到变量村 ======
再次提醒:带好你的干粮和水
====== 欢迎来到变量村 ======
最后说一次:前路危险!

你的第一反应可能是:"不对,我应该写一个循环。"好,用循环改写:

java
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)就是那个"有名字的东西"。

第二幕:定义你第一个方法

打开你的编辑器。别复制,自己打一遍——手打才能感觉到节奏:

java
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,再回来。

方法的基本语法:

java
static void 方法名() {
    // 你想做的事
}
  • static:现在先记住它——"这个方法属于类本身,不需要创建对象就能调用"。第7章会拆解它。
  • void:这个方法不返回任何东西。它做完了事,没有"成果"给你。
  • 方法名:小驼峰命名,动词开头。showBannercalculateDamageisReady

🐍 Python 差异:

python
def show_banner():
    print("====== 铁匠铺 ======")
    print("           老陈在此")
    print("====================")

# 调用
show_banner()

Python 用 def 关键字,没有 static 和返回类型声明。方法名用蛇形命名(snake_case)。不需要用分号——但这对你来说是好事还是坏事?答案是:省了打字,丢了视觉边界感。

第三幕:传参数进去

你师傅说:"展示个横幅有啥用?你得能告诉我——给谁打、打什么。"

方法接收参数,就像老陈接收材料——你把矿石扔过去,他才知道要炼什么刀。

java
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)。形参就像在方法入口处声明了两个临时变量——调用时被赋值为你传进来的值。

java
// 你写的调用:
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 没有"传引用"这个选项(尽管后面会看到引用类型变量的值本身是地址——但那是"传值地址",不是真正意义上的传引用)。

第四幕:返回值——你做出东西得还给我

把矿石扔进去炼,炼完了你得把剑拿回来。

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 做了两件事:

  1. 计算出结果并返回给调用者
  2. 立即结束方法——后面的代码不会执行
java
static String checkAge(int age) {
    if (age < 18) {
        return "未成年,不能领取冒险执照";
        // System.out.println("这行永远不会执行");  ← 编译器会报错
    }
    return "欢迎领取冒险执照";
}

🐍 Python 差异:

python
def forge_sword(material: str, quality: int) -> str:
    result = f"{'精良' if quality >= 4 else '普通'}{material}剑"
    return result

Python 用 -> str 标记返回类型(3.5+),但这只是类型提示——你不写也照样跑。Java 的 String编译期强制检查的,你要是声明了 String 却没 return,编译器直接拒绝编译。

第五幕:方法重载 —— 同一个名字,不同的兵器

"师傅,打剑是 forgeSword,打盾牌难道要叫 forgeShield?"

"不用,"老陈笑了,"就叫 forge。传进来的东西不同,我做的自然不同。"

java
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):同一个方法名,不同参数列表(数量不同/类型不同/顺序不同)。编译器根据你调用时传的参数,自己决定用哪个版本。

规则一字不改

  • 必须参数列表不同(数量、类型、顺序)
  • 光返回类型不同不够——编译器只看你传了什么,不看你要什么类型
java
// ❌ 编译错误:仅返回类型不同
static int calc(int a) { return a * 2; }
static double calc(int a) { return a * 2.0; }

** C++ 差异:**

C++ 的重载规则和 Java 几乎一样。但 C++ 还支持默认参数,这可以替代一部分重载:

cpp
std::string forge(const std::string& material, int level = 3) { ... }
// 可以只传一个参数调用: forge("铁")

Java 没有默认参数——重载是唯一的替代方案。不过 Java 22 开始预览了,但在惯用法上默认参数还不是官方特性。

🏔 深入冒险:栈帧与调用栈

第六幕:藏在背后的高塔

你调用方法的时候,电脑做了什么?

不是"跑到另一个地方执行",不是"跳过去"。是一个精确的、可预测的结构——你每次调用方法,JVM 在当前线程的调用栈上压入一个栈帧

栈帧 = 一个方法调用的"快照",包含:

  • 局部变量表(你方法里声明的所有变量)
  • 操作数栈(做运算时临时放值的地方)
  • 方法返回地址(执行完后回到哪一行)

打个比方:你走进老陈的铁匠铺,把剑坯递过去(压栈)。老陈开始打铁(执行方法)。打完了把剑还给你(返回值)。你走出铁匠铺(出栈),回到你原来的位置。

如果你在 forge 里又调用了 checkMaterial——那老陈的活没干完,他得先停下来,让你去找材料检验员。检验员检验完了,回来说"钢的,没问题",老陈再继续打。

这就形成了一座高塔

text
main 调用 forge("钢", 5)

  ├─ 压入栈帧:forge("钢", 5)
  │    执行到一半,发现需要检查材料强度

  ├─ 压入栈帧:checkStrength("钢")
  │    执行完毕,返回 "硬度8"
  │    弹出栈帧 ← 检查员走了

  ├─ 继续执行 forge,拿到结果
  │    执行完毕,返回 "精良钢剑"
  │    弹出栈帧 ← 铁匠也走了

  └─ main 继续

让我们用代码看到它:

java
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 结束

输出顺序反映的是调用栈的压入弹出顺序。如果嵌套更深:

java
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)。你传给方法的任何东西,都是它的副本。

java
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 = 10

x 没有被方法改掉。因为 numx 的值的一个复制品,它们在内存里的位置不同。

"那数组和对象呢?"你问。

java
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]

"数组被改了!"你喊到,"这不是引用传递吗?"

不是。 来,看这个:

java
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++ 给了你第三种选择——真正的引用传递

cpp
void reassignArray(int*& nums) {  // 指针的引用
    nums = new int[3]{100, 200, 300};
}
// 这么调用后,外面的指针确实被改了

但这是 C++ 独有的能力,Java 和 Python 都不支持。

常见陷阱

陷阱一:无限递归——栈击穿了

🏁 本章只做预告:递归的完整原理与深入分析在第5+章《递归深入》中展开。 这里只展示其症状——希望你能意识到"没有终止条件的递归会耗尽调用栈"。

java
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

实战教训:写递归一定要有终止条件

陷阱二:方法太长

一个方法写了一二百行?你遇到了典型的"大方法"反模式。

java
// ❌ 不要这样
static void processOrder() {
    // 验证
    // 计算价格
    // 计算折扣
    // 更新库存
    // 发送邮件
    // 记录日志
    // 生成收据
    // ... 二百行代码
}

每做一个独立的事情,就提取成一个方法。一个方法只做一件事,名字就说明它做了什么:

java
// ✅ 这样
static void processOrder() {
    validateOrder();
    calculatePrice();
    updateInventory();
    sendConfirmation();
    generateReceipt();
}

这就像老陈的铁匠铺——不是你一个人又挖矿又炼铁又打剑又磨剑。每道工序一个人,分工明确。

陷阱三:魔法数字

java
// ❌
double total = price * 0.85;
if (score > 60) { ... }

这些数字是什么意思?半年后你自己也看不懂。用方法封装起来:

java
// ✅
static double applyDiscount(double price) {
    return price * getDiscountRate();
}
static double getDiscountRate() {
    return 0.85;  // 85折
}

一个小技巧:如果你发现一段代码需要加注释才能解释它在做什么——提取它成一个方法,方法名就是注释。

通关挑战

  • 🗡 热身(5 分钟,必做)
  1. 写一个方法 isEven(int n),返回 true 如果 n 是偶数。在 main 里循环打印 1 到 10 的奇偶判断。
  2. 写一个方法 max(int a, int b, int c) 返回三个数中最大的。
  3. 重载 greet 方法:greet(String name)greet(String name, String title),输出不同的问候语。
  • 挑战(30 分钟,选做)
  1. 写一个程序,模拟老陈牌计算器:定义 addsubmuldiv 四个方法,从 main 传入两个数,输出四则运算结果。

  2. 修改下面这个有 bug 的程序——它试图交换两个变量,但没有成功。解释为什么,并修复它:

java
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 里直接交换。

  1. 写一个程序,依次调用三个嵌套方法(A→B→C),在每层打印当前深度(可以用一个 depth 参数传递)。观察输出顺序。
  • 观察

写一个递归程序(比如计算 1 到 n 的累加),在不同的 n 值下运行,观察报 StackOverflowError 的临界点。尝试用 java -Xss256k Sum 调小栈空间,看看临界值的变化。

验收标准

  • 你能写出带参数和返回值的方法
  • 你知道 void 表示不返回
  • 你能解释"值传递"——为什么 swap(a, b) 无法在方法内部交换外部变量
  • 你能画出 A→B→C 嵌套调用的调用栈变化
  • 你能解释方法重载的规则(同名、不同参数列表)
  • 你知道 StackOverflowError 的原因——调用栈满

常见卡点

"我方法里的变量和 main 里的变量重名了会怎样?" 它们是不同的。每个方法的变量在自己的栈帧里,同名也没关系——栈帧之间是隔离的。这叫"局部变量的作用域(scope)"。

"为什么方法必须是 static?" 现在记住:mainstatic 的,所以它直接调用的方法也必须是 static 的(否则一个静态的 main 不能调用非静态的东西)。第7章会解开这个谜。

"我传了个大数组进去,是不是复制了一份?" 不,传的是数组引用的值(地址)。数组本身没有复制。你传了一个小地址纸片,但指向的是同一个大数组。

现在不需要理解

  • publicprivateprotected 这些访问修饰符的确切含义(第7章封装会细讲)
  • static 的完整含义(同上)
  • 方法和函数的区别(在 OOP 中,方法属于类/对象;函数独立于类)
  • 递归(下章马上讲,别急)
  • 可变参数(void foo(int... nums))——现在先不要用,容易混淆

旅人笔记

方法让你把一段逻辑打包成一个有名字的小任务。参数是输入,返回值是输出。Java 只有值传递——你传的是副本。每次方法调用,JVM 在调用栈上压入一个栈帧;调用结束后弹出。方法重载:同名不同参数。记住一句话:一个方法做一件事。

下一站预告

你现在学会了把任务拆成方法——然后一个方法调另一个、再调另一个。但如果一个方法调用自己呢?听起来像俄式套娃,也像通往无限深渊的阶梯。它会压爆你的调用栈吗?它比循环更强大还是更危险?下一章,我们走进递归

Built with VitePress | Software Systems Atlas