如何实现一个垃圾收集器

原文:Do It Yourself (OpenJDK) Garbage Collector
作者:Aleksey,Red Hat,OpenJDK开发者,https://shipilev.net/
译者:初开,http://chukai.link

介绍

构建高可靠、高性能、可观察、可调试,有故障预测的运行时非常难,但造一个简单的轮子还是可以的。
我们今天实现一个简单的垃圾收集器。Roman Kennke曾使用本文的早期版本,参加了FOSDEM 2019的演讲“20分钟构建GC”。虽然生产的实现很复杂,但原理是类似的。

为了改善阅读体验,请确保你了解过垃圾收集器的基本原理。本文虽然有一些基础知识和实现细节,但并不是入门的地方。可以阅读GC Handbook的第一章,或了解维基百科Tracing garbage collection

1.构建块

现在实现新的GC要简单得多,很多其他GC实现的模块可以复用。

1.1 Epsilon GC

OpenJDK 11的JEP 318:“Epsilon:无操作垃圾收集器(实验版)”,是为不需要或禁止GC的场景提供最小实现,可以阅读JEP以获得更多信息。
从实现的角度来看,“垃圾收集器”这个术语用词不当,正确的术语是“自动内存管理器”,它实际上负责内存的分配和回收。
Epsilon GC仅实现了“分配”部分,所以我们可以在它上面来实现回收功能。

1.1.1 分配

Epsilon GC的核心是分配算法,分配到堆,或者分配到线程局部分配缓冲区(TLAB)。因为没有回收,所以它没怎么扩展TLAB。

1.1.2 屏障

一些垃圾收集器需要维护GC不变量,方法是在访问堆时强制执行GC屏障,所有并发收集器,包括分代收集器都是如此。
Epsilon不需要屏障,但运行时和编译器仍然需要知道,哪怕是空实现,也要假装把它们打包起来。而Ope​​nJDK 11的JEP-304:“垃圾收集接口”的定义使插入屏障更简单。
正因为Epsilon的GC屏障是空实现,所有的琐碎工作:加载,存储,CAS,数组复制,被委托给默认屏障。如果要开发一个仍然不需要障碍的GC,可以简单地重用Epsilon已有的。

1.1.3 JVM工具

实现GC的最后一项工作是能将它整合到JVM的各种监视工具中:MX bean、诊断命令等等。Epsilon 已经为我们处理了这个问题

1.2 运行时和GC

1.2.1 Roots

垃圾收集器通常需要知道哪些部分包含对堆的引用,即GC Roots,包括线程堆栈和局部变量(JIT编译代码),本地类和类加载器,JNI句柄等等。GC Roots虽然听起来很复杂,但是在Hotspot中,每个VM子系统都会跟踪这些位置,我们可以用现有的GC实现。

1.2.2 对象遍历器

垃圾收集器需要遍历对象的引用,运行时已经提供了遍历器,无需自己实现。你将在下面的obj→oop_iterate中看到这些。

1.2.3 转发数据

垃圾收集器需要记录GC后的对象的新地址,称之为转发数据或转发地址,有几个地方存储地址值:

  1. 复用对象中的“markword”字段(Serial, Parallel等)。当GC暂停时,对对象字段的所有访问都停止,没有线程知道我们偷偷在“markword”中的存了数据。
  2. 维护单独的本地转发表(ZGC,C4等)。这将完全隔离GC与运行时和应用程序的其余部分,只有GC才会知道存在转发表。并发GC为了避免混淆通常采用这种方案。

  3. 在对象上添加另一个字段(Shenandoah等)。这是结合前两种方法,让运行时和应用程序与现有标头一起工作,同时保持转发数据。

1.2.4 标记数据

垃圾收集器需要在某处记录可达性标记。同样,有几种方法可以存储它:

  1. 复用对象中的“markword”(Serial, Parallel等)。同样,在GC暂停时,使用“markword”来编码“已标记”属性。然后,如果需要遍历所有活动对象,可以利用堆可解析性遍历。
  2. 维护单独的标记数据结构(G1,Shenandoah等)。这通常使用位图,将Java堆的每N个字节映射到1位标记位图。通常,Java对象按8个字节对齐,因此标记位图将64位堆映射到1位标记位图,占本机堆大小的1/64。这在扫描堆以获取对象实例(尤其是稀疏对象)时很管用:遍历位图通常比遍历分析堆快得多。
  3. 在引用本身(ZGC,C4,其他)中编码标记信息。这需要与应用协调以从引用中去除标记信息,或者做其他工作以保持正确性。换句话说,它需要GC屏障或更多的GC工作。

2.大计划

最容易在Epsilon上实现的GC算法是LISP2风格的标记-整理算法。该算法的原理在相关的维基百科条目GC手册的第3.2章中给出。下面概述了算法的部分原理,可以阅读维基百科或GC手册进一步了解。
算法的关键是滑动GC:它通过将对象滑动到堆的开头来移动对象。它具有以下特征:

  1. 维护分配顺序。这对于控制内存布局非常有用,如果你是控制狂会很开心,但缺陷是,没法得到引用位置
  2. 对象数是O(n)。但是,这种线性需要付出代价,每个GC循环需遍历堆4次。
  3. 它不需要任何额外的堆内存!无需保留活动对象的堆内存,因此即使是占有率99.9%的堆也可以操作。如果我们实现其他算法,例如标记-复制,我们需要重新划分堆的结构,并为复制预留一些空间,这超出了本文的范围。
  4. 通过一些技巧,当GC未启用时,可以不产生空间和时间开销。密集整理分配区域,这用来形容Epsilon很贴切:它将持续从整理指针开始分配。但这也是它的缺点:堆开始时的一些死对象会频繁移动。
  5. 它不需要任何新的屏障来防止EpsilonBarrierSet受影响。

为简单起见,本文的GC实现将完全暂停,而非分代和单线程。在这种情况下,使用标记位图来存储标记数据,并重用标记字来存储转发数据。

3 实现GC的关键

一口气阅读完整实现太复杂了,我们逐个击破它。

3.1 准备

GC通常需要做一些准备工作,阅读以下注释,它们应该不难:

{
  GCTraceTime(Info, gc) time("Step 0: Prologue", NULL);
    //提交标记位图内存。 这样做有几个好处
   //循环之前:如果没有发生GC,则不会占用内存
   //在第一次调用时清理并映射未使用的位图部分
   //提交到零页,提高稀疏堆的性能。
  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;
  }

    //我们不需要可解析堆来让算法工作,但我们想让线程放弃现有的TLAB。
  ensure_parsability(true);

  //通知运行时的各个部分我们将开始GC。
  CodeCache::gc_prologue();
  BiasedLocking::preserve_marks();

 //在标记期间将重置派生指针。
 //清除并激活表。
  DerivedPointerTable::clear();
}

由于我们使用标记位图来跟踪哪些对象可以访问,因此需要在使用之前清理。或者,在这种情况下,当我们追求从不占用资源直到GC循环命中,则需要首先将位图提交到内存。这带来了一些优势,至少在Linux上,大多数位图都映射到零页,特别是对于稀疏堆。
GC完成后,线程需要放弃现有的TLAB并向GC请求新的TLAB。

运行时的某些部分,特别是处理Java堆引用的部分,会被GC过程修改,因此需要通知它们GC即将开始,请准备/保存某些状态。

3.2 标记

一旦准备好所有的组件,GC暂停标记就非常简单,这是许多GC实现的第一步。

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

//标记堆栈和对象闭包(OopClosure)。通过对象闭包
//扫描对象的引用,标记它们,并推送新标记对象
//到堆栈以进行下一步处理。
  EpsilonMarkStack stack;
  EpsilonScanOopClosure cl(&stack, &_bitmap);

  // 在Roots上设置标记
  process_roots(&cl);
  stat_reachable_roots = stack.size();

  // 扫描堆的其余部分,终止是有保证的,最终会标记所有可访问对象。
  while (!stack.is_empty()) {
    oop obj = stack.pop();
    obj->oop_iterate(&cl);
    stat_reachable_heap++;
  }

    //标记完成后,就没有其他派生指针了。
  DerivedPointerTable::set_active(false);
}

就像图遍历一样:从最初的可到达顶点开始,遍历所有边,记录访问过的顶点,并执行此操作,直到走完未访问的顶点。在GC中,“顶点”是对象,“边”是它们之间的引用。
从技术上讲,可以使用递归来遍历,但对于任意图来说是一个糟糕的主意,比如有10亿个节点的话。因此,为了限制递归深度,我们使用记录堆栈来记录遍历后的对象。
可达对象的初始值来自GC Roots。不要纠结process_roots,稍后会解释,先假设它遍历了所有引用。
标记位图既用于跟踪标记(已访问对象的集合),也最终输出:所有可达对象的集合。在实际工作是由EpsilonScanOopClosure完成的,它会遍历给定对象的所有引用,如下:

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

  template <class T>
  void do_oop_work(T* p) {
    // p是指向oop的内存位置的指针,从中加载值,如果有必要,进行解压
    T o = RawAccess<>::oop_load(p);
    if (!CompressedOops::is_null(o)) {
      oop obj = CompressedOops::decode_not_null(o);

      //发现了对象。看看它是否已经标记。如果没有,
      //标记并将其推到标记堆栈上以进行进一步遍历。
      if (_bitmap->par_mark(obj)) {
         _stack->push(obj);
      }
    }
  }
};

完成此步骤后,_bitmap包含活动对象设置的位。然后进一步遍历他们,如下:

//遍历标记位图,并在标记对象上调用它引用的对象闭包。
//走一个(非常稀疏的)可解析的堆,这要快得多,
//但占用位图的堆大小的1/64。
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 计算新位置

这部分也很简单。

计算新的:

/我们将用对象头中的markword存储转发信息(对象的新位置),其中一些markword需要谨慎保存。
//这是一个维护这些特殊markword列表的实用程序。
PreservedMarks preserved_marks;
//新分配空间的顶部。
HeapWord* new_top;

{
  GCTraceTime(Info, gc) time("Step 2: Calculate new locations", NULL);
//遍历所有活动对象,计算新地址
//并存储到markword。可选择保留一些已有的markword
  EpsilonCalcNewLocationObjectClosure cl(_space->bottom(), &preserved_marks);
  walk_bitmap(&cl);

 //在计算地址后,就有了新分配空间的头部指针。
 // 我们还不能使用它,因为有些“在堆中”的断言检查对象
  //在当前的“头部”。
  new_top = cl.compact_point();
  stat_preserved_marks = preserved_marks.size();
}

这里唯一的缺陷是我们将新地址存储在Java对象的markword中,而这些markword可能已经有数据了,例如锁的信息。但这样的markword很少见,如果需要,可以单独存储,这是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) {
    //记录对象的新位置:当前的整理指针(compact point)的位置(见本节开头图)。
    //如果对象当前位置和整理位置相同(这一判断对密集前缀中的对象一直为true),跳过它。
    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是这里的关键部分:它将“转发地址”存储在对象的markword字中。后面将使用它。

译者注:密集前缀(dense prefix )。由于上一次的的标记整理GC后,内存空间中,左边的对象密度很大,这部分被称为密集密集前缀。并且再一次整理时,这一部分也往往回收不了多少(因为大部分对象存活时间很短,而经过一次GC能活下来的,往往也能继续活着),如果剩余空间充足,为了提高性能往往会跳过左边密集部分的GC。

3.4 调整指针

根据算法,再次遍历堆并重写新位置的引用:

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

//遍历所有活动对象及其引用字段,并设置新地址。新地址在转发数据markword字段中,先处理堆对象。
  EpsilonAdjustPointersObjectClosure cl;
  walk_bitmap(&cl);

  // 对于VM roots,执行相同的操作,更新引用。
  EpsilonAdjustPointersOopClosure cli;
  process_roots(&cli);

  // 最后,确保之前保留的标记知道对象移动了。
  preserved_marks.adjust_during_full_gc();
}

移动的对象有两组引用:来自堆中的其他对象和GC Roots,需要同时更新。一些保留标记也记录了对象引用,需要让它们自我更新。PreservedMarks自己知道该怎么做,因为它得到了记录的对象引用。

对象闭包(OopClosureœ)现在有两种类型:一种接受对象并遍历其内容,另一种更新位置。这是有一个小的性能优化:如果一个对象没有转发地址,它就不会更新,我们可以节省操作。

class EpsilonAdjustPointersOopClosure : public BasicOopIterateClosure {
private:
  template <class T>
  void do_oop_work(T* p) {
    // p是指向oop的内存位置的指针,从中加载值,如果有必要,进行解压
    T o = RawAccess<>::oop_load(p);
    if (!CompressedOops::is_null(o)) {
      oop obj = CompressedOops::decode_not_null(o);

      // 用转发地址重写当前对象的指针
      // 如果不需要则跳过
      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) {
    // 遍历更新当前所有可用对象的引用
    obj->oop_iterate(&_cl);
  }
};

完成此步骤后,堆已经被玩坏了:引用指向了“错误”位置,因为对象本身没移动。让我们纠正下!

3.5 移动对象

按照算法步骤,将对象移动到新位置:

再次遍历堆并用EpsilonMoveObjects移动所有活动对象:

{
  GCTraceTime(Info, gc) time("Step 4: Move objects", NULL);
  // 将所有活动对象移动到新位置。引用已在前一步调整了。
  EpsilonMoveObjects cl;
  walk_bitmap(&cl);
  stat_moved = cl.moved();
  //将所有对象移动到对应位置后,我们可以移回分配空间的头部指针了。
  _space->set_top(new_top);
}

完成此操作后,可以将可分配空间的起点移回到整理区末尾,让GC循环结束后从这里开始分配新内存。

请注意,滑动GC意味着覆盖了现有对象,但由于在一个方向上扫描,这意味着覆盖的对象直接被复制到正确位置。因此,EpsilonMoveObjects只是将转发的对象移动到新的位置:

class EpsilonMoveObjects : public ObjectClosure {
public:
  void do_object(oop obj) {
//如果存在转发地址,将对象复制到新位置。
//这是最后一步了,所以重置markword字段,清除转发地址。
    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 润色

GC已经结束,堆内存基本一致了,再润色下:

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

  // 恢复所有特殊的markword。
  preserved_marks.restore();

  // 通知运行时GC已完成
  DerivedPointerTable::update_pointers();
  BiasedLocking::restore_marks();
  CodeCache::gc_epilogue();
  JvmtiExport::gc_epilogue();

  // 不再需要标记位图了
  if (!os::uncommit_memory((char*)_bitmap_region.start(), _bitmap_region.byte_size())) {
    log_warning(gc)("Could not uncommit native memory for marking bitmap");
  }

  // 如果有要求,将内存还给操作系统,在大堆上会花一点时间。
  if (EpsilonUncommit) {
    _virtual_space.shrink_by((_space->end() - new_top) * HeapWordSize);
    _space->set_end((HeapWord*)_virtual_space.high());
  }
}

通知运行时的其他部分进行GC后清理和修复;恢复之前保存的特殊markword;清除标记位图。
如果有必要,可以将请求的内存重置到新的分配点,从而将内存还给给操作系统!

4.将GC挂接到VM

4.1 Roots 遍历

还记得需要从VM中获取特殊的可隐式访问的引用吗?这是要求VM中的每个特殊子系统将其隐藏的引用从其余对象中移除。当前Hotspot中Root目录如下所示:

void EpsilonHeap::do_roots(OopClosure* cl) {
  // 需要告诉运行时我们将用1个线程遍历Roots
  StrongRootsScope scope(1);

  // 需要为一些特殊的Root类型适配对象闭包。
  CLDToOopClosure clds(cl, ClassLoaderData::_claim_none);
  MarkingCodeBlobClosure blobs(cl, CodeBlobToOopClosure::FixRelocations);

  // 遍历运行时Root的所有部分,有些Root在遍历时需要锁。
  {
    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暂停

由于GC是全局的,需要VM执行全局的GC暂停。在Hotspot中,这是通过一个新VM_Operation调用我们的GC代码,并要求VM线程执行它:

// 在安全点下执行GC循环的VM操作
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() {
//在管理备份存储之前需要获取堆锁。
//这也自然地序列化GC请求,并允许我们合并来自多个线程的连续分配失败请求。
//不需要处理自上次完成GC以来没有分配的分配失败。
//在开始下一个之前等待分配的1%的堆
    // 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循环中的最后一步,它可以遍历所有活动对象并验证是否正常。在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,用Apache Bench加载,跑一下吧!由于程序几乎没有实时数据,因此分代和非分代GC都差不多。
运行-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毫秒?对于第一次运行的单线程GC来说还不错!你可以看到四个主要阶段的时间相同。事实上,如果你用不同的占比和大小的堆,那么就会出现:更多对象会让GC会显著变慢(有太多活动对象并不好玩),更​​大的堆意会导致较慢的GC(即使在稀疏堆上遍历很长的距离也会对吞吐量产生影响)。

为了进行比较,用分代GC来测下。例如,-Xlog:gc -XX:+UseSerialGC主要是年轻代GC:

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毫秒,这是因为大多数对象在年轻一代中已经死亡,并几乎没有任何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
还可以用其他GC策略,留给读者练习。

8.改进

可以从这里开始尝试,但它会占用现有的OpenJDK GC的工作(包括测试!),所以仅作为练习。
还可以改进:

  1. 处理引用实现。当前实现忽略了软/弱/虚引用。也忽略了可终结对象的存在。从性能的角度来看,这并不理想,但从正确性来看是安全的,因为代码会将这些引用视为始终可访问,会更新为常规引用。进阶实现则涉及将共享的ReferenceProcessor连接到标记代码,并在标记结束后标记/清除这些存活/死亡的引用。
  2. 实现类卸载和其他VM清理。当前的实现从不卸载类,也不会清除堆无法访问的对象的内部VM数据结构。实现这一点需要注意弱/强Root,默认情况下只标记强Root,然后在标记完成后查看是否仍然标记了弱Root,清理死亡的Root。
  3. 并行化。并行版本的最简单方法是将堆划分给每个线程,并在这些区域内执行相同的顺序整理。这将留下区域之间的空白,因此需要修改分配方式以知道存在多个空白区域。
  4. 处理密集前缀。通常情况下,正常堆最终会出现对象“沉积”层,如果将堆的前缀区指定为不移动,避免计算地址和移动对象,可以提升性能。不过仍需要标记并调整指针。
  5. 将密集前缀扩展到所有分代。通过GC屏障,可以看出密集前缀的哪些部分变动少,从而减少标记和指针调整。最后,它会变成“分代”,它会通过前缀执行young GC,有时也可以执行Full GC来整理前缀。
  6. 从GC手册中获取GC算法,并尝试实现。

总结

实现GC既有趣又有教育意义,可能非常适合大学的GC课程。
产品化这样的实现是一个繁琐且耗时的过程,因此更容易的是切换并调整现有的收集器。如果进一步发展,这种实现最终变为现有的串行或并行GC一样,没这个必要。


注:

  1. 不要混淆TLAB和java.lang.ThreadLocal。从GC的角度来看,ThreadLocals仍然是普通对象,并且它们不会被GC清除,除非Java代码有处理。
  2. 可以先看看闭包
  3. 对象的新旧位置可以重叠。例如,可以将100字节对象滑动8个字节。复制例程将确保正确复制内容,见Copy::aligned_*conjoint*_words。
  4. 从GC的角度来看,java.lang.ref.Reference.referent只是另一个Java字段,除非我们以特殊方式遍历堆,否则它是强可访问的。可终结对象有自己的合成实例,FinalReference会保留它们。
  5. 这个mark-compact的并行版本是Shenandoah(从OpenJDK 8开始)和G1(从OpenJDK 10开始,JEP 307: "Parallel Full GC for G1") 中Full GC的实现。

感谢阅读,如有翻译不当之处,请回复指出。

Comments
Write a Comment