本节目标:能判断一个变量在哪些行可用、活到什么时候,能读懂方法调用栈和
javap的局部变量表,并知道递归为什么会把栈撑爆。
适用版本:JDK 25 LTS(Temurin 25.0.5);作用域规则与栈帧结构在 JDK 21 上一致。
上一节留下的问题
6.2 解释了参数怎么传:基本类型复制数值,对象复制引用。但还有一组问题没回答:
borrow里的局部变量book在方法返回后去哪了?- 为什么
for (Book book : books)结束后再写book会编译失败? addBook里的book明明已经消失,为什么书还在books列表里?- 为什么递归太深会抛
StackOverflowError,而不是一直运行下去?
前两个是作用域问题:名字能用的范围。第三个是生命周期问题:对象活多久。第四个是栈帧问题:每次方法调用在内存里留下什么。三件事经常被混为一谈,分开看就清楚了。
作用域:名字能用的范围
Java 是块级作用域。一对花括号 {} 划定一个块,局部变量从声明那一行开始,到所在块结束为止可用。
循环变量只在循环里
static void loopScope(List<Book> books) {
for (Book book : books) {
System.out.println(book.title);
}
System.out.println(book.title);
}
本机 JDK 25 报:
错误: 找不到符号
System.out.println(book.title);
^
符号: 变量 book
book 是循环头里声明的变量,作用域是循环体。循环结束,名字就失效。传统的 for (int i = 0; ...) 也一样,i 在循环外不可见。
这不是限制,而是保护:如果 i 泄漏到外面,下一次循环复用同名变量时,读者很难判断它现在代表哪一轮。让变量的作用域尽量小,是减少「这个值什么时候被改过」这类问题的低成本手段。
分支各自成块
if 和 else 的花括号各自是一个块:
if (book.available) {
String status = "available";
System.out.println(status);
} else {
String status = "borrowed";
System.out.println(status);
}
两个 status 位于不同的块,可以同名,互不影响。块结束后都不可见。这种写法比在 if 外面声明一个 status 再在两个分支里赋值更安全:后者存在「某个分支忘了赋值」的可能,编译器会用「可能尚未初始化」拦住你。
未初始化不能使用
static void uninitialized() {
int count;
System.out.println(count);
}
本机 JDK 25 报:
错误: 可能尚未初始化变量count
局部变量没有默认值。字段有默认值(int 是 0,引用是 null),局部变量没有——编译器要求每条执行路径在使用前都明确赋值。这条规则挡住了一整类「读到随机值」的 bug。
同名变量不能嵌套声明
static void redeclare() {
int total = 1;
{
int total = 2;
System.out.println(total);
}
}
本机 JDK 25 报:
错误: 已在方法 redeclare()中定义了变量 total
内层块不能重新声明外层的局部变量。原因和循环变量一样:同名遮蔽会让「这一行用的是哪个 total」变成需要回溯的谜题。Java 选择直接禁止。
字段不受这条限制。字段 title 和局部变量 title 可以同名,块内引用的是局部变量;要访问字段就写 this.title:
public class Book {
private String title;
public void rename(String title) {
this.title = title;
}
}
参数 title 遮蔽了字段 title,this.title 明确指向字段。这种写法在 setter 和构造器里很常见,读的时候注意 this. 的有无即可。
静态上下文没有 this
public class StaticContext {
private final List<String> books = List.of("a");
public static void main(String[] args) {
System.out.println(books);
}
}
本机 JDK 25 报:
错误: 无法从静态上下文中引用非静态 变量 books
main 是静态方法,执行时还没有任何 StaticContext 实例,自然没有 books 这个实例字段。解决办法是先 new StaticContext(),再通过实例访问;或者把字段改成 static,但那就变成所有实例共享一份,通常不是想要的。
LibraryApp 的 main 写 new LibraryApp().run(),正是为了先创建实例,再调用实例方法 run。run 里的 in、service 都是实例字段,可以正常使用。
生命周期:作用域结束不等于对象死亡
局部变量的作用域和方法调用的栈帧绑定。方法返回,栈帧弹出,局部变量消失。但局部变量引用的对象不一定消失。
LibraryService.addBook 是最好的例子:
public Book addBook(String isbn, String title, String author, int publishedYear) {
Book book = new Book(isbn, title, author);
book.publishedYear = publishedYear;
books.add(book);
return book;
}
方法返回后,局部变量 book 不可见了,但它指向的 Book 对象还被 books 列表引用着,所以对象继续存活。调用方拿到的返回值又是同一个对象的另一个引用。一个对象可以同时被多处引用,只要还有一条可达路径,它就不会被回收。
反过来,如果方法里创建了对象却没有交给任何人:
Book temp = new Book("000", "Temp", "Nobody");
temp.available = false;
方法返回后,temp 消失,也没有别的引用指向这个对象,它才变成垃圾。决定对象生死的是可达性,不是某个局部变量的作用域。
null 不是「立即释放」
把引用赋成 null 只是断开一条可达路径,不等于对象立刻被回收。垃圾回收器什么时候跑、跑多久,由 JVM 决定;System.gc() 也只是「建议」,不保证立即执行。日常代码不需要手动置 null,让变量自然离开作用域即可。
对象可能活得比方法长很多
books 列表是 LibraryService 的字段。只要 LibraryService 实例还被 LibraryApp 引用,列表就活着;列表里的 Book 也就活着。整条引用链是:
LibraryApp 实例
└── service 字段 ──> LibraryService 实例
└── books 字段 ──> ArrayList
└── elementData ──> Book 对象
理解这条链,才能回答「为什么我的对象没被回收」——通常是某条链没断,比如静态集合、缓存、监听器注册表。卷四讲内存分析时会用堆转储把这条链画出来。
栈帧:每次调用一张新桌子
JVM 用栈(stack)管理方法调用。每调用一个方法,就压入一个栈帧(stack frame);方法返回,栈帧弹出。栈帧里主要有这些内容:
| 内容 | 作用 |
|---|---|
| 局部变量表 | 存放 this、参数、局部变量,按槽位(slot)编号 |
| 操作数栈 | 字节码指令临时放操作数和结果的地方 |
| 返回地址(实现相关) | 方法结束后回到调用方的哪条指令 |
| 常量池引用 | 指向当前类的运行时常量池 |
栈是后进先出的。main 调用 run,run 调用 dispatch,dispatch 调用 handleBorrow,handleBorrow 调用 borrow,borrow 调用 findByIsbn,栈里就叠了六层:
┌─────────────────────────────┐
│ findByIsbn 的栈帧 │ ← 栈顶,当前执行
├─────────────────────────────┤
│ borrow 的栈帧 │
├─────────────────────────────┤
│ handleBorrow 的栈帧 │
├─────────────────────────────┤
│ dispatch 的栈帧 │
├─────────────────────────────┤
│ run 的栈帧 │
├─────────────────────────────┤
│ main 的栈帧 │ ← 栈底
└─────────────────────────────┘
每次调用都有独立的局部变量表。borrow 的 book 和 findByIsbn 的 book 是两个不同的槽位,互不干扰。这也解释了递归:递归方法调用自己时,每一层都是一个新的栈帧,局部变量各存一份。
用 javap 看局部变量表
编译时加 -g 保留调试信息,javap -c -l 会把局部变量表列出来。LibraryService.borrow 的关键部分:
LocalVariableTable:
Start Length Slot Name Signature
0 45 0 this Lcom/plume/library/LibraryService;
0 45 1 isbn Ljava/lang/String;
0 45 2 memberId Ljava/lang/String;
13 32 3 book Lcom/plume/library/Book;
四个槽位:this 占 0,两个参数占 1 和 2,局部变量 book 占 3。Start 是字节码偏移:book 从 13 开始,因为前面的指令要先调用 findByIsbn,返回的引用在偏移 12 的 astore_3 才存进槽位 3。Length 是它有效的字节码长度。
静态方法的槽位从 0 开始放参数,没有 this;实例方法固定用 0 放 this。这就是为什么实例方法比同参数的静态方法多占一个槽位。
读调用栈
抛异常时,JVM 把当前栈帧从栈顶到栈底打印出来。LibraryApp 里输入一个非数字年份:
Exception in thread "main" java.lang.NumberFormatException: For input string: "abcd"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:565)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at com.plume.library.LibraryApp.handleAdd(LibraryApp.java:60)
at com.plume.library.LibraryApp.dispatch(LibraryApp.java:32)
at com.plume.library.LibraryApp.run(LibraryApp.java:23)
at com.plume.library.LibraryApp.main(LibraryApp.java:12)
这是本机 JDK 25 的真实输出,行号以实际文件为准。读法是从下往上:main 调用 run,run 调用 dispatch,dispatch 调用 handleAdd,handleAdd 调用 Integer.parseInt,parseInt 内部又调了两层。异常在栈顶抛出,栈帧记录的是「谁调用了谁」。
最上面几帧是 JDK 内部实现,通常不需要看;找到第一个属于自己包的帧(这里是 LibraryApp.handleAdd),问题就在那一行附近。第 13 章会讲怎么把这类底层异常转换成用户能读懂的提示。
递归与 StackOverflowError
递归方法每调用一次就压一个栈帧。栈的大小有限,压不下时 JVM 抛 StackOverflowError:
static int down(int n) {
return down(n + 1);
}
本机 JDK 25 用 -Xss256k 把栈调小后运行,输出前几行是:
Exception in thread "main" java.lang.StackOverflowError
at com.plume.library.OverflowDemo.down(OverflowDemo.java:5)
at com.plume.library.OverflowDemo.down(OverflowDemo.java:5)
at com.plume.library.OverflowDemo.down(OverflowDemo.java:5)
at com.plume.library.OverflowDemo.down(OverflowDemo.java:5)
at com.plume.library.OverflowDemo.down(OverflowDemo.java:5)
同一个方法重复出现,正是递归栈帧一层层叠上去的证据。-Xss 控制每个线程的栈大小,默认值随平台和 JDK 版本变化;调大能让递归更深,但不能把「没有终止条件」变成「能跑完」。
递归必须有收敛的终止条件,并且每层要把问题规模缩小。library 的查找用循环而不是递归,因为线性搜索本来就没有「分治」的结构;只有问题能拆成同构的子问题时,递归才值得用。
在 library 里落地
把借还逻辑收进 LibraryService,并加上按作者计数的方法:
public boolean borrow(String isbn, String memberId) {
Objects.requireNonNull(memberId, "memberId");
Book book = findByIsbn(isbn);
if (book == null || !book.available) {
return false;
}
book.available = false;
loans.put(isbn, memberId);
return true;
}
public int countByAuthor(String author) {
int count = 0;
for (Book book : books) {
if (book.author.equals(author)) {
count++;
}
}
return count;
}
borrow 里的局部变量有三个:参数 isbn、memberId,以及 findByIsbn 返回后绑定的 book。它们都在方法返回时消失;book 指向的对象仍被 books 持有,loans 里新增的映射也留在服务实例里。
countByAuthor 的 count 是方法级局部变量,每次调用都从 0 开始;book 是循环变量,每轮绑定一个元素。循环结束后 book 不可见,count 作为返回值传出。
写一个小程序直接调用服务:
LibraryService service = new LibraryService();
service.addBook("978-7-111-1", "Effective Java", "Joshua Bloch", 2008);
service.addBook("978-7-118-3", "Java Puzzlers", "Joshua Bloch", 2005);
service.addBook("978-7-115-2", "Java Concurrency in Practice", "Brian Goetz", 2006);
System.out.println("by Joshua Bloch = " + service.countByAuthor("Joshua Bloch"));
System.out.println("first borrow = " + service.borrow("978-7-111-1", "m-001"));
System.out.println("second borrow = " + service.borrow("978-7-111-1", "m-002"));
System.out.println("count = " + service.count());
本机 JDK 25 输出:
by Joshua Bloch = 2
first borrow = true
second borrow = false
count = 3
countByAuthor 返回 2,因为两本书的作者都是 Joshua Bloch;第二次借同一本书返回 false,因为 available 已经被第一次调用改成 false。count 仍是 3,说明借出不会把书从列表里删掉——借出状态和是否存在是两件事,第 7 章会把前者从 Book 里移走。
borrow 第一行的 Objects.requireNonNull(memberId, "memberId") 是防御性检查:传入 null 时立刻抛 NullPointerException,调用栈里第一个属于自己代码的帧就是 borrow,而不是等到 loans.put 或更深的调用才炸。错误越早暴露,栈追踪越短,定位越快。
常见错误与边界
在循环外使用循环变量。 for (Book book : books) 结束后 book 不可见。需要在循环外保留某个元素时,在循环外声明一个变量,在循环内赋值,并想清楚「一个都没匹配」时它是什么值。
声明了局部变量却忘了赋值。 局部变量没有默认值,编译器用「可能尚未初始化」拦住。字段有默认值,所以「字段忘了赋值」不会编译失败,只会得到 0 或 null——这类问题更隐蔽,第 7 章的构造器会强制把字段一次赋好。
在内层块重新声明同名变量。 编译失败。字段可以被局部变量遮蔽,但同一个方法里的局部变量不能互相遮蔽。需要复用名字时,换一个更具体的名字,或者把内层逻辑抽成独立方法。
在静态方法里直接访问实例字段。 报「无法从静态上下文中引用非静态变量」。main 里先创建实例,再调用实例方法。
把「作用域结束」当成「对象被回收」。 局部变量消失只断了一条引用;只要集合、缓存、其他对象还引用着它,对象就活着。排查内存问题时,找的是「谁还引用着它」,不是「哪个变量出了作用域」。
递归没有终止条件,或者终止条件不收敛。 每层必须让问题规模严格变小。栈溢出时先看重复出现的帧,确认递归参数是不是在朝终止条件靠近。
小结
- 作用域由花括号决定:局部变量从声明到块结束,循环变量只在循环内,分支各自成块。
- 局部变量使用前必须明确赋值;内层块不能重新声明外层局部变量;字段可以被局部变量遮蔽,用
this访问字段。 - 静态上下文没有
this,不能直接访问实例字段;main通常先创建实例再调用实例方法。 - 生命周期由可达性决定:栈帧弹出只让局部变量消失,对象只要还被引用就继续存活。
- 栈帧包含局部变量表、操作数栈、常量池引用等;实例方法的槽位 0 固定是
this。 - 调用栈从下往上读是「谁调用了谁」;递归每层压一个栈帧,过深会抛
StackOverflowError。 library的借还逻辑共享同一个Book对象,借出状态通过引用修改;这个设计在第 7 章会被封装和不可变化改造。
第 6 章讲的是「方法」:签名、传参、作用域和栈帧。第 7 章回到对象本身——Book 和 Member 应该有哪些字段、构造器怎么让对象一创建就合法、封装和不可变到底解决什么问题。
阅读导航:上一节:6.2 值传递的真相 · 下一节:7.1 类、字段与方法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。