ITPub博客

首页 > 应用开发 > > JVM 解剖公园:局部变量可用性

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

作者:supergirlxu 时间:2019-10-14 09:40:04 0 删除 编辑

“离开当前作用域,存储在局部变量中的引用会被回收”,这种说法正确吗?在 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."":()V

7: astore_0

8: new #17 // class LocalFinalize$MyHook

11: dup

12: invokespecial #18 // Method LocalFinalize$MyHook."":()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."":()V

7: astore_0

8: new #17 // class LocalFinalize$MyHook

11: dup

12: invokespecial #18 // Method LocalFinalize$MyHook."":()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、强引用、弱引用、虚引用这样的方法通知对象不可达,会受到“提前检查”优化带来的影响,即代码块还没有结束变量已不可用。

来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/644/viewspace-3120479/ ,如需转载,请注明出处,否则将追究法律责任。

请登录后发表评论 登录
全部评论

注册时间:2019-04-12

  • 博文量
    220
  • 访问量
    1690