由 WeakReference 引发的思考

Posted by ahfywff on October 21, 2018

写在前面

在使用 WeakReference 的过程中遇到了一个问题,从而引发了我的一些思考和学习,为了给遇到类似问题的人一个参考也为了记录自己的学习过程,故写成此文。本文代码运行环境为 Window 7、JDK 1.8.0_191。部分内容参考自《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第 2 版)》,如有雷同,就是引用。

什么是弱引用(WeakReference)?

大家都知道 Java 中的数据类型除了基本数据类型之外,其余的都是引用类型,我们经常使用的引用叫做强引用,强引用的例子如下:

StringBuffer buffer  = new StringBuffer();

上面创建了一个 StringBuffer 对象,并将这个对象的强引用存到变量 buffer 中。如果一个对象通过一连串强引用链可到达(Strongly reachable),那么这个对象不会被垃圾回收器回收。弱引用是比强引用弱的一种引用,当一个弱引用所指向的对象没有被任何强引用所引用时,下次垃圾回收时该对象就会被回收。弱引用的使用方法如下:

String str = new String("abc");
// 声明一个弱引用
WeakReference<String> weakReference = new WeakReference<>(str);
// 弱引用转强引用
String strongReference = weakReference.get();

使用弱引用遇到的问题

这里用一段代码来描述遇到的问题,程序一开始创建一个弱引用,然后在循环体里不停的获取这个弱引用所引用的对象,并根据获取到的对象是否为空打印不同的信息。演示代码如下:

public class TestWeakReference {

    public static void main(String[] args) {

        StringBuffer buffer = new StringBuffer("TestWeakReference");
        WeakReference<StringBuffer> weakRef = new WeakReference<>(buffer);
        int i = 0;

        while (true) {
            if (weakRef.get() != null) {
                i++;
                System.out.println("Object is alive for " + i + " loops - " + weakRef.get());
            } else {
                System.out.println("Object has been collected.");
                break;
            }
        }
    }
}

运行上述代码一段时间之后,得到以下结果:

Object is alive for 109734 loops - TestWeakReference
Object is alive for 109735 loops - TestWeakReference
Object is alive for 109736 loops - TestWeakReference
Object is alive for 109737 loops - TestWeakReference
Object is alive for 109738 loops - TestWeakReference
Object is alive for 109739 loops - null
Object has been collected.

由运行结果我们可以得出以下结论:

  1. 程序打印出 “Object has been collected.”,说明 weakRef 指向的对象被回收了。
  2. 倒数第二行打印出 “Object is alive for 109739 loops - null”,表明即使前面已经做了非 null 判断,weakRef.get() 得到的对象仍然可能为 null。因此,在使用弱引用时要先将 get() 方法得到的对象赋值给一个强引用,然后再进行非 null 判断以及后面的处理,否则可能引发 NPE(NullPointerException)

对于第一个结论,很多人就不能理解了,明明有一个强引用 buffer 引用了 weakRef 所指向的对象,为什么 weakRef 所指向的对象仍然被回收了呢?这似乎不太符合我们对强引用的认知。可以肯定的是,我们创建的 StringBuffer 对象一定是被回收了。为了进行验证,我们添加虚拟机运行参数 “-verbose:gc”来观察垃圾收集过程,重新运行上面的程序后,得到下面的运行结果:

Object is alive for 113675 loops - TestWeakReference
Object is alive for 113676 loops - TestWeakReference
Object is alive for 113677 loops - TestWeakReference
Object is alive for 113678 loops - TestWeakReference
[GC (Allocation Failure)  34032K->744K(125952K), 0.0008420 secs]
Object is alive for 113679 loops - null
Object has been collected.

从运行结果可以看出,程序运行过程中进行了一次垃圾回收,垃圾回收弱引用 weakRef 所引用的对象就为 null 了,说明 StringBuffer 对象确实被回收了。可是,buffer 的作用域是整个 main 函数,它怎么就在 main 函数结束之前被回收了呢?buffer 在循环开始之后就没有被使用过了,难道是编译器进行了优化,提前回收了 buffer?带着这些疑问,我翻开了《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第 2 版)》这本书。

局部变量表与 Slot 复用

书中第 8 章主要对虚拟机字节码执行引擎进行了介绍,章节开始部分介绍了运行时栈帧结构。而栈帧中存储的局部变量表似乎能为我们提供答案。

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,每一个 Slot 都能够存放一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据。

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非 static 的方法),那局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字”this“来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。

局部变量表 Slot 复用

为了尽可能节省栈帧空间,局部变量表的 Slot 是可以复用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那么这个变量对应的 Slot 就可以交给其他变量使用。不过,这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,在某些情况下,Slot 的复用会直接影响到 JVM 的垃圾收集行为,下面为演示代码。

public static void main(String[] args) {
    byte[] placeholder = new byte[1024 * 1024 * 64];
    System.gc();
}

上面的代码很简单,即向内存填充了 64MB 的数据,然后通知虚拟机进行垃圾收集。在虚拟机运行参数中加上“-verbose:gc”,看看垃圾回收过程,发现 System.gc() 运行后,并没有回收这 64MB 内存。下面是运行的结果:

[GC (System.gc())  68878K->66352K(125952K), 0.0013650 secs]
[Full GC (System.gc())  66352K->66160K(125952K), 0.0040263 secs]

没有回收 placeholder 所占的内存能说得过去,因为在执行 System.gc() 时,变量 placeholder 还处于作用域之内,虚拟机自然不敢回收 placeholder 的内存。那我们把代码修改一下,变成下面的样子。

public static void main(String[] args) {
    {
        byte[] placeholder = new byte[1024 * 1024 * 64];
    }
    System.gc();
}

加入了花括号之后,placeholder 的作用域被限制在花括号之内,从代码逻辑上讲,在执行 System.gc() 的时候,placeholder 已经不可能再被访问了,但执行一下这段程序,会发现运行结果如下,这 64MB 的内存还是没有被回收。

[GC (System.gc())  68878K->66320K(125952K), 0.0008260 secs]
[Full GC (System.gc())  66320K->66160K(125952K), 0.0037335 secs]

对这段代码进行第二次修改,在调用 System.gc() 之前加入一行“int i = 0;”,代码变成下面这个样子。

public static void main(String[] args) {
    {
        byte[] placeholder = new byte[1024 * 1024 * 64];
    }
    int i = 0;
    System.gc();
}

这个修改看起来很莫名其妙,但运行一下程序,却发现这次内存真的被正确回收了。

[GC (System.gc())  68878K->66304K(125952K), 0.0008187 secs]
[Full GC (System.gc())  66304K->624K(125952K), 0.0040267 secs]

上面三个示例中,placeholder 能否被回收取决于:局部变量表中的 Slot 是否还存在关于 placeholder 数组对象的引用。第一次修改中,代码虽然已经离开了 placeholder 的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder 原本所占用的 Slot 还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。

回顾问题

我们再回过头看一下那段代码。

public class TestWeakReference {

    public static void main(String[] args) {

        StringBuffer buffer = new StringBuffer("TestWeakReference");
        WeakReference<StringBuffer> weakRef = new WeakReference<>(buffer);
        int i = 0;

        while (true) {
            if (weakRef.get() != null) {
                i++;
                System.out.println("Object is alive for " + i + " loops - " + weakRef.get());
            } else {
                System.out.println("Object has been collected.");
                break;
            }
        }
    }
}

这段代码中,声明 i 时 buffer 仍在其作用域之内,换句话说 buffer 和 i 的作用域是重叠的,所以“int i = 0;”这段代码不会导致 buffer 所占用的 Slot 被复用。看来,我们还没有找到 buffer 被回收的原因。

为了解决心中的困惑,我又开始在网络上寻找答案,最终在知乎上一个问题的回答中找到了答案,这个回答对我所遇到的这个现象进行了详细的解答。答案比较长,有兴趣的的同学可以点击传送门:这段 Java 代码中的局部变量能够被提前回收吗?编译器或 VM 能够实现如下的人工优化吗?- RednaxelaFX 的回答

主流 JVM 在做完从 Java 字节码到机器码的编译后,都能做适当的优化来让上述代码中的 buffer 所指向的对象可以被回收,做到这种效果的编译优化技术叫做“活跃分析”(Liveness analysis)。一个变量只有被使用的地方才是“活跃”的,如果没有(继续)使用,那么这个变量就“死”掉了,垃圾收集器会将这些“死”掉的对象进行回收。至于怎么回收的,可以参考上面的传送门。

对于上述代码,buffer 在循环之后就没有再使用了,经过一段时间之后,由于“活跃分析”技术的优化,导致 buffer 所指向的对象被回收,weakRef.get() 得到的值便为 null 了。

到这里我们已经知道:变量的存活时间可能跟其看上去的生命周期是不一致的。这在大部分时候对我们都没什么影响,但是有时会带来一些问题,比如本文例子中提到的这个问题。

总结

  • 在使用弱引用时要先将 get() 方法得到的对象赋值给一个强引用,然后再进行非 null 判断以及后面的处理,否则可能引发 NPE(NullPointerException)。
  • 变量的存活时间可能跟其看上去的生命周期是不一致的,使用弱引用时要特别留意这一点。