SoftReference 到底在什么时候被回收 ? 如何量化内存不足 ?

本文基于 OpenJDK17 进行讨论,垃圾回收器为 ZGC。

提示: 为了方便大家索引,特将在上篇文章 《以 ZGC 为例,谈一谈 JVM 是如何实现 Reference 语义的》 中讨论的众多主题独立出来。


大家在网上或者在其他讲解 JVM 的书籍中多多少少会看到这样一段关于 SoftReference 的描述 ------ "当 SoftReference 所引用的 referent 对象在整个堆中没有其他强引用的时候,发生 GC 的时候,如果此时内存充足,那么这个 referent 对象就和其他强引用一样,不会被 GC 掉,如果此时内存不足,系统即将 OOM 之前,那么这个 referent 对象就会被当做垃圾回收掉"。

当然了,如果仅从概念上理解的话,这样描述就够了,但是如果我们从 JVM 的实现角度上来说,那这样的描述至少是不准确的,为什么呢 ? 笔者先提两个问题出来,大家可以先思考下:

  1. 内存充足的情况下,SoftReference 所引用的 referent 对象就一定不会被回收吗 ?

  2. 什么是内存不足 ?这个概念如何量化,SoftReference 所引用的 referent 对象到底什么时候被回收 ?

下面笔者继续以 ZGC 为例,带大家深入到 JVM 内部去探寻下这两个问题的精确答案~~

1. JVM 无条件回收 SoftReference 的场景

经过前面第五小节的介绍,我们知道 ZGC 在 Concurrent Mark 以及 Concurrent Process Non-Strong References 阶段中处理 Reference 对象的关键逻辑都封装在 ZReferenceProcessor 中。

在 ZReferenceProcessor 中有一个关键的属性 ------ _soft_reference_policy,在 ZGC 的过程中,处理 SoftReference 的策略就封装在这里,本小节开头提出的那两个问题的答案就隐藏在 _soft_reference_policy 中。

c 复制代码
class ZReferenceProcessor : public ReferenceDiscoverer {
  // 关于 SoftReference 的处理策略
  ReferencePolicy*     _soft_reference_policy;
}

那下面的问题就是如果我们能够知道 _soft_reference_policy 的初始化逻辑,那是不是关于 SoftReference 的一切疑惑就迎刃而解了 ?我们来一起看下 _soft_reference_policy 的初始化过程。

在 ZGC 开始的时候,首先会创建一个 ZDriverGCScope 对象,这里主要进行一些 GC 的准备工作,比如更新 GC 的相关统计信息,设置并行 GC 线程个数,以及本小节的重点,初始化 SoftReference 的处理策略 ------ _soft_reference_policy。

c 复制代码
void ZDriver::gc(const ZDriverRequest& request) {
  ZDriverGCScope scope(request);
  ..... 省略 ......
}
c 复制代码
class ZDriverGCScope : public StackObj {
private:
  GCCause::Cause             _gc_cause;
public:
  ZDriverGCScope(const ZDriverRequest& request) :
      _gc_cause(request.cause()),
 {
    // Set up soft reference policy
    const bool clear = should_clear_soft_references(request);
    ZHeap::heap()->set_soft_reference_policy(clear);
  }

在 JVM 开始初始化 _soft_reference_policy 之前,会调用一个重要的方法 ------ should_clear_soft_references,本小节的答案就在这里,该方法就是用来判断,ZGC 是否需要无条件清理 SoftReference 所引用的 referent 对象。

  • 返回 true 表示,在 GC 的过程中只要遇到 SoftReference 对象,那么它引用的 referent 对象就会被当做垃圾清理,SoftReference 对象也会被 JVM 加入到 _reference_pending_list 中等待 ReferenceHandler 线程去处理。这里就和 WeakReference 的语义一样了。

  • 返回 false 表示,内存充足的时候,JVM 就会把 SoftReference 当做普通的强引用一样处理,它所引用的 referent 对象不会被回收,但内存不足的时候,被 SoftReference 所引用的 referent 对象就会被回收,SoftReference 也会被加入到 _reference_pending_list 中。

c 复制代码
static bool should_clear_soft_references(const ZDriverRequest& request) {
  // Clear soft references if implied by the GC cause
  if (request.cause() == GCCause::_wb_full_gc ||
      request.cause() == GCCause::_metadata_GC_clear_soft_refs ||
      request.cause() == GCCause::_z_allocation_stall) {
    // 无条件清理 SoftReference
    return true;
  }

  // Don't clear
  return false;
}

这里我们看到,在 ZGC 的过程中,只要满足以下三种情况中的任意一种,那么在 GC 过程中就会无条件地清理 SoftReference 。

  1. 引起 GC 的原因是 ------ _wb_full_gc ,也就是由 WhiteBox 相关 API 触发的 Full GC,就会无条件清理 SoftReference。

  2. 引起 GC 的原因是 ------ _metadata_GC_clear_soft_refs,也就是在元数据分配失败的时候触发的 Full GC,元空间内存不足,情况就很严重了,所以要无条件清理 SoftReference。

  3. 引起 GC 的原因是 ------ _z_allocation_stall,在 ZGC 采用阻塞模式分配 Zpage 页面的时候,如果内存不足无法分配,那么就会触发一次 GC,这时 GC 的触发原因就是 _z_allocation_stall,这种情况下就会无条件清理 SoftReference。

ZGC 非阻塞模式分配 Zpage 的时候如果内存不足、就直接抛出 OutOfMemoryError,不会启动 GC 。

c 复制代码
ZPage* ZPageAllocator::alloc_page(uint8_t type, size_t size, ZAllocationFlags flags) {
  EventZPageAllocation event;

retry:
  ZPageAllocation allocation(type, size, flags);
  // 判断是否进行阻塞分配 ZPage
  if (!alloc_page_or_stall(&allocation)) {
    // 如果非阻塞分配  ZPage 失败,直接 Out of memory
    return NULL;
  }
}

在我们了解了这个背景之后,在回头来看下 _soft_reference_policy 的初始化过程 :

参数 clear 就是 should_clear_soft_references 函数的返回值

c 复制代码
void ZReferenceProcessor::set_soft_reference_policy(bool clear) {
  static AlwaysClearPolicy always_clear_policy;
  static LRUMaxHeapPolicy lru_max_heap_policy;

  if (clear) {
    log_info(gc, ref)("Clearing All SoftReferences");
    _soft_reference_policy = &always_clear_policy;
  } else {
    _soft_reference_policy = &lru_max_heap_policy;
  }

  _soft_reference_policy->setup();
}

ZGC 采用了两种策略来处理 SoftReference :

  1. always_clear_policy : 当 clear 为 true 的时候,ZGC 就会采用这种策略,在 GC 的过程中只要遇到 SoftReference,就会无条件回收其引用的 referent 对象,SoftReference 对象也会被 JVM 加入到 _reference_pending_list 中等待 ReferenceHandler 线程去处理。

  2. lru_max_heap_policy :当 clear 为 false 的时候,ZGC 就会采用这种策略,这种情况下 SoftReference 的存活时间取决于 JVM 堆中剩余可用内存的总大小,也是我们下一小节中讨论的重点。

下面我们就来看一下 lru_max_heap_policy 的初始化过程,看看 JVM 是如何量化内存不足的 ~~

2. JVM 如何量化内存不足

LRUMaxHeapPolicy 的 setup() 方法主要用来确定被 SoftReference 所引用的 referent 对象最大的存活时间,这个存活时间是和堆的剩余空间大小有关系的,也就是堆的剩余空间越大 SoftReference 的存活时间就越长,堆的剩余空间越小 SoftReference 的存活时间就越短。

c 复制代码
void LRUMaxHeapPolicy::setup() {
  size_t max_heap = MaxHeapSize;
  // 获取最近一次 gc 之后,JVM 堆的最大剩余空间
  max_heap -= Universe::heap()->used_at_last_gc();
  // 转换为 MB
  max_heap /= M;
  //  -XX:SoftRefLRUPolicyMSPerMB 默认为 1000 ,单位毫秒
  // 表示每 MB 的剩余内存空间中允许 SoftReference 存活的最大时间
  _max_interval = max_heap * SoftRefLRUPolicyMSPerMB;
  assert(_max_interval >= 0,"Sanity check");
}

JVM 首先会获取我们通过 -Xmx 参数指定的最大堆 ------ MaxHeapSize,然后在通过 Universe::heap()->used_at_last_gc() 获取上一次 GC 之后 JVM 堆占用的空间,两者相减,就得到了当前 JVM 堆的最大剩余内存空间,并将单位转换为 MB

现在 JVM 堆的剩余空间我们计算出来了,那如何根据这个 max_heap 计算 SoftReference 的最大存活时间呢 ?

这里就用到了一个 JVM 参数 ------ SoftRefLRUPolicyMSPerMB,我们可以通过 -XX:SoftRefLRUPolicyMSPerMB 来指定,默认为 1000 , 单位为毫秒。

它表达的意思是每 MB 的堆剩余内存空间允许 SoftReference 存活的最大时长,比如当前堆中只剩余 1MB 的内存空间,那么 SoftReference 的最大存活时间就是 1000 ms,如果剩余内存空间为 2MB,那么 SoftReference 的最大存活时间就是 2000 ms 。

现在我们剩余 max_heap 的空间,那么在本轮 GC 中,SoftReference 的最大存活时间就是 ------ _max_interval = max_heap * SoftRefLRUPolicyMSPerMB

从这里我们可以看出 SoftReference 的最大存活时间 _max_interval,取决于两个因素:

  1. 当前 JVM 堆的最大剩余空间。

  2. 我们指定的 -XX:SoftRefLRUPolicyMSPerMB 参数值,这个值越大 SoftReference 存活的时间就越久,这个值越小,SoftReference 存活的时间就越短。

在我们得到了这个 _max_interval 之后,那么 JVM 是如何量化内存不足呢 ?被 SoftReference 引用的这个 referent 对象到底什么被回收 ?让我们再次回到 JDK 中,来看一下 SoftReference 的实现:

java 复制代码
public class SoftReference<T> extends Reference<T> {
    // 由 JVM 来设置,每次 GC 发生的时候,JVM 都会记录一个时间戳到这个 clock 字段中
    private static long clock;
    // 表示应用线程最近一次访问这个 SoftReference 的时间戳(当前的 clock 值)
    // 在 SoftReference 的 get 方法中设置
    private long timestamp;

    public SoftReference(T referent) {
        super(referent);
        this.timestamp = clock;
    }

    public T get() {
        T o = super.get();
        if (o != null && this.timestamp != clock)
            // 将最近一次的 gc 发生时间设置到 timestamp 中
            // 用这个表示当前 SoftReference 最近被访问的时间戳
            // 注意这里的时间戳语义是 最近一次的 gc 时间
            this.timestamp = clock;
        return o;
    }
}

SoftReference 中有两个非常重要的字段,一个是 clock ,另一个是 timestamp。clock 字段是由 JVM 来设置的,在每一次发生 GC 的时候,JVM 都会去更新这个时间戳。具体一点的话,就是在 ZGC 的 Concurrent Process Non-Strong References 阶段处理完所有 Reference 对象之后,JVM 就会来更新这个 clock 字段。

c 复制代码
void ZReferenceProcessor::process_references() {
  ZStatTimer timer(ZSubPhaseConcurrentReferencesProcess);

  // Process discovered lists
  ZReferenceProcessorTask task(this);
  // gc _workers 一起运行 ZReferenceProcessorTask
  _workers->run(&task);

  // Update SoftReference clock
  soft_reference_update_clock();
}

soft_reference_update_clock() 中 ,JVM 会将 SoftReference 类中的 clock 字段更新为当前时间戳,单位为毫秒。

c 复制代码
static void soft_reference_update_clock() {
  const jlong now = os::javaTimeNanos() / NANOSECS_PER_MILLISEC;
  java_lang_ref_SoftReference::set_clock(now);
}

而 timestamp 字段用来表示这个 SoftReference 对象有多久没有被访问到了,应用线程越久没有访问 SoftReference,JVM 就越倾向于回收它的 referent 对象。这也是 LRUMaxHeapPolicy 策略中 LRU 的语义体现。

应用线程在每次调用 SoftReference 的 get 方法时候,都会将最近一次的 GC 时间戳 clock 更新到 timestamp 中,这样一来,如果一个 SoftReference 被频繁的访问,那么 clock 和 timestamp 的值一直是相等的。

如果一个 SoftReference 已经很久没有被访问了,timestamp 就会远远落后于 clock,因为在没有被访问的这段时间内可能已经发生好几次 GC 了。

在我们了解了这些背景之后,再来看一下 JVM 对于 SoftReference 的回收过程,在本文 5.1 小节中介绍的 ZGC Concurrent Mark 阶段中,当 GC 遍历到一个 Reference 类型的对象的时候,会在 should_discover 方法中判断一下这个 Reference 对象所引用的 referent 是否被标记过。如果 referent 没有被标记为 alive , 那么接下来就会将这个 Reference 对象放入 _discovered_list 中,等待后续被 ReferenHandler 处理,referent 也会在本轮 GC 中被回收掉。

c 复制代码
bool ZReferenceProcessor::should_discover(oop reference, ReferenceType type) const {

  // 此时 Reference 的状态就是 inactive,那么这里将不会重复将 Reference 添加到 _discovered_list 重复处理
  if (is_inactive(reference, referent, type)) {
    return false;
  }
  // referent 还被强引用关联,那么 return false 也就是说不能被加入到 discover list 中
  if (is_strongly_live(referent)) {
    return false;
  }
  // referent 现在只被软引用关联,那么就需要通过 LRUMaxHeapPolicy
  // 来判断这个 SoftReference 所引用的 referent 是否应该存活
  if (is_softly_live(reference, type)) {
    return false;
  }

  return true;
}

如果当前遍历到的 Reference 对象是 SoftReference 类型的,那么就需要在 is_softly_live 方法中根据前面介绍的 LRUMaxHeapPolicy 来判断这个 SoftReference 引用的 referent 对象是否满足存活的条件。

c 复制代码
bool ZReferenceProcessor::is_softly_live(oop reference, ReferenceType type) const {
  if (type != REF_SOFT) {
    // Not a SoftReference
    return false;
  }

  // Ask SoftReference policy
  // 获取 SoftReference 中的 clock 字段,这里存放的是上一次 gc 的时间戳
  const jlong clock = java_lang_ref_SoftReference::clock();
  // 判断是否应该清除这个 SoftReference
  return !_soft_reference_policy->should_clear_reference(reference, clock);
}

通过 java_lang_ref_SoftReference::clock() 获取到的就是前面介绍的 SoftReference.clock 字段 ------ timestamp_clock。

通过 java_lang_ref_SoftReference::timestamp(p) 获取到的就是前面介绍的 SoftReference.timestamp 字段。

如果 SoftReference.clock 与 SoftReference.timestamp 的差值 ------ interval,小于等于前面介绍的 SoftReference 最大存活时间 ------ _max_interval,那么这个 SoftReference 所引用的 referent 对象在本轮 GC 中就不会被回收,SoftReference 对象也不会被放到 _reference_pending_list 中被 ReferenceHandler 线程处理。

c 复制代码
// The oop passed in is the SoftReference object, and not
// the object the SoftReference points to.
bool LRUMaxHeapPolicy::should_clear_reference(oop p,
                                             jlong timestamp_clock) {
  // 相当于 SoftReference.clock - SoftReference.timestamp
  jlong interval = timestamp_clock - java_lang_ref_SoftReference::timestamp(p);


  // The interval will be zero if the ref was accessed since the last scavenge/gc.
  // 如果 clock 与 timestamp 的差值小于等于 _max_interval (SoftReference 的最大存活时间)
  if(interval <= _max_interval) {
    // SoftReference 所引用的 referent 对象在本轮 GC 中就不会被回收
    return false;
  }
  // interval 大于 _max_interval,这个 SoftReference 所引用的 referent 对象就会被回收
  // SoftReference 也会被放到 _reference_pending_list 中等待 ReferenceHandler 线程去处理
  return true;
}

如果 interval 大于 _max_interval,那么这个 SoftReference 所引用的 referent 对象在本轮 GC 中就会被回收,SoftReference 对象也会被 JVM 放到 _reference_pending_list 中等待 ReferenceHandler 线程处理。

总结

从以上过程中我们可以看出,SoftReference 被 ZGC 回收的精确时机是,当一个 SoftReference 对象已经很久很久没有被应用线程访问到了,那么发生 GC 的时候这个 SoftReference 就会被回收掉。

具体多久呢 ? 就是 _max_interval 指定的 SoftReference 最大存活时间,这个时间由当前 JVM 堆的最大剩余空间和 -XX:SoftRefLRUPolicyMSPerMB 共同决定。

比如,发生 GC 的时候,当前堆的最大剩余空间为 1MB,SoftRefLRUPolicyMSPerMB 指定的是 1000 ms ,那么当一个 SoftReference 对象超过 1000 ms 没有被应用线程访问的时候,就会被 ZGC 回收掉。