《Java 编程入门》6.3 作用域、生命周期与栈帧

从 LibraryService.borrow 的局部变量讲起,分清作用域与生命周期:花括号决定名字能用到哪里,栈帧决定局部变量活到什么时候,可达性决定对象活到什么时候;再用 javap 的局部变量表、真实调用栈和 StackOverflowError 把这三件事对上。

本节目标:能判断一个变量在哪些行可用、活到什么时候,能读懂方法调用栈和 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 类、字段与方法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发