元数据卡
- 前置知识:方法定义与调用(第5章)
- 预计时间:15 分钟
- 阅读模式: 高度专注
- 完成标志:能画出简单调用栈,理解值传递
本章分层
- 必读:调用栈帧结构与压栈/弹栈过程
- 选读:值传递 vs 引用对象的区别
- 进阶:无
第五幕:藏在背后的高塔
你调用方法的时候,电脑做了什么?
不是"跑到另一个地方执行",不是"跳过去"——是一个精确的、可预测的结构:你每次调用方法,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 开始 → 3. add 结束 → 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");
}
}预期输出:A → B → C → D → D → E → F
A→B→C→D→(最深的先结束)→D→E→F。栈是后进先出——最后调用的方法最先执行完并弹出。
第七幕:值传递的真相
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]"数组被改了!这难道不是引用传递吗?"
不是。 你可以改数组内容(副本指向同一个数组对象),但不能让 nums 指向新数组来改变 main 里的 arr。因为 nums 本身是 arr 的地址值的副本。
用老陈的话说:"你拿着地图的副本可以找到那座山,但你换一张地图,原来的地图不会跟着变。"
陷阱:无限递归——栈击穿了
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。
实战教训:写递归一定要有终止条件。每调用一次方法,栈帧就多一层——栈帧不是无限的。
旅人笔记
每次方法调用,JVM 在调用栈上压入一个栈帧;调用结束后弹出。Java 只有值传递——你传的是副本。调用栈是后进先出的高塔,记住一句话:你看到的变量名只是名字,栈帧之间是隔离的。
→ 下一站:方法重载
你已经能把任务拆成方法了。但如果你想让两个方法做相似但不同的事,却不想取两个不同的名字呢?就像老陈的锻造——传进来的材料不同,打出来的东西自然不同。下一节,方法重载。