在 JVM 中实现一个完整的 GC 算法

概述

自动内存管理,也叫垃圾回收(garbage collection, GC),是程序语言 runtime 的核心组成部分。GC 主要有两个任务:对象的分配和对象的回收。由于对象分配在语言中是非常高频的操作,GC 需要有效组织内存并高效分配对象;当 runtime 中没有空闲内存供分配对象时,GC 需要触发垃圾回收,从根对象出发,沿着对象引用关系进行对象标记,找到不可达的对象并回收其内存,从而继续分配对象。

GC 的实现涉及程序语言的方方面面。许多语言特性需要 GC 支持,例如 weak reference, finalizer 等;GC 需要同 runtime 其他模块交互,例如 JIT 编译器,GC 需要在进行标记时处理 JIT 生成代码中指向的对象;GC 的实现时需要非常小心,实现不正确可能导致 memory corruption,并且报错的地方同出错的地方可能相距甚远,排查起来十分困难;一些内存的特性可能同 OS 和 CPU 架构相关,例如不同 CPU 的 memory model,会影响 GC 的实现和正确性。但另一方面,GC 算法的逻辑十分清晰,当你清楚代码的大致逻辑时,读起来会很流畅。

为了支持语言特性,同时应对生产环境中对内存管理的高要求,GC实现支持许多高级特性,例如为了降低 GC 暂停时间,现代 GC 支持 concurrent marking 和 concurrent evacuation,因此 GC 代码的体量和实现的细节非常庞杂,不花一些时间很难理解其精髓。太多实现细节成为完整理解 GC 算法的绊脚石。

据此,为了学习 GC 算法的核心逻辑,本文介绍了在最新的 HotSpot JVM 中,借助 epsilon GC,实现一个 single generation stop-the-world mark compact GC 的方法。具体的实现来自于这篇文章:Do It Yourself (OpenJDK) Garbage Collector。Epsilon GC 仅支持对象分配,对象回收的部分提供的是空实现,因此可以认为 epsilon GC 提供了在 HotSpot 中实现 GC 的全部接口,我们给 Epsilon GC 补充了回收的部分。我们实现的 GC 具体而微,具有 GC 的完整流程,包括标记根对象、标记存活对象、移动对象、修改对象地址等;同时也足够简单清晰,不分代,GC 的全部操作在 stop the world 中进行,不需要实现 GC barriers,也不需要复杂的同步操作。

通过实现这样一个 GC 算法,可以帮助我们理解 GC 算法的核心逻辑,了解 HotSpot 中 GC 相关的数据结构,为学习其他 GC 打下基础。

步骤

本文实现的 GC 算法是一个简单的 single generation mark compact GC,即每次回收都会回收整个堆上的垃圾(相当于每次 GC 都是一次 full GC),同时会将所有存活对象按照原本在堆上的分布移动到堆的起始位置,从而完成内存的清理。

整个 GC 一共分四步,分别是标记、计算移动后的新地址、修改对象指针、移动对象。 第一步需要沿着指针关系遍历堆。同时,我们借助 HotSpot 提供的 bitmap 来标记堆上存活对象的位置,方便我们在后续步骤找到并处理这些存活对象。后续每一步我们只需要遍历 bitmap 即可,无需遍历整个堆,提高性能。

在介绍 GC 的具体实现之前,首先介绍一些 HotSpot 中与 GC 实现相关的机制。

触发 GC

通常,runtime 会提供一个内部函数,例如 allocate(),用于在 heap 上分配内存。编译器会将程序中分配对象的动作转为对 allocate() 的调用并传入对象的大小。当没有空闲内存时,即本次对象分配失败(allocation failure),需要触发 GC 进行回收。因为我们实现的是 stop-the-world GC,当分配对象遇到 allocation failure 时,暂停用户线程执行并触发 GC 即可。GC 结束后,重新尝试分配内存。若此时分配对象成功,则用户线程继续执行;若依然无法分配,则表明没有剩余内存,进程退出。据此,我们分配对象的函数如下。

c++ 复制代码
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;
}

在上图中,EpsilonSlidingGC 是我们引入的新选项,默认关闭,当打开时会启用 GC。当一次分配失败时,通过调用vmentry_collect() 触发一次 GC。GC 结束后,再次进行分配。

对于 concurrent GC,GC 触发机制就比较复杂。例如 Go 语言,GC 触发后,concurrent GC 支持用户线程和 GC 线程同时执行。那么对于 GC 触发之后,marking 结束之前的这段时间,用户线程依然会分配对象。因此,对于 concurrent GC,一般需要稍微提前触发 GC,给 concurrent marking 期间执行的用户线程分配的对象留出一些内存空间,这样才能够保证 GC marking 结束后堆内存使用不会超过阈值。至于提前多少,那就是另外一个故事了。在 Go 中,使用了 GC pacer 机制,根据对象分配和回收的速度的历史数据,来估算下一轮 GC 需要提前多久触发。

VM Operation

确定好 GC 触发机制后,接下来需要考虑如何执行 GC 的操作。在 HotSpot 中,我们借助 VM operation 机制来发起 GC。

HotSpot JVM 使用 VMThread 来执行虚拟机内部的操作,例如处理 GC 请求、处理 heapdump、处理 class re-definition 等等。VM thread 可以看做 JVM 实例的守护线程,一个 JVM 实例只有一个 VMThread。许多语言都有类似的设计,例如 Go 的 sysmon goroutine。这些请求被包装成一个个 VM operation,每个 VM operation 可以看作是一个闭包,由用户线程投递给 VMThread,VMThread 等待获取 VM operation 并执行。

在上面的代码片段中,我们调用 vmentry_collect() 发起 GC 请求。vmentry_collect() 的实现如下。

c++ 复制代码
void EpsilonHeap::vmentry_collect(GCCause::Cause cause) {
  VM_EpsilonCollect vmop(cause);
  VMThread::execute(&vmop);
}

可以看到,我们创建了一个 VM_EpsilonCollect 的对象,并交给 VMThread 执行。由于分配对象的线程是 Java 线程,因此,执行 VMThread::execute() 的是 JavaThread。接下来,在 wait_until_executed() 中,将 VMThread::_next_vm_operation 设置为当前 VM operation,且 JavaThread 会一直阻塞,直到当前 VM operation 执行结束。

c++ 复制代码
void VMThread::execute(VM_Operation* op) {
  ...
  op->set_calling_thread(t);
  wait_until_executed(op);
  op->doit_epilogue();
}

真正执行 VM operation 的是 VMThread。VMThread 启动后,会一直执行 VMThread::loop(),等待处理 VM operation。VM thread 拿到一个 VM operation,调用 inner_execute() 进行处理。注意,下面的代码是由 VMThread 执行的。

c++ 复制代码
void VMThread::loop() {
  ...
  while (true) {
    if (should_terminate()) break;
    wait_for_operation();
    if (should_terminate()) break;
    assert(_next_vm_operation != nullptr, "Must have one");
    inner_execute(_next_vm_operation);
  }
}

在执行 VM operation 时,若 VM operation 需要进入 safepoint 进行执行,则通过调用 SafepointSynchronize::begin() 停下所有 Java 线程的执行,让 JVM 进入可以安全处理的状态。更具体地,如果当前线程正在执行解释器的代码,则 JavaThread 会在当前 bytecode 执行完成后进入 safepoint;若执行的是 JIT 编译代码,会在进行 polling page 的位置进入 safepoint,如循环回边,方法返回前等;若在执行 VM 代码,则当前线程不会暂停,等从 VM 中返回后,进入 safepoint;若在执行非 VM 代码的 native code,则当前线程不会暂停,直到 native code 通过 JNI 访问 Java 层的数据或从 native code 中返回。进入 safepoint 在 HotSpot 中是个比较复杂的话题,具体细节可以参考 SafepointSynchronize::arm_safepoint() 前的注释。

c++ 复制代码
void VMThread::inner_execute(VM_Operation* op) {
  ...
  if (_cur_vm_operation->evaluate_at_safepoint() &&
      !SafepointSynchronize::is_at_safepoint()) {
    SafepointSynchronize::begin();
    ...
    end_safepoint = true;
  }

  evaluate_operation(_cur_vm_operation);
  ...
  if (end_safepoint) {
    ...
    SafepointSynchronize::end();
  }
  ...
}

既然提到了 safepoint,我们从 runtime 的设计和实现层面讲讲 safepoint 的意义。Safepoint 是 runtime 暂停用户线程并可以安全执行各项操作的程序点。逻辑上讲,有 safepoint,那么就一定有 unsafepoint。从 OS 的角度看,OS 可以随时暂停进程中的任何线程并随时恢复,且这些操作对线程是透明的。因此,从 OS 角度来看,程序中任意的位置都是 safepoint。

那么从 runtime 的角度看,哪些地方是 unsafepoint 呢?简单来说,runtime 是由一堆状态(数据)和一堆等待被调用的代码组成的。Runtime 的代码知晓这些数据的含义(例如这些数据是 klasses,这些是 code cache),并用 runtime 的代码维护这些数据,且在维护过程中必须保证数据的完整性和正确性,否则程序就会出错。若 runtime 的代码在执行时无法保证数据的完整性,例如两个用户线程同时调用 runtime 的函数修改数据,但不能很简单地确定这种操作是否安全,那么可以保守地认为这些地方是 "unsafepoint",反之,就是 "safepoint"。因为 OS 只是挂起和恢复线程的执行,并不会修改其内部的状态,因此可以在任意位置暂停和恢复。

因此,大致上来看,用户代码中绝大多数地方都是 safepoint,因为用户代码通常不会直接修改 runtime 的数据;而 runtime 代码中大多数地方都是 unsafepoint。为了方便,我们挑选一些用户代码中的位置作为 safepoint;因为较难确认 runtime 函数中的位置是不是安全的,因此通常需要等待 runtime 函数执行完毕后,才会尝试进入 safepoint。其他语言也有类似的机制,例如 Go runtime 支持 asynchronous preemption,会在 safepoint 抢占当前 goroutine 的执行。

以上这些是我对 runtime safepoint 机制的一些思考。

言归正传。进入 safepoint 之后,调用 evaluate_operation() 真正执行 VM operation 的操作,最终,调用 VM_Operation::doit() 执行 VM operation 的具体操作。注意,这里的函数执行依然在 VMThread 上,因此,我们实现的 GC 的对象标记和回收是由 VMThread 执行的,而不是 GC workers。这么做只是实现简单,并不是个好的实现,因为 VMThread 的主要工作是进行同步并将具体的操作转发给对应的线程而不是自己处理。

c++ 复制代码
void VMThread::evaluate_operation(VM_Operation* op) {
  ...
  op->evaluate();
  ...
}

void VM_Operation::evaluate() {
  ...
  doit();
  ...
}

执行完毕后,调用 SafepointSynchronize::end() 恢复程序的执行,VM operation 执行结束。

以上就是用户线程发起 GC 请求的过程。用户线程分配对象失败后,以 VM operation 的形式向 VM 请求进行 GC。VMThread 收到 VM operation 后,根据需求让 VM 进入 safepoint,同时 VMThread 执行 VM operation 的具体操作。对于 GC 来说,就是触发一次对象标记和回收。执行完毕后,VMThread 恢复用户线程的执行,并再次进行对象分配。

接下来,我们进入 GC 算法的实现。我们实现的 GC 算法总共分四步:标记、计算移动对象后的新地址、修改对象中的指针、移动对象。

标记

标记是从根对象出发,沿着对象的引用关系,找出所有存活对象的过程。让我们先大致梳理一下 GC marking 需要做什么。

大致思路

首先是找到根对象。在 Java 中,根对象包括:类的 static 变量、class loader 对象、栈上的对象、JIT 编译生成的代码中的对象等。根对象的范围是明确的,VM 会维护这些对象的位置,因此可以方便地找到他们。

其次是给存活的对象打上标记。被标记到的对象,我们需要设置相应的标记,表示该对象存活。一种方式是在对象头中设置标记,不占用额外的空间。但是标记过后,我们无法快速找到已经标记的对象。

另一种方式是,分配额外的内存空间作为对象标记的 bitmap,并将 heap 空间一一映射到 bitmap 上。标记到的对象在 bitmap 对应位置设置为 1,未标记的为 0。假设对象是 8B 对齐,则对象的起始位置均是 8 的整数倍,那么 可以将 heap 中的 8 B 整体映射到 bitmap 中的 1 bit 上,那么对于 1 GB 的 heap,我们需要分配的 bitmap 的大小是 1 GB / 8 = 128 Mb = 16 MB,memory overhead 约为 1.6%。Heap 和 bitmap 的一一映射关系可以让我们完成标记后遍历 bitmap 即可找到存活对象,无需遍历 heap。根据对象标记在 bitmap 中的偏移和 heap 的起始地址就可以计算出其对应的对象的地址,反之亦然。

接下来是沿着对象引用关系遍历整个对象图。我们用 worklist 组织遍历算法。首先,将根对象的指针加入 worklist(这些对象是灰色的);接下来,从 worklist 中取出对象指针,若对象未被标记过,则根据其类型找到对象中的指针,并将这些指针加入 worklist(被扫描的对象从灰色变为黑色,新找到的对象从白色变为灰色);若已经标记过,则跳过,从 worklist 获取新的指针,直到 worklist 为空,标记完成。

以上是 GC 标记的大致思路,接下来,我们看看更具体地,在 HotSpot 中如何组织对象标记的流程。

标记对象

在 HotSpot 中,不同 GC 标记对象的具体动作被抽象成为闭包。我们的 GC 标记对象的闭包如下。

c++ 复制代码
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.
    T o = RawAccess<>::oop_load(p);
    if (!CompressedOops::is_null(o)) {
      oop obj = CompressedOops::decode_not_null(o);
      if (!_bitmap->is_marked(obj)) {
        _bitmap->mark((HeapWord*)obj);
        _stack->push(obj);
      }
    }
  }
...
};

我们用 EpsilonScanOopClosure 来抽象标记对象的操作。在标记过程中,使用 HotSpot 内建数据结构 MarkBitMap 保存对象标记,用 EpsilonMarkStack 最为 worklist 进行对象图遍历。函数 do_oop_work() 进行对象标记,其中参数 p 是指向 oop 的指针,oop 是指向对象的指针。因为有时我们需要修改 oop 的值(例如移动对象),所以我们在 GC 时需要指向 oop 的指针。

拿到指向 oop 的指针后,首先 de-reference 获取 oop。若使用了 compressed oop,则需要根据指针压缩规则计算出对象真实的地址。简单的情况是将 compressed oop 左移三位(对象按照 8 B 对齐)再加上 heap base(通常是 0),即可获得对象真实地址。

接下来,若对象未被标记,则标记,并将该 oop 放入 worklist,等待下一轮扫描。

标记根对象

HotSpot 将标记根对象的操作抽象成了几个函数,我们只需将标记对象的闭包传入即可,代码如下。

c++ 复制代码
void EpsilonHeap::entry_collect(GCCause::Cause cause) {
  ...
  {
    GCTraceTime(Info, gc) time("Step 1: Mark", NULL);
    EpsilonMarkStack stack;
    EpsilonScanOopClosure cl(&stack, &_bitmap);
    process_roots(&cl);
  }
  ...
}

void EpsilonHeap::do_roots(OopClosure *cl) {
  StrongRootsScope scope(1);
  CLDToOopClosure clds(cl, ClassLoaderData::_claim_none);
  MarkingCodeBlobClosure blobs(cl, CodeBlobToOopClosure::FixRelocations, true);
  ...
  ClassLoaderDataGraph::cld_do(&clds);
  OopStorageSet::strong_oops_do(cl);
  WeakProcessor::oops_do(cl);
  Threads::possibly_parallel_oops_do(false, cl, &blobs);
}

entry_collect() 是 GC 的入口函数。process_roots() 会调用 do_roots() 并用 EpsilonScanOopClosure 处理根对象。

对象标记

根对象处理完毕后,就可以进行正式的标记了,代码如下。

c++ 复制代码
void EpsilonHeap::entry_collect(GCCause::Cause cause) {
  ...
  {
    GCTraceTime(Info, gc) time("Step 1: Mark", NULL);
    EpsilonMarkStack stack;
    EpsilonScanOopClosure cl(&stack, &_bitmap);
    process_roots(&cl);
    stat_reachable_roots = stack.size();

    while (!stack.is_empty()) {
      oop obj = stack.pop();
      obj->oop_iterate(&cl);
      stat_reachable_heap++;
    }
    ...
  }
}

从 worklist 中取出 oop,oop_iterate() 会根据对象的类型,扫描其中的包含指针的 fields,并用 EpsilonScanOopClosure 处理。新扫描到的指针,EpsilonScanOopClosure 会将其放入 worklist。不断从 worklist 中取出 oop,直到 worklist 为空,标记完成。

标记结束后,我们找到了所有存活的对象并都标记在 bitmap 中。

计算移动后的地址

经过对象标记后,我们找到了 heap 中所有存活的对象。接下来,我们将这些对象移动到 heap 的起始位置。首先需要计算他们在移动之后的地址。

因为我们在对象标记过程中,在 bitmap 中标记了所有存活对象,且 bitmap 和 heap 是一一对应的,因此我们在后续的遍历只需要遍历 bitmap 找到被标记的 bit,并根据 bit 在 bitmap 中的偏移、对象对齐规则和 heap 的起始地址计算该 bit 对应的对象地址,直接处理对象即可,无需多次遍历 heap。

我们用 EpsilonCalcNewLocationObjectClosure 来抽象计算对象移动后地址的逻辑,代码如下。

c++ 复制代码
class EpsilonCalcNewLocationObjectClosure : public ObjectClosure {
public:
  EpsilonCalcNewLocationObjectClosure(HeapWord* start, PreservedMarks* pm) :
                                      _compact_point(start), _preserved_marks(pm) {}

  void do_object(oop obj) {
    if ((HeapWord*)obj != _compact_point) {
      markWord mark = obj->mark();
      if (obj->mark_must_be_preserved(mark)) {
        _preserved_marks->push_always(obj, mark);
      }
      obj->forward_to(oop(_compact_point));
    }
    _compact_point += obj->size();
  }
};

有了对象标记的经验,重新计算地址的闭包就比较好理解了。_compact_point 移动对象的目的地,一开始是 heap 的起始位置。我们会把对象移动后的新地址作为 forwarding pointer 保存到对象头的 markword 上。也有些 GC 会把 forwarding pointer 放在独立的 forwarding table 中,例如 ZGC。移动对象 GC 在调整对象指针时会用到 forwarding pointers。举个例子,假设 o.f 指向对象 a,若对象 a 已经被移动,对于移动对象 GC,当 GC 遍历到 o.f 时,发现 a 的 markword 处于 forwarded 状态,表明 a 已经被移动,那么直接将 o.f 修改为 a markword 中保存的地址,即 forwardee,就完成了指针的更新。当所有对象处理完毕后,所有的指针均指向移动后的对象,带有 forwarding pointers 的旧对象不会有其他对象引用,可以直接回收。

回到上面的代码中。拿到 oop 后,首先获取对象的 markword。因为需要在 markword 中填充 forwarding pointer,会覆盖 markword 之前的内容,因此需要确定 markword 是否可以被安全覆盖。若不行,我们用 HotSpot 内建数据结构 PreservedMarks 来保存对象移动前的旧地址和旧的 markword,再覆盖 markword。对象移动完毕后,再在新对象上恢复旧的 markword,保证 markword 在对象移动前后的一致性。

接下来,计算移动后对象的地址。整个地址计算过程类似 bump allocation。_compact_point 表示当前对象移动后的起始地址,将当前对象 forwarding 到 _compact_point,再根据当前对象的大小将 _compact_point 移动到下一个位置,即完成对当前对象移动后地址的计算。

因为我们是从 heap 头到尾找寻存活对象并计算新地址,这种移动对象的方式相当于将所有存活对象"滑动"到 heap 的开头,保证存活对象在内存中的分配时的布局,这也是这个 GC 选项中 "sliding" 的含义。

接下来的工作是通过遍历 bitmap 找到所有存活对象并用 EpsilonCalcNewLocationObjectClosure 处理。遍历 bitmap 的代码如下。

c++ 复制代码
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);
    }
  }
}

代码逻辑很清晰,获取 bitmap 中 bit 对应的对象的地址,并用 EpsilonCalcNewLocationObjectClosure 处理对象。

处理完成后,所有对象的新地址都保存在其 markword 中。

修改对象指针

计算完对象的新地址后,指向根对象的指针和对象内部的指针还是指向对象原本的位置,因此我们需要将这些指针调整到指向移动后对象的新位置。这一步需要再次遍历 bitmap。

我们用 EpsilonAdjustPointersOopClosure 来抽象修改对象地址的操作,代码如下。

c++ 复制代码
class EpsilonAdjustPointersOopClosure : public BasicOopIterateClosure {
private:
  template<class T>
  void do_oop_work(T* p) {
    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, "sanity");
        RawAccess<>::oop_store(p, fwd);
      }
    }
  }
...
};

拿到指向 oop 的指针后,de-reference 后获得 oop,此时 oop 依然指向移动前的对象,移动后的新地址位于对象的 markword 上。获取到 oop 后,若对象 markword 被 forwarded,则取出 forwarding pointer,并更新指向对象的指针,完成指针的调整。

下面是调整对象指针的代码。首先,遍历 bitmap,将 EpsilonAdjustPointersObjectClosure 传入。EpsilonAdjustPointersObjectClosure 针对每个对象会处理其 fields,将 fields 的指针使用 EpsilonAdjustPointersOopClosure 调整到指向移动对象后的地址。这样,heap 中的所有对象中的指针已经指向移动后的位置。

c++ 复制代码
{
  GCTraceTime(Info, gc) time("Step 3: Adjust pointers", NULL);
  EpsilonAdjustPointersObjectClosure cl;
  walk_bitmap(&cl);

  EpsilonAdjustPointersOopClosure oopClosure;
  process_roots(&oopClosure);

  preserved_marks.adjust_during_full_gc();
};

class EpsilonAdjustPointersObjectClosure : public ObjectClosure {
private:
  EpsilonAdjustPointersOopClosure _cl;
public:
  void do_object(oop obj) {
    obj->oop_iterate(&_cl);
  }
};

处理完 heap 后,还需要调整 GC roots 的指针。调用 process_roots() 并传入 EpsilonAdjustPointersOopClosure 进行调整即可。

最后,我们在第二步计算对象新地址时,使用 PreservedMarks 处理 markword 不能被直接覆盖的情况。preserved_marks 中保存的是对象移动前的地址和被 forwarding pointer 覆盖前的 markword。preserved_marks.adjust_during_full_gc() 会根据对象的 forwarding pointers,将其中保存的对象移动前的地址修改为移动后的地址,方便在对象移动完成后在移动后的对象上恢复原本的 markword。

处理完成后,所有的指针均指向了移动后的位置。

移动对象

最后一步就是真正移动对象了。这一步我们最后一次遍历 bitmap,找到所有存活对象,根据 forwarding pointer 即移动后的地址,将对象移动到新位置。对象移动完成后,原先的对象不会被引用,可以被清理。

我们用 EpsilonMoveObjectsObjectClosure 抽象移动对象的操作,代码如下。

c++ 复制代码
class EpsilonMoveObjectsObjectClosure : public ObjectClosure {
private:
  size_t _moved;
  ...
public:
  EpsilonMoveObjectsObjectClosure() : _moved(0) {}
  void do_object(oop obj) {
    if (obj->is_forwarded()) {
      oop fwd = obj->forwardee();
      assert(fwd != NULL, "sanity");
      _moved_size += obj->size();
      Copy::aligned_conjoint_words((HeapWord*)obj, (HeapWord*)fwd, obj->size());
      fwd->init_mark();
      _moved++;
    }
  }
...
};

拿到 oop 后,通过 forwardee() 获取 forwarding pointer,调用 aligned_conjoint_words() 将对象从当前位置复制到 forwarding pointer 指向的位置,复制的大小为对象的大小。这里注意一点,对象复制前后的内存空间有可能重叠,因此我们不能调用 aligned_disjoint_words() 来复制对象。此时,新对象的 markword 依然保存的是 forwarding pointer,调用 init_mark() 清除新对象 markword 的 forwarding pointer。此外,在在复制对象过程中,_moved_size 记录复制对象的总大小,_moved 记录对象的个数。

复制对象的过程如下。调用 walk_bitmap() 并传入 EpsilonMoveObjectsObjectClosure 复制 heap 中的存活对象。

c++ 复制代码
{
  GCTraceTime(Info, gc) time("Step 4: Move objects", NULL);
  EpsilonMoveObjectsObjectClosure cl;
  walk_bitmap(&cl);
  stat_moved = cl.moved();
  stat_moved_size = cl.moved_size();

  _space->set_top(new_top);
};

复制完成后,调用 preserved_marks.restore() 恢复 markword 不能被覆盖的对象的 markword,整个 GC 过程结束。

测试

我们使用 Spring PetClinic 测试,看我们的 GC 能否支持一个简单的 Spring 服务启动。为了测试 GC,可以适当把 heap size 调小一些,从而多次触发 GC。

github.com/spring-proj... clone PetClinic 并编译,用下面的选项启动程序。

ruby 复制代码
java -Xms500m -Xmx500m -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+EpsilonSlidingGC -Xlog:gc -jar target/*.jar

启动后,会在控制台打印 GC log,如下。

less 复制代码
[2.667s][info   ][gc     ] GC(0) Step 0: Prologue 0.011ms
[2.691s][info   ][gc     ] GC(0) Step 1: Mark 23.584ms
[2.697s][info   ][gc     ] GC(0) Step 2: Calculate new locations 6.228ms
[2.714s][info   ][gc     ] GC(0) Step 3: Adjust pointers 16.858ms
[2.723s][info   ][gc     ] GC(0) Step 4: Move objects 8.720ms
[2.723s][info   ][gc     ] GC(0) GC Stats: 20633 (6.98%) reachable from roots, 275165 (93.02%) reachable from heap, 254142 (85.92%) moved, size 11104 KB, 7568 (2.56%) markwords preserved
[2.723s][info   ][gc     ] GC(0) Heap: 500M reserved, 500M (100.00%) committed, 12014K (2.35%) used
[2.723s][info   ][gc     ] GC(0) Step 5: Epilogue 0.113ms
[2.723s][info   ][gc     ] GC(0) Lisp2-style Mark-Compact (Allocation Failure) 496M->11M(500M) 55.708ms

可以看到,我们的 GC 从标记到完成共耗时 55 ms,移动了约 11 MB 的对象,其实这个 GC 的效率很低,也有非常大的优化空间,但作为一个 GC 的 demo 已经足够。

最后的话

我在读 HotSpot 的 GC 代码时,特别是一些比较复杂的 GC,例如 ZGC,经常感觉抓不住主干,读着读着就陷入许多细节之中,常常放弃。我日常的工作也在内存管理的领域,写过 copying GC, mark-sweep GC,但没接触过 mark-compact GC 和更复杂的 concurrent GC。基于这两点,无意中看到了 Aleksey Shipilëv 借助 Epsilon GC 提供的框架来实现一个简单的 mark-compact GC(当然这个框架也是他实现的),当即决定把这个小项目实现一遍。HotSpot 博大精深,文章中定有许多不准确的地方,代码也有 bug,劳烦大家批评指正。

实现完以后,最大的感触是,OpenJDK 经过几十年的发展,已经变得越来越成熟。整个项目的架构已经被整理得非常清晰,GC 部分抽象出来了许多常用的非原子操作,例如 GC logging,用 MarkBitMap 标记存活对象,用 EpsilonMarkStack 保存被标记到的 oop,用 PreservedMarks 处理需要保存的 markword 等等,让实现简单的 GC 逻辑变得很轻松,有搭积木的感觉。

希望有朝一日,我们也能做出这样扎实的工业级产品。

参考

Do It Yourself (OpenJDK) Garbage Collector

JEP 318: Epsilon: A No-Op Garbage Collector (Experimental)

The Garbage Collection Handbook

OpenJDK

相关推荐
Ray Wang13 小时前
3.JVM
jvm
java6666688881 天前
Java中的对象生命周期管理:从Spring Bean到JVM对象的深度解析
java·jvm·spring
生产队队长1 天前
JVM(HotSpot):字符串常量池(StringTable)
jvm
Yuan_o_1 天前
JVM(Java Virtual Machine) 详解
jvm
派大星-?1 天前
JVM内存回收机制
jvm
G丶AEOM2 天前
Hotspot是什么?
jvm·hotspot
太阳伞下的阿呆2 天前
Java内存布局
jvm·内存对齐·内存布局·压缩指针
Tech Synapse2 天前
Java如何解决同时出库入库订单号自动获取问题
java·jvm·oracle
hiyo5852 天前
C#类的概念
java·jvm·c#
pumpkin845143 天前
JVM类数据共享(CDS)
java·jvm