SoftReference 到底在什麼時候被回收 ? 如何量化記憶體不足 ?

bin的技术小屋發表於2024-06-15

本文基於 OpenJDK17 進行討論,垃圾回收器為 ZGC。

提示: 為了方便大家索引,特將在上篇文章 《以 ZGC 為例,談一談 JVM 是如何實現 Reference 語義的》 中討論的眾多主題獨立出來。


大家在網上或者在其他講解 JVM 的書籍中多多少少會看到這樣一段關於 SoftReference 的描述 —— “當 SoftReference 所引用的 referent 物件在整個堆中沒有其他強引用的時候,發生 GC 的時候,如果此時記憶體充足,那麼這個 referent 物件就和其他強引用一樣,不會被 GC 掉,如果此時記憶體不足,系統即將 OOM 之前,那麼這個 referent 物件就會被當做垃圾回收掉”。

image

當然了,如果僅從概念上理解的話,這樣描述就夠了,但是如果我們從 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 中。

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。

void ZDriver::gc(const ZDriverRequest& request) {
  ZDriverGCScope scope(request);
  ..... 省略 ......
}
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 中。

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 。

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 函式的返回值

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 的存活時間就越短。

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 的實現:

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 欄位。

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 欄位更新為當前時間戳,單位為毫秒。

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 的值一直是相等的。

image

如果一個 SoftReference 已經很久沒有被訪問了,timestamp 就會遠遠落後於 clock,因為在沒有被訪問的這段時間內可能已經發生好幾次 GC 了。

image

在我們瞭解了這些背景之後,再來看一下 JVM 對於 SoftReference 的回收過程,在本文 5.1 小節中介紹的 ZGC Concurrent Mark 階段中,當 GC 遍歷到一個 Reference 型別的物件的時候,會在 should_discover 方法中判斷一下這個 Reference 物件所引用的 referent 是否被標記過。如果 referent 沒有被標記為 alive , 那麼接下來就會將這個 Reference 物件放入 _discovered_list 中,等待後續被 ReferenHandler 處理,referent 也會在本輪 GC 中被回收掉。

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 物件是否滿足存活的條件。

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 執行緒處理。

// 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 回收掉。

相關文章