JVM 解剖公园:局部变量可用性

“离开当前作用域,存储在局部变量中的引用会被回收”,这种说法正确吗?在 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++ 经验的程序员大脑中。在语言规范中是这样定义的:


>>>

  1. 声明为 `auto`、`register`、非 `static` 或 `extern` 局部变量具有自动存续期,对象会持续存在直到退出创建的代码块。

  2. 注意:这些对象会按照6.7中的描述初始化并销毁。

  3. 如果 `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、强引用、弱引用、虚引用这样的方法通知对象不可达,会受到“提前检查”优化带来的影响,即代码块还没有结束变量已不可用。


上一篇:批量修改linux用户密码脚本


下一篇:nginx代理转发规则