Java虚拟机究竟是如何处理SoftReference的

但凡是写过几行java代码的,都知道java中的引用分为4种:强引用软引用弱引用虚引用

其中广大开发者最热衷的莫过于 软引用 了。因为它能保证在内存足够时,我们创建的对象完好的存活在内存中。同时当内存不足时,则将软引用指向的对象交由GC回收。

抛个砖

但是Java工程师不能认为SoftReference就是万无一失的保险锁,并且肆无忌惮的使用。

被 软引用 对象关联的对象会自动被垃圾回收器回收,但是软引用对象本身也是一个对象,并且是一个 强引用

上面这句话有点绕,不是很容易理解。用一张图来表示如下:
Java虚拟机究竟是如何处理SoftReference的
用一段更具体的代码表示如下:
Java虚拟机究竟是如何处理SoftReference的

1 软引用也是强引用

虽然MyObject被软引用SoftReference引用,但是软引用对象自身被强引用集合Set所引用,这机会导致SoftReference对象本身不会被GC回收掉。如果我们不断的向Set中添加对象,终将导致OOM。如下所示:
Java虚拟机究竟是如何处理SoftReference的
虽说也是OOM,但是造成OOM的原因却有点特殊:

java.lang.OutOfMemoryError : GC overhead limit exceeded

造成这种OOM的原因在于:虚拟机一直在不断回收软引用,回收进行的速度过快,占用的cpu过大(超过98%),并且每次回收掉的内存过小(小于2%),导致最终抛出了这个错误。

2 内存足够,软引用也能被GC回收

除了OOM,软引用还有另外一个问题或许会刷新你对它的认知。
比如如下代码:
Java虚拟机究竟是如何处理SoftReference的
运行结果如下:
Java虚拟机究竟是如何处理SoftReference的
也就是说GC之后,内存足够所以 软引用 所关联的SoftObject并没有被GC回收掉。这是正常运行下的情况,很容易理解。

但是如果对代码作如下改动:
Java虚拟机究竟是如何处理SoftReference的
主要是添加了一个2s的睡眠,并再次调用一次GC操作。这时再次运行效果如下:
Java虚拟机究竟是如何处理SoftReference的
加了一个2s的休眠,再次执行一次GC,软引用 竟然被回收了!!!!

引个玉

为了弄清楚为什么会发生上述情况,那就必须深入分析JVM内部GC时是如何处理 软引用 的。接下来看下GC回收这部分源码是如何对SoftReference做处理的。

先看下SoftReference的源码:
Java虚拟机究竟是如何处理SoftReference的
在SoftReference中有两个比较重要的变量:

  • clock:在JVM发生GC时,也会更新clock的值,所以clock会记录上次GC发生的时间点
  • timestamp:记录对象被访问(get方法被调用)时最近一次GC的时间

我们经常说 软引用 是在内存不足时被GC回收,但是如何定义“内存不足”就是通过clock和timestamp这两个变量来决定的。

接下来看下GC回收时的源码,大部分实现代码是在c++层 ReferenceProcessor.cpp 中。

当GC开始时,将 _soft_ref_timestamp_clock 设置为当前时间,它对应SoftReference中的clock变量。如下:
Java虚拟机究竟是如何处理SoftReference的

当垃圾回收器遍历完所有的GC Roots之后,在执行对象清理之前,会调用 ReferenceProcessor::process_discovered_references 函数。 在这个函数中对所有的引用进行处理,用来区分哪些引用是可以清理的,哪些是不能清理的。

Java虚拟机究竟是如何处理SoftReference的

可以看出,JVM中的各种引用都是在这个方法中进行处理的。包括 软引用弱引用虚引用等。重点来看对 软引用 的处理:

重点代码就是在 process_phase1 函数中,这个函数主要作用就是判断当前系统中的“内存是否足够”,如果内存足够,则将 软引用 从refs_list中移除,如下:
Java虚拟机究竟是如何处理SoftReference的
如上图红框所示,GC最终会调用一个 policy 的 should_clear_reference 函数来决定这个 软引用 是否需要清除。

policy 就是虚拟机在执行GC时,对 软引用 引用的回收策略。一共有4种回收策略:

软引用回收策略 具体策略
NeverClearPolicy 从不清理
AlwaysClearPolicy 总是清理
LRUCurrentHeapPolicy 最近未使用即清理(根据当前堆空间剩余来评估最近时间)
LRUMaxHeapPolicy 最近未使用即清理(根据最大可使用堆空间剩余来评估最近时间)

对于NeverClearPolicy和AlwaysClearPolicy我们基本不会使用到,不需要关注。

LRUCurrentHeapPolicy和LRUMaxHeapPolicy中 should_clear_reference 函数的实现一致,如下所示:
Java虚拟机究竟是如何处理SoftReference的
可以看出,是否需要清理 软引用 跟几个条件有关:

  • interval:当前时间与上次GC回收的执行时间
  • _max_interval: 最大回收间隔

LRUCurrentHeapPolicy 和 LRUMaxHeapPolicy唯一的不同就是对 _max_interval 的初始化值不同:
Java虚拟机究竟是如何处理SoftReference的
从图中可以看出:

  • LRUCurrentHeapPolicy使用 get_head_at_last_gc() 获取的是当前可用堆的大小
  • LRUMaxHeapPolicy使用 get_heap_used_at_last_gc() 获取的是最大堆大小

总结一下就是:

GC对于存活时间大于 _max_interval 的软引用会进行回收。而这个 _max_interval 的值是基于对内存大小和上次GC回收时间一起计算出来的。

最后用一个公式来表示一个软引用是否可以被回收如下:

clock - timestamp <= heap_size * SoftRefLRUPolicPerMB

划重点:我们平时说软引用会在内存不足时被GC回收。这里说的内存不足不仅仅是指空间大小,还有时间的限制。这就解释了为什么在文章开始的例子中睡眠2s之后,再执行GC 软引用 就被回收了!

小验证

在上面的源码中,有一个参数–SoftRefLRUPolicPerMB 。我们在执行java命令时可以通过 -XX:SoftRefLRUPolicyMSPerMB 这个参数来设置它的值。

既然如此,那不妨将参数设置大一点,如下所示设置2s,再次执行之前的代码,则软引用不会被回收,也间接验证了上面源码的执行流程。
Java虚拟机究竟是如何处理SoftReference的
脑袋是不是嗡嗡的了?鉴于这种情况,Android官方在对SoftReference的介绍中,也已经不建议使用它来实现缓存功能:
Java虚拟机究竟是如何处理SoftReference的
原因就是因为 SoftReference 无法提供足够的信息可以让 runtime 很轻松地决定 clear 它还是 keep 它。