“离开当前作用域,存储在局部变量中的引用会被回收”,这种说法正确吗?在 Java 中并非如此,代码块还没有结束变量可能已不可用。
1. 写在前面
“[JVM 解剖公园][1]”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,不做写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。
Aleksey Shipilёv,JVM 性能极客
推特 [@shipilev][2]
问题、评论、建议发送到 [aleksey@shipilev.net][3]
[1]:https://shipilev.net/jvm-anatomy-park
[2]:http://twitter.com/shipilev
[3]:aleksey@shipilev.net
2. 问题
“离开当前作用域,存储在局部变量中的引用会被回收”,这种说法正确吗?
3. 理论
这种概念植根于许多具有 C/C++ 经验的程序员大脑中。在语言规范中是这样定义的:
>>>
声明为 `auto`、`register`、非 `static` 或 `extern` 局部变量具有自动存续期,对象会持续存在直到退出创建的代码块。
注意:这些对象会按照6.7中的描述初始化并销毁。
如果 `auto` 对象的初始化或析构函数具有副作用,除非符合12.8中的情形,否则不应在代码块结束时销毁对象或其拷贝,也不能作为未使用的对象将其优化。
— C++98 标准
3.7.2 "Automatic storage duration 自动存续期"
>>>
这是一个非常有用的语言特性,它把对象生存周期与语法中的代码块绑定,例如像下面这样:
```java
void method() {
...执行一些操作...
{
MutexLocker ml(mutex);
...在锁机制的保护下执行...
} // ~MutexLocker 解锁
...执行其他操作...
}
```
有 C++ 经验的程序员会自觉地把这种特性应用到 Java 上。尽管没有析构函数,但还是有办法检测不可到达的代码,并采取相应的行动,例如软引用、弱引用、虚引用、finalizer 等。然而,Java 语法中的代码块和 C++ 的工作机制并不相同。例如:
>>>
“Java 编译器优化转换后,可访问的对象数量比通常认为的要少。例如,编译器或生成器会把不再使用的对象置为 `null`,从而加速内存回收”。
— Java8 语言规范
12.6.1 "Implementing Finalization"
>>>
这真的很重要吗?
4. 实验
这种差异很容易通过实验证明,以下面的 `LocalFinalize` 类为例:
```java
public class LocalFinalize {
...
private static volatile boolean flag;
public static void pass() {
MyHook h1 = new MyHook();
MyHook h2 = new MyHook();
while (flag) {
// 循环
}
h1.log();
}
public static class MyHook {
public MyHook() {
System.out.println("Created " + this);
}
public void log() {
System.out.println("Alive " + this);
}
@Override
protected void finalize() throws Throwable {
System.out.println("Finalized " + this);
}
}
}
```
通常认为 `h2` 的生命周期会持续到 `pass` 函数结束。由于函数中有一个循环等待,在 `flag` 设置为 `true` 之前,对象的生命周期不会终止。
从编译结果中可以发现一些有趣的结果。我们设计了下面这个例子:第一次调用 `pass`,进入函数后循环等待一段时间,然后退出。第二次进入函数,会一直循环。
像下面这样:
```java
public static void arm() {
new Thread(() -> {
try {
Thread.sleep(5000);
flag = false;
} catch (Throwable t) {}
}).start();
}
public static void main(String... args) throws InterruptedException {
System.out.println("Pass 1");
arm();
flag = true;
pass();
System.out.println("Wait for pass 1 finalization");
Thread.sleep(10000);
System.out.println("Pass 2");
flag = true;
pass();
}
```
还有一个后台线程,反复执行垃圾回收,触发 `finalize()`。设置完毕([完整源代码][4]),运行代码:
[4]:https://shipilev.net/jvm/anatomy-quarks/8-local-var-reachability/LocalFinalize.java
```shell
$ java -version
java version "1.8.0_101"
Java(TM) SE Runtime Environment (build 1.8.0_101-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.101-b13, mixed mode)
$ java LocalFinalize
Pass 1
Created LocalFinalize$MyHook@816f27d # 新建 h1
Created LocalFinalize$MyHook@87aac27 # 新建 h2
Alive LocalFinalize$MyHook@816f27d # 调用 h1.log
Wait for pass 1 finalization
Finalized LocalFinalize$MyHook@87aac27 # h1 触发 finalize
Finalized LocalFinalize$MyHook@816f27d # h2 触发 finalize
Pass 2
Created LocalFinalize$MyHook@3e3abc88 # 新建 h1
Created LocalFinalize$MyHook@6ce253f1 # 新建 h2
Finalized LocalFinalize$MyHook@6ce253f1 # h2 触发 finalize (!)
```
意外出现了。因为优化编译器知道最后一次使用 `h2` 发生在分配之后,所以出现第二次调用时 `h2` 触发 finalize 的情况。编译器与垃圾收集器确认对象是否可用时,把 `h2` 认为是不可用对象。因此,`MyHook` 实例被回收,触发 finalize。由于 `h1` 在循环之后被再次使用,因此视为可用,不会触发 finalize。
这是一种很好的特性,使得 GC 无需退出函数执行就能回收本地分配的大量缓存,例如:
```java
void processAndWait() {
byte[] buf = new byte[1024 * 1024];
writeToBuf(buf);
processBuf(buf); // 最后使用!
waitForTheDeathOfUniverse(); // oops
}
```
5. 深入讨论
实际上,可以在各种反汇编结果中查看技术细节。首先,反汇编结果不包含局部变量,`h2` 变量存储的 slot 1 一直保留到方法结尾:
```shell
$ javap -c -v -p LocalFinalize.class
public static void pass();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=0
0: new #17 // class LocalFinalize$MyHook
3: dup
4: invokespecial #18 // Method LocalFinalize$MyHook."<init>":()V
7: astore_0
8: new #17 // class LocalFinalize$MyHook
11: dup
12: invokespecial #18 // Method LocalFinalize$MyHook."<init>":()V
15: astore_1
16: getstatic #10 // Field flag:Z
19: ifeq 25
22: goto 16
25: aload_0
26: invokevirtual #19 // Method LocalFinalize$MyHook.log:()V
29: return
```
加上调试参数(`javac -g`)可以编译生成局部变量表(LVT),看起来局部变量的生命周期“似乎”延续到了方法结束:
```shell
public static void pass();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=0
0: new #17 // class LocalFinalize$MyHook
3: dup
4: invokespecial #18 // Method LocalFinalize$MyHook."<init>":()V
7: astore_0
8: new #17 // class LocalFinalize$MyHook
11: dup
12: invokespecial #18 // Method LocalFinalize$MyHook."<init>":()V
15: astore_1
16: getstatic #10 // Field flag:Z
19: ifeq 25
22: goto 16
25: aload_0
26: invokevirtual #19 // Method LocalFinalize$MyHook.log:()V
29: return
LocalVariableTable:
Start Length Slot Name Signature
8 22 0 h1 LLocalFinalize$MyHook; // 8 + 22 = 30
16 14 1 h2 LLocalFinalize$MyHook; // 16 + 14 = 30
```
这个结果让人疑惑,因为通常认为变量的“作用域”是 LVT 定义的。但事实并非如此,编译器会检查局部变量有没有继续使用并进行优化。在当前的测试中,会发生下面的情况(用伪代码描述):
```java
public static void pass() {
MyHook h1 = new MyHook();
MyHook h2 = new MyHook();
while (flag) {
// 循环
// <这里 gc 安全>
// 这里,编译后的代码能够知道哪些引用存储在寄存器中和堆栈上,
// 这时,"h2" 已经超过了最后使用的地方,map 中没有 "h2" 的信息,
// 因此,垃圾回收会把它视为不可用
}
h1.log();
}
```
加上 `-XX:+PrintAssembly` 选项可以看到下面输出:
```asm
data16 data16 xchg %ax,%ax ; ImmutableOopMap{r10=Oop rbp=Oop}
;*goto {reexecute=1 rethrow=0 return_oop=0}
; - LocalFinalize::pass@22 (line 43)
LOOP:
test %eax,0x15ae2bca(%rip) # 0x00007f30868ff000
; *goto {reexecute=0 rethrow=0 return_oop=0}
; - LocalFinalize::pass@22 (line 43)
; {poll}
movzbl 0x70(%r10),%r8d ;*getstatic flag {reexecute=0 rethrow=0 return_oop=0}
; - LocalFinalize::pass@16 (line 43)
test %r8d,%r8d
jne LOOP ;*ifeq {reexecute=0 rethrow=0 return_oop=0}
; - LocalFinalize::pass@19 (line 43)
```
`ImmutableOopMap{r10=Oop rbp=Oop}` 可以基本上认为 `%r10` 和 `%rbp` 保存了“对象指针”。`%r10` 保存 `this`,通过它读取 `flag`;`%rbp` 保存 `h1` 引用。这里没有 `h2` 引用的信息。
6. 替代方案
延长局部变量的声明周期,可以在接下来的代码中调用局部变量。然而,这种方案很难做到不会带来副作用。比如,“仅仅”调用方法并传入局部变量是不够的,因为方法可能会被内联优化或者进行类似优化。自 Java 9 开始,[java.lang.ref.Reference::reachabilityFence][5] 提供了必要的语法支持。
[5]:http://download.java.net/java/jdk9/docs/api/java/lang/ref/Reference.html#reachabilityFence-java.lang.Object-
如果“只是”希望获得 C++ “代码块结束时释放“这样的特性,可以在 Java 代码块结束时调用 `try-finally`。
7. 观察
Java 局部变量的可用性不由代码块决定,而与最后一次使用有关,并且可能会持续到最后一次使用为止。使用像 finalizer、强引用、弱引用、虚引用这样的方法通知对象不可达,会受到“提前检查”优化带来的影响,即代码块还没有结束变量已不可用。