【譯】自制(OpenJDK)垃圾收集器

原文地址:Do It Yourself (OpenJDK) Garbage Collector

序言

構建語言運行時系統的任一部分都是一個有趣的練習。至少構建第一個 hacky 版本是這樣的!但是構建一個可靠、高性能、可觀察、可調試、故障可預測的運行時子系統是非常非常難的。

構建一個簡單的垃圾收集器看起來很容易,我們在這裏就是要實現一個簡單的垃圾收集器。Roman Kennke 在 FOSDEM 2019 上使用了這個補丁的早期版本講解演示了“20分鐘構建 GC”。雖然實現的代碼中包含豐富的註釋,但是相關的概括性講解還是有必要的,所以我寫了這篇文章。

瞭解垃圾收集器基本的工作原理將會極大改善閱讀體驗。本文有很多關於基本思想與 Hotspot 實現的討論,但是本文並不是關於 GC 構建的速成課程。請撿起《GC 手冊》閱讀關於基本 GC 的第一章,或者快速閱讀維基百科的文章

1. 構件

在許多已經實現的 GCs 基礎上實現一個新的 GC 相對容易很多,因爲有許多存在的經過驗證和測試的構件可以重用。

1.1. Epsilon GC

JEP 318: "Epsilon: A No-Op Garbage Collector (Experimental)" 是在 OpenJDK 11 中引入的。目的是提供內存不需要回收或者禁止回收時的最小實現。閱讀 JEP 可以獲取更多有用的信息。

從實現角度來看,“垃圾收集器”是一個不恰當的術語,恰當的術語是“自動內存管理”,既負責分配內存又負責回收內存。由於 Epsilon GC 只實現了“分配”內存,並沒有實現“回收”內存,所以這是一個很好的白板,可以在其基礎上實現真正的 GC 算法。

1.1.1. 分配

Epsilon GC 主要實現了分配邏輯。響應分配任意大小內存和分配給定大小線程本地分配緩衝區(TLAB)的外部請求。該實現不會嘗試將 TLABs 擴張很大,因爲沒有回收的邏輯,浪費的字節永遠不會被回收。

1.1.2. 屏障

某些垃圾收集器通過強制運行時系統和應用程序訪問堆內存時執行GC 屏障,以維護與應用程序交互的 GC 不變式。所有並行收集器都需要屏障,某些分代的萬物靜止收集器也需要屏障

Epsilon 不需要屏障,但是運行時系統和編譯器仍然需要知道屏障是空操作。到處關聯這些信息將會非常令人厭煩。幸運的是 OpenJDK 11 中的 JEP-304: "Garbage Collection Interface" 使得插入屏障非常整潔。尤其是 Epsilon 的屏障集合是空的,所以將所有瑣碎的工作 —— 加載、存儲、CAS 和數組拷貝 —— 委託給基本的屏障集合即可。如果我們構建的 GC 仍然不需要屏障,那麼簡單地重用 Epsilon 的實現即可。

1.1.3. 監控鉤子

實現 GC 最後一塊麻煩的部分是關聯監控設施到 JVM:MX beans 需要可以工作,診斷命名也需要可以工作,等。Epsilon 已經爲我們處理了這個問題

1.2. 運行時系統與 GC

1.2.1. 根

通常來說垃圾收集器需要知道 Java 運行時系統的哪些部分持有 Java 堆的引用。這些位置,GC 根,包含線程棧和局部變量(包含 JIT編譯後的代碼中的局部變量!),本地類和類加載器,JNI 句柄,等。知道根集合是很困難的。但是在 Hotspot 中,這些位置由每個 VM 子系統跟蹤,我們可以仔細研究現有的 GC 實現如何處理它們。你將會在下面的實現章節看到這些內容。

1.2.2. 對象遍歷

垃圾收集器也需要遍歷 Java 對象向外的引用。因爲這是一個普遍存在的操作,所以共享的運行時系統已經爲 GCs 提供了對象遍歷器,不需要我們自己去實現。例如你在下面的實現章節將會看到 obj→oop_iterate 調用。

1.2.3. 移動信息(Forwarding Data)

移動的垃圾收集器需要記錄被移動對象的新位置。有許多位置可以用於記錄移動信息

  1. 重用對象中的“標記字段”(Serial、Parallel 等)。當萬物靜止時,所有對象字段的訪問都是可控的,Java 線程看不到存儲在對象字段中的臨時數據。我們可以重用這些字段存儲移動信息。
  2. 維護單獨的本地移動表(ZGC、C4 等)。這將會完全隔離 GC 與運行時系統和應用程序其它部分,因爲只有 GC 知道移動表的存在。這就是並行 GC 通常採用這種方式的原因:避免互相干擾。
  3. 在對象中添加額外的字段(Shenandoah等)。這組合了上述兩種方法,既讓運行時系統和應用程序使用存在的頭部字段工作,又保存了移動信息。

1.2.4. 標記信息(Marking Data)

Garbage collectors need to record the reachability (marking) data somewhere. Again, there are several ways where to store it:

垃圾收集器需要記錄可達性(標記)信息。這些信息也有不同的方式存儲:

  1. 重用對象的“標記字段(mark word)”(Serial、Parallel等)。在萬物靜止模式,我們可以使用標記字段編碼“標記”屬性。如果我們需要遍歷所有存活的對象,我們可以利用 堆可解析性一個對象一個對象遍歷堆內存。
  2. 維護單獨的標記信息數據結構(G1, Shenandoah等)。通常來說這是一個單獨的位圖,將 Java 堆內存的 N 字節映射到標記位圖的 1 比特。通常 Java 對象是 8 字節對齊的,所以標記位圖將 64 比特映射到 1 比特,佔用堆大小 1/64 的本地內存。與掃描整個堆尋找存活對象相比,這點兒成本將換來可觀的收益,特別是在存活對象比較稀疏的情況下:遍歷位圖通常來說比遍歷可解析的堆內存快很多。
  3. 將標記信息編碼在引用本身(ZGC、C4 等)。這需要協調應用程序從引用中剔除標記信息,或者通過其它技巧維護正確性。換句話說,這需要 GC 屏障或更多 GC 工作。

2. 大計劃

毫無疑問基於 Epsilon 可以實現的最簡單的 GC 是 LISP2 那樣的標記-整理(Mark-Compact)算法。你可以從相關的維基百科條目《GC 手冊》 的 3.2 章瞭解該 GC 的基本思想。雖然你可以通過下述實現章節瞭解算法的執行,但是建議你閱讀一下上述參考材料。

這裏討論的算法是滑動 GC:通過將對象滑動到堆的起始位置來移動對象。它具有下述優點和缺點:

  • 可以保持分配順序。這對於控制內存佈局非常有用,如果你確實很介意的話(控制狂,歡呼吧!),但是相應的缺點是失去了自動訪問局部性(booo!)。
  • 相對於對象數量的時間複雜度是 O(N)。然而這個線性複雜度是有成本的,每個 GC 週期需要遍歷堆內存4次。
  • 不需要任何額外 Java 堆內存!不需要預留疏散存活對象的堆內存,即使使用了 99.(9)% 的堆內存也可以正常工作。如果我們選擇其它簡單的 GC,例如半空間清掃器(semi-space scavenger),那麼我們需要重新構建堆的表示方式,爲疏散預留一些空間,這些超出了本文的範圍。
  • GC 未啓動時不會產生空間和時間開銷。分配區域處於任意狀態都可以啓動,結束時處於密集壓縮的狀態。這非常適合 Epsilon 實現:從壓縮後的狀態繼續分配。這也是它的缺點:如果堆內存起始位置有很多死亡對象,那麼將會導致大量的對象移動。
  • 不需要任何新的屏障,也就是說不需要修改 EpsilonBarrierSet

簡單起見,該 GC 實現將會是萬物靜止的,而且不分代,單線程執行。在這種情況下可以使用標記位圖存儲標記信息,重用標記字段存儲移動信息。

3. 實現 GC 核心

一下子閱讀完整的實現可能是比較困難的,所以本章將會逐一介紹。

3.1. Prologue

GC 通常需要做一些準備工作。閱讀下述註釋即可,不需要額外說明了。

{
  GCTraceTime(Info, gc) time("Step 0: Prologue", NULL);

  // Commit marking bitmap memory. There are several upsides of doing this
  // before the cycle: no memory is taken if GC is not happening, the memory
  // is "cleared" on first touch, and untouched parts of bitmap are mapped
  // to zero page, boosting performance on sparse heaps.
  if (!os::commit_memory((char*)_bitmap_region.start(), _bitmap_region.byte_size(), false)) {
    log_warning(gc)("Could not commit native memory for marking bitmap, GC failed");
    return;
  }

  // We do not need parsable heap for this algorithm to work, but we want
  // threads to give up their TLABs.
  ensure_parsability(true);

  // Tell various parts of runtime we are doing GC.
  CodeCache::gc_prologue();
  BiasedLocking::preserve_marks();

  // Derived pointers would be re-discovered during the mark.
  // Clear and activate the table for them.
  DerivedPointerTable::clear();
}

由於我們使用標記位圖跟蹤對象是否可達,所以在使用之前需要清理位圖。或者,由於我們的目的是在 GC 週期到來之前不佔用資源,所以我們需要首先申請位圖的內存。這帶來一些有趣的優勢,至少對於 Linux 是這樣,位圖的大部分將會映射到零頁,特別是對於稀疏堆的情況。

線程需要放棄當前的 TLAB,在 GC 結束之後申請新的。[1]

運行時系統的某些部分,特別是需要處理 Java 堆引用的部分,會被 GC 破壞,所以我們需要通知它們 GC 將要開始工作了。這將使得這些子系統準備或者保存 GC 移動之前的狀態。

3.2. 標記

一旦所有的模塊都準備好,進行萬物靜止的標記就相對簡單了。標記過程非常通用,這通常是 GC 實現的第一步。

{
  GCTraceTime(Info, gc) time("Step 1: Mark", NULL);

  // Marking stack and the closure that does most of the work. The closure
  // would scan the outgoing references, mark them, and push newly-marked
  // objects to stack for further processing.
  EpsilonMarkStack stack;
  EpsilonScanOopClosure cl(&stack, &_bitmap);

  // Seed the marking with roots.
  process_roots(&cl);
  stat_reachable_roots = stack.size();

  // Scan the rest of the heap until we run out of objects. Termination is
  // guaranteed, because all reachable objects would be marked eventually.
  while (!stack.is_empty()) {
    oop obj = stack.pop();
    obj->oop_iterate(&cl);
    stat_reachable_heap++;
  }

  // No more derived pointers discovered after marking is done.
  DerivedPointerTable::set_active(false);
}

就像其它的圖遍歷問題一樣:從初始的可達節點集合開始,遍歷所有出邊,記錄所有訪問到的節點,重複這樣操作,直到遍歷完未訪問節點爲止。在 GC 的問題中,“節點”就是對象,“邊”是對象之間的引用。

從技術上講,我們可以用遞歸遍歷對象圖,但是這不是一個好主意,因爲圖可能很大。設想一下遍歷十億個節點的路徑。所以爲了限制遞歸的深度,我們可以使用標記棧來記錄發現的對象。

可達對象的初始集合來自 GC 根。現在不需要糾結 process_roots 的內部細節,稍後我們會介紹。到目前爲止只需要假設該方法遍歷 VM 中所有可達對象。

The marking bitmap serves both as the thing that tracks the marking wavefront (the set of already visited objects), and in the end gives us the desired output: the set of all reachable objects. The actual work in done in the EpsilonScanOopClosure,[2] that would be applied to all interesting objects, and which iterates all references in a given object, and here it is:

標記位圖既可以追蹤標記波前(marking wavefront)(已經訪問的對象集合),也能最後給出所需的輸出:可達對象的集合。EpsilonScanOopClosure[2] 完成了實際的工作,應用於所有相關的對象,迭代給定對象的所有引用:

class EpsilonScanOopClosure : public BasicOopIterateClosure {
private:
  EpsilonMarkStack* const _stack;
  MarkBitMap* const _bitmap;

  template <class T>
  void do_oop_work(T* p) {
    // p is the pointer to memory location where oop is, load the value
    // from it, unpack the compressed reference, if needed:
    T o = RawAccess<>::oop_load(p);
    if (!CompressedOops::is_null(o)) {
      oop obj = CompressedOops::decode_not_null(o);

      // Object is discovered. See if it is marked already. If not,
      // mark and push it on mark stack for further traversal.
      if (_bitmap->par_mark(obj)) {
         _stack->push(obj);
      }
    }
  }
};

這步完成以後,_bitmap 包含了所有存活對象位置的比特集合。我們可以這樣遍歷所有存活對象:

// Walk the marking bitmap and call object closure on every marked object.
// This is much faster that walking a (very sparse) parsable heap, but it
// takes up to 1/64-th of heap size for the bitmap.
void EpsilonHeap::walk_bitmap(ObjectClosure* cl) {
   HeapWord* limit = _space->top();
   HeapWord* addr = _bitmap.get_next_marked_addr(_space->bottom(), limit);
   while (addr < limit) {
     oop obj = oop(addr);
     assert(_bitmap.is_marked(obj), "sanity");
     cl->do_object(obj);
     addr += 1;
     if (addr < limit) {
       addr = _bitmap.get_next_marked_addr(addr, limit);
     }
   }
}

3.3. 計算新位置

這部分也很簡單,主要是算法的實現。

// We are going to store forwarding information (where the new copy resides)
// in mark words. Some of those mark words need to be carefully preserved.
// This is an utility that maintains the list of those special mark words.
PreservedMarks preserved_marks;

// New top of the allocated space.
HeapWord* new_top;

{
  GCTraceTime(Info, gc) time("Step 2: Calculate new locations", NULL);

  // Walk all alive objects, compute their new addresses and store those
  // addresses in mark words. Optionally preserve some marks.
  EpsilonCalcNewLocationObjectClosure cl(_space->bottom(), &preserved_marks);
  walk_bitmap(&cl);

  // After addresses are calculated, we know the new top for the allocated
  // space. We cannot set it just yet, because some asserts check that objects
  // are "in heap" based on current "top".
  new_top = cl.compact_point();

  stat_preserved_marks = preserved_marks.size();
}

這裏唯一的問題是我們將新的地址存儲在 Java 對象的標記字段中,這些標記字段可能處於其他用途,比如保存鎖信息。幸運的是,這些有意義的標記字段很少,如果有需要,我們可以單獨存儲,這就是 PreservedMarks 的作用。

EpsilonCalcNewLocationObjectClosure 實際上執行了算法的步驟:

class EpsilonCalcNewLocationObjectClosure : public ObjectClosure {
private:
  HeapWord* _compact_point;
  PreservedMarks* const _preserved_marks;

public:
  EpsilonCalcNewLocationObjectClosure(HeapWord* start, PreservedMarks* pm) :
                                      _compact_point(start),
                                      _preserved_marks(pm) {}

  void do_object(oop obj) {
    // Record the new location of the object: it is current compaction point.
    // If object stays at the same location (which is true for objects in
    // dense prefix, that we would normally get), do not bother recording the
    // move, letting downstream code ignore it.
    if ((HeapWord*)obj != _compact_point) {
      markOop mark = obj->mark_raw();
      if (mark->must_be_preserved(obj)) {
        _preserved_marks->push(obj, mark);
      }
      obj->forward_to(oop(_compact_point));
    }
    _compact_point += obj->size();
  }

  HeapWord* compact_point() {
    return _compact_point;
  }
};

forward_to 是這裏的關鍵部分:將“移動地址”存儲在對象的標記字段中。下一步需要這些信息。

3.4. 調整指針

再次遍歷堆內存,將所有引用重寫到新的位置:

{
  GCTraceTime(Info, gc) time("Step 3: Adjust pointers", NULL);

  // Walk all alive objects _and their reference fields_, and put "new
  // addresses" there. We know the new addresses from the forwarding data
  // in mark words. Take care of the heap objects first.
  EpsilonAdjustPointersObjectClosure cl;
  walk_bitmap(&cl);

  // Now do the same, but for all VM roots, which reference the objects on
  // their own: their references should also be updated.
  EpsilonAdjustPointersOopClosure cli;
  process_roots(&cli);

  // Finally, make sure preserved marks know the objects are about to move.
  preserved_marks.adjust_during_full_gc();
}

有兩類移動對象的引用;來自 GC 根的和來自堆內存其它對象的。我們都需要更新。某些保存的標記字段也會記錄對象的引用,所以我們也需要更新。PreservedMarks 知道如何操作,因爲“移動信息”就存儲在它原來存儲的位置。

這裏的閉包有兩種類型:一種接受對象,並且遍歷其內容,另一種更新位置。這裏有一點兒性能優化:如果對象沒有被移動,那就不需要改寫位置,所以可以節省相當多的堆內存寫操作。

class EpsilonAdjustPointersOopClosure : public BasicOopIterateClosure {
private:
  template <class T>
  void do_oop_work(T* p) {
    // p is the pointer to memory location where oop is, load the value
    // from it, unpack the compressed reference, if needed:
    T o = RawAccess<>::oop_load(p);
    if (!CompressedOops::is_null(o)) {
      oop obj = CompressedOops::decode_not_null(o);

      // Rewrite the current pointer to the object with its forwardee.
      // Skip the write if update is not needed.
      if (obj->is_forwarded()) {
        oop fwd = obj->forwardee();
        assert(fwd != NULL, "just checking");
        RawAccess<>::oop_store(p, fwd);
      }
    }
  }
};

class EpsilonAdjustPointersObjectClosure : public ObjectClosure {
private:
  EpsilonAdjustPointersOopClosure _cl;
public:
  void do_object(oop obj) {
    // Apply the updates to all references reachable from current object:
    obj->oop_iterate(&_cl);
  }
};

這步完成之後,堆內存基本上毀壞了:引用指向了“錯誤”的位置,因爲對象還沒有移動。讓我們修正這個問題吧!

3.5. 移動對象

是時候將對象移到新位置了,算法的步驟如下:

再次遍歷堆內存,將閉包 EpsilonMoveObjects 應用到所有存活對象:

{
  GCTraceTime(Info, gc) time("Step 4: Move objects", NULL);

  // Move all alive objects to their new locations. All the references are
  // already adjusted at previous step.
  EpsilonMoveObjects cl;
  walk_bitmap(&cl);
  stat_moved = cl.moved();

  // Now we moved all objects to their relevant locations, we can retract
  // the "top" of the allocation space to the end of the compacted prefix.
  _space->set_top(new_top);
}

這步完成之後,我們可以將分配的空間收縮到壓縮點,GC 週期結束之後內存分配邏輯就可以從這裏開始分配。

Note that sliding GC means we can overwrite the contents of existing objects, but since we are scanning in one direction, that means the object we are overwriting is already copied out to its proper location. [3] So, the closure itself just moves the forwarded objects to their new locations:

注意滑動 GC 意味着我們會覆寫存在對象的內容,但是因爲我們是一個方向掃描,所以覆寫的對象已經複製到了合適的位置。[3]所以這個閉包的邏輯僅僅是將對象移動到新的位置:

class EpsilonMoveObjects : public ObjectClosure {
public:
  void do_object(oop obj) {
    // Copy the object to its new location, if needed. This is final step,
    // so we have to re-initialize its new mark word, dropping the forwardee
    // data from it.
    if (obj->is_forwarded()) {
      oop fwd = obj->forwardee();
      assert(fwd != NULL, "just checking");
      Copy::aligned_conjoint_words((HeapWord*)obj, (HeapWord*)fwd, obj->size());
      fwd->init_mark_raw();
    }
  }
};

3.6. Epilogue

GC 結束了,現在堆內存再一次一致了,我們還需要做一些收尾工作:

{
  GCTraceTime(Info, gc) time("Step 5: Epilogue", NULL);

  // Restore all special mark words.
  preserved_marks.restore();

  // Tell the rest of runtime we have finished the GC.
  DerivedPointerTable::update_pointers();
  BiasedLocking::restore_marks();
  CodeCache::gc_epilogue();
  JvmtiExport::gc_epilogue();

  // Marking bitmap is not needed anymore
  if (!os::uncommit_memory((char*)_bitmap_region.start(), _bitmap_region.byte_size())) {
    log_warning(gc)("Could not uncommit native memory for marking bitmap");
  }

  // Return all memory back if so requested. On large heaps, this would
  // take a while.
  if (EpsilonUncommit) {
    _virtual_space.shrink_by((_space->end() - new_top) * HeapWordSize);
    _space->set_end((HeapWord*)_virtual_space.high());
  }
}

通知運行時系統的其他部分執行 GC 後的清理、修正工作。恢復我們之前保存的特殊的標記字段。跟標記位圖說再見吧,我們不需要它了。

如果我們願意,我們可以將提交的空間收縮到新的分配點,將多餘的內存還給 OS!

4. 將 GC 連接到 VM

4.1. 根遍歷

還記得需要遍歷 VM 中特殊的隱含可達的引用麼?這是通過查詢相關的 VM 子系統完成的,相關的子系統會遍歷對其它 Java 對象隱藏的引用。當前 Hotspot 中根的詳盡列表如下:

void EpsilonHeap::do_roots(OopClosure* cl) {
  // Need to tell runtime we are about to walk the roots with 1 thread
  StrongRootsScope scope(1);

  // Need to adapt oop closure for some special root types.
  CLDToOopClosure clds(cl, ClassLoaderData::_claim_none);
  MarkingCodeBlobClosure blobs(cl, CodeBlobToOopClosure::FixRelocations);

  // Walk all these different parts of runtime roots. Some roots require
  // holding the lock when walking them.
  {
    MutexLockerEx lock(CodeCache_lock, Mutex::_no_safepoint_check_flag);
    CodeCache::blobs_do(&blobs);
  }
  {
    MutexLockerEx lock(ClassLoaderDataGraph_lock);
    ClassLoaderDataGraph::cld_do(&clds);
  }
  Universe::oops_do(cl);
  Management::oops_do(cl);
  JvmtiExport::oops_do(cl);
  JNIHandles::oops_do(cl);
  WeakProcessor::oops_do(cl);
  ObjectSynchronizer::oops_do(cl);
  SystemDictionary::oops_do(cl);
  Threads::possibly_parallel_oops_do(false, cl, &blobs);
}

可以並行遍歷根。對於我們單線程 GC 的場景,簡單的遍歷就可以了。

4.2. 安全點與萬物靜止

因爲我們的 GC 是萬物靜止的,所以我們需要請求 VM 執行實際的萬物靜止停頓。在 Hotspot 中,這是通過實現一個新的 VM_Operation 達成的,它會調用我們的 GC 代碼,並且要求 VM 線程執行它:

// VM operation that executes collection cycle under safepoint
class VM_EpsilonCollect: public VM_Operation {
private:
  const GCCause::Cause _cause;
  EpsilonHeap* const _heap;
  static size_t _last_used;
public:
  VM_EpsilonCollect(GCCause::Cause cause) : VM_Operation(),
                                            _cause(cause),
                                            _heap(EpsilonHeap::heap()) {};

  VM_Operation::VMOp_Type type() const { return VMOp_EpsilonCollect; }
  const char* name()             const { return "Epsilon Collection"; }

  virtual bool doit_prologue() {
    // Need to take the Heap lock before managing backing storage.
    // This also naturally serializes GC requests, and allows us to coalesce
    // back-to-back allocation failure requests from many threads. There is no
    // need to handle allocation failure that comes without allocations since
    // last complete GC. Waiting for 1% of heap allocated before starting next
    // GC seems to resolve most races.
    Heap_lock->lock();
    size_t used = _heap->used();
    size_t capacity = _heap->capacity();
    size_t allocated = used > _last_used ? used - _last_used : 0;
    if (_cause != GCCause::_allocation_failure || allocated > capacity / 100) {
      return true;
    } else {
      Heap_lock->unlock();
      return false;
    }
  }

  virtual void doit() {
    _heap->entry_collect(_cause);
  }

  virtual void doit_epilogue() {
    _last_used = _heap->used();
    Heap_lock->unlock();
  }
};

size_t VM_EpsilonCollect::_last_used = 0;

void EpsilonHeap::vmentry_collect(GCCause::Cause cause) {
  VM_EpsilonCollect vmop(cause);
  VMThread::execute(&vmop);
}

當所有線程都想要執行 GC 時(通常在內存耗盡時發生),這也有助於解決性能敏感的爭用。

4.3. 分配失敗

雖然顯式執行 GC 很好,但是我們也希望 GC 對堆內存耗盡做出反應。將 allocate_work 調用替換成這個包裝了分配失敗執行 GC 的方法即可:

HeapWord* EpsilonHeap::allocate_or_collect_work(size_t size) {
  HeapWord* res = allocate_work(size);
  if (res == NULL && EpsilonSlidingGC) {
    vmentry_collect(GCCause::_allocation_failure);
    res = allocate_work(size);
  }
  return res;
}

到這裏!全部完成了。

5. 構建

這個補丁可以毫無問題的應用於 OpenJDK。

$ hg clone https://hg.openjdk.java.net/jdk/jdk/ jdk-jdk
$ cd jdk-jdk
$ curl https://shipilev.net/jvm/diy-gc/webrev/jdk-jdk-epsilon.changeset | patch -p1

然後像往常一樣構建 OpenJDK:

$ ./configure --with-debug-level=fastdebug
$ make images

然後像往常一樣執行:

$ build/linux-x86_64-server-fastdebug/images/jdk/bin/java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+EpsilonSlidingGC -version
openjdk version "13-internal" 2019-09-17
OpenJDK Runtime Environment (build 13-internal+0-adhoc.shade.jdk-jdk-epsilon)
OpenJDK 64-Bit Server VM (build 13-internal+0-adhoc.shade.jdk-jdk-epsilon, mixed mode, sharing)

6. 測試

如何確保 GC 實現沒有問題呢?好吧,這裏有一些方便的方法:

  1. 斷言。許多斷言。Hotspot 代碼中有許多斷言,fastdebug 構建的版本會在 GC 有問題時拋出一些相關的錯誤。
  2. 內部的驗證。該實現在 GC 的最後一步會遍歷所有存活對象,驗證它們是正常的。在暴露給運行時和應用程序之前捕獲嚴重的錯誤。
  3. 測試。如果代碼沒有實際運行,斷言和驗證是無用的。可以儘早進行單元測試和集成測試,這通常是很方便的。

例如,可以這樣驗證這個補丁沒有太嚴重的問題:

$ CONF=linux-x86_64-server-fastdebug make images run-test TEST=gc/epsilon/
Building targets 'images run-test' in configuration 'linux-x86_64-server-fastdebug'
Test selection 'gc/epsilon/', will run:
* jtreg:test/hotspot/jtreg/gc/epsilon

Running test 'jtreg:test/hotspot/jtreg/gc/epsilon'
Passed: gc/epsilon/TestAlwaysPretouch.java
Passed: gc/epsilon/TestAlignment.java
Passed: gc/epsilon/TestElasticTLAB.java
Passed: gc/epsilon/TestEpsilonEnabled.java
Passed: gc/epsilon/TestHelloWorld.java
Passed: gc/epsilon/TestLogTrace.java
Passed: gc/epsilon/TestDieDefault.java
Passed: gc/epsilon/TestDieWithOnError.java
Passed: gc/epsilon/TestMemoryPools.java
Passed: gc/epsilon/TestMaxTLAB.java
Passed: gc/epsilon/TestPrintHeapSteps.java
Passed: gc/epsilon/TestArraycopyCheckcast.java
Passed: gc/epsilon/TestClasses.java
Passed: gc/epsilon/TestUpdateCountersSteps.java
Passed: gc/epsilon/TestDieWithHeapDump.java
Passed: gc/epsilon/TestByteArrays.java
Passed: gc/epsilon/TestManyThreads.java
Passed: gc/epsilon/TestRefArrays.java
Passed: gc/epsilon/TestObjects.java
Passed: gc/epsilon/TestElasticTLABDecay.java
Passed: gc/epsilon/TestSlidingGC.java
Test results: passed: 21
TEST SUCCESS

對這滿意麼?現在使用啓用驗證功能的 fastdebug 構建版本執行實際的應用程序。沒有崩潰吧?在這一點上,我們得抱有希望。

7. 性能

讓我們用 spring-petclinic 運行在我們的玩具 GC 上,並且使用 Apache Bench 製造一些負載!因爲工作負載幾乎沒有存活數據,所以分代和非分代 GCs 都合適。

使用 -Xlog:gc -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+EpsilonSlidingGC 執行:

Heap: 20480M reserved, 20480M (100.00%) committed, 19497M (95.20%) used
GC(2) Step 0: Prologue 2.085ms
GC(2) Step 1: Mark 51.005ms
GC(2) Step 2: Calculate new locations 71.207ms
GC(2) Step 3: Adjust pointers 49.671ms
GC(2) Step 4: Move objects 22.839ms
GC(2) Step 5: Epilogue 1.008ms
GC(2) GC Stats: 70561 (8.63%) reachable from roots, 746676 (91.37%) reachable from heap, 91055 (11.14%) moved, 2237 (0.27%) markwords preserved
GC(2) Heap: 20480M reserved, 20480M (100.00%) committed, 37056K (0.18%) used
GC(2) Lisp2-style Mark-Compact (Allocation Failure) 20479M->36M(20480M) 197.940ms

200 ms?對於一個我們剛剛構建的單線程 GC 來說還不錯!你可以看到四個主要階段耗費了相同量級的時間。實際上,如果你嘗試不同的堆內存佔用和堆內存大小,那麼就能看出一些規律:更多的存活數據意味着明顯緩慢的 GC(當數據很多時,遍歷存活對象並不是很容易),更大的堆內存大小意味着稍微緩慢的 GC(在稀疏堆上進行長距離遍歷也會影響吞吐量)。

相對而言,分代 GC 和清除器可以輕鬆取勝。例如, -Xlog:gc -XX:+UseSerialGC 主要進行年輕代收集:

GC(46) Pause Young (Allocation Failure) 575M->39M(1943M) 2.603ms
GC(47) Pause Young (Allocation Failure) 575M->39M(1943M) 2.606ms
GC(48) Pause Young (Allocation Failure) 575M->39M(1943M) 2.747ms
GC(49) Pause Young (Allocation Failure) 575M->39M(1943M) 2.578ms

哇!2 ms。這是因爲大部分對象在年輕代都死掉了,所以幾乎沒有 GC 工作要做。如果我們關閉
-Xlog:gc -XX:+UseSerialGC 中的分代擴展,只執行 Full GC,那麼結果就不太好了:

GC(3) Pause Full (Allocation Failure) 16385M->34M(18432M) 1969.694ms
GC(4) Pause Full (Allocation Failure) 16385M->34M(18432M) 2261.405ms
GC(5) Pause Full (Allocation Failure) 16385M->34M(18432M) 2327.577ms
GC(6) Pause Full (Allocation Failure) 16385M->34M(18432M) 2328.976ms

There are plenty of other metrics and scenarios one can play with. This is left as an exercise for the reader.

還有許多度量標準和測試場景。這就留給讀者做練習了。

8. 下一步

在這個實現的基礎上還可以做很多事情。但是現有的 OpenJDK GCs 已經實現(並且測試!)了這些,所以這只是教學練習。

可以改進之處:

  1. 實現引用處理。當前的實現忽略了軟引用、弱引用、虛引用的存在。也忽略了 finalizeable 對象的存在。從性能角度來看,這並不理想,但是從正確性的角度來看,這很安全,因爲共享的代碼“只是”將所有這些引用視爲總是可達的[4],因此會像其它常規引用一樣被移動和更新。正確的實現應該將共享的 ReferenceProcessor 連接到標記邏輯中,在標記結束之後標記和清除存活或死亡的引用。
  2. 實現類卸載以及其它 VM 清理。當前的實現不會卸載類,也不會清理內部 VM 數據結構,這些數據結構可能持有不可達的對象,因此可能是冗餘的。實現這部分邏輯需要處理根,默認只標記根,然後查看是否存在根仍然被標記,清理死亡的根。
  3. 並行化[5]。並行化最簡單的方法是將堆內存劃分爲各個線程的區域,各個線程在各自的區域執行相同的順序壓縮。這將會在區域之間留下空隙,所以分配邏輯也需要修改以知道存在多個空閒區域。
  4. 實現密集前綴處理。通常來說,正常的堆內存會形成總是可達對象的“沉澱”層,如果我們將堆內存的某些前綴指定爲不能移動的,那麼可以節省不少時間。然後我們可以避免計算地址,移動對象。但是我們仍然需要標記引用,調整指針。
  5. 擴展密集前綴爲分代。結合某些 GC 屏障的工作,我們可以分辨哪些密集前綴是需要處理的,因此節省了標記和調整指針的時間。最終,這就成了“分代的”,前綴之外執行“young”收集,有時執行壓縮前綴的“full”收集。
  6. 從 GC 手冊中選擇任意 GC 算法,嘗試實現。

結論

這裏可以得出什麼結論呢?實現一個玩具 GCs 很有意思,也具有教學作用,很適合大學的 GC 課程。

產品化這個實現將是一個乏味耗時的過程,所以選擇存在的收集器,進行一下調優更容易。值得注意的是,如果進一步開發,這個實現最終會變爲 Serial 或 Parallel GC,這使得開發工作多少是徒勞的。


  1. 不要混淆 TLAB 與 java.lang.ThreadLocal。從 GC 的角度來看,ThreadLocals 仍然是普通的對象,除非 Java 代碼特殊處理,否則不會被清除。

  2. 看,Java 在此之前就使用閉包很酷!

  3. 但是某些對象的新位置和舊位置可能重疊。例如,你可以將一個100字節的對象滑動8字節。複製例程將會保證重疊的數據正確複製,注意 Copy::aligned_*conjoint*_words

  4. 從 GC 角度來看, java.lang.ref.Reference.referent 僅僅是另一個 Java 字段,除非在遍歷堆內存時特殊處理,否則這就是強可達的。Finalizable 對象擁有持有自身的 FinalReference 對象。

  5. 標記整理的並行版本是 Shenandoah(OpenJDK 8 之後) 和 G1(OpenJDK 10 之後, JEP 307: "Parallel Full GC for G1") 的 Full GC 預設機制。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章