涨薪5K的Java虚拟机:垃圾回收,Serial GC,卡表你想学吗?

366 篇文章 2 订阅
232 篇文章 5 订阅

Serial GC

文章首发公众号:Java架构师联盟,每日更新技术好文

弱分代假说

Serial GC是最经典也是最古老的垃圾回收器,使用-XX:+UseSerialGC开启。它的Java堆符合弱分代假说(Weak GenerationalHypothesis)。弱分代假说的含义是大多数对象都在年轻时死亡,这个假说已经在各种不同编程范式或者编程语言中得到证实。与之相对的是强分代假说,它的含义是越老的对象越不容易死亡,但是支持该假说的证据稍显不足。人们注意到大部分对象的生命周期都非常短暂,这符合认知,因为在方法中分配局部变量几乎是最常见的操作,这些对象很多是用后即弃。

基于弱分代假说,虚拟机实现了分代堆模型,它将Java堆分为空间较大的老年代(Old Generation)和空间较小的新生代(YoungGeneration)。其中新生代容纳朝生夕死的新对象,在此区域发生垃圾回收较为频繁,老年代容纳生命周期较长的对象,可以简单认为多次垃圾回收后仍然存活的对象生命周期较长。老年代增长缓慢,因此发生垃圾回收的频率较低,这样的堆被称为分代堆。在分代堆模型中,GC的工作不再是面向整个堆,而是“专代专收”,Young GC(以下简称YGC)只回收新生代,Full GC(以下简称FGC)回收整个堆。YGC的出现使得GC无须遍历整个堆寻找存活对象,同时降低了老年代回收的频率。

分代堆受益于对象生命周期的区分,但是也受桎于它。之前只需要遍历整个堆即可找出所有存活对象,分代后却不能简单遍历单个分代,因为可能存在老年代指向新生代的引用,即跨代引用。如果只遍历新生代可能会错误标记一些本来存在引用的对象,继而杀死,而垃圾回收的原则是“宁可漏过不可错杀”,错误地清理存活对象是绝对不可以的。现在的问题是分代后新生代对象除了被GC Root引用外还会被老年代跨代引用,如果要遍历空间较大的老年代和GC Root才能找出新生代的存活对象,那么就失去了分代的优势,得不偿失。

跨代引用是所有分代式垃圾回收器必须面对的问题,为了处理跨代引用问题,需要一种名为记忆集(Remember Set,RSet)的数据结构来记录老年代指向新生代的引用。另一个问题是老年代很多对象可能已经实际死去,如果老年代死亡对象没有及时清理,新生代回收时会将GCRoot和这些老年代中已经死亡的对象当作根来寻找存活对象,导致本该死亡的新生代对象也被标记为存活对象,由此产生浮动垃圾,极端情况下浮动垃圾会抵消堆分代带来的收益。

记忆集暗示着GC拥有发现每个写对象操作的能力,每当对象写操作发生时,GC会检查被写入对象是否位于不同分代并据此决定是否放入记忆集。赋予GC这种“发现所有写对象操作”能力的组件是GC屏障,具体到上下文中是写屏障。写对象操作属于代码执行系统的一部分,由GC屏障与JIT编译器、模板解释器合力完成。

在Serial GC中,FGC遍历整个堆,不需要考虑跨代引用,YGC只发生在新生代,需要处理跨代引用问题。Serial GC使用的是一种名为卡表的粗粒度的记忆集,下面将展开具体介绍。

卡表

卡表(Card Table)是一种可以存储跨代引用的粗粒度的记忆集,它没有精确记录老年代中指向新生代的对象和引用,而是将老年代划分为2次幂大小的一些内存页,记录它们所在的内存页。用卡表来映射这些页,减少了记忆集本身的内存开销,同时也尽量避免了整个老年代的遍历。标准的卡表实现通常为一个bitmap,它的每个bit对应一片内存页。如图10-2所示。

涨薪5K的深入解析java虚拟机:垃圾回收,Serial GC,你想学吗?

图10-2 Card Table

当Mutator线程执行类成员变量赋值操作时,虚拟机会检查是否将一个老年代对象或引用赋值给新生代成员,如果是,则对成员变量所在内存页对应的卡表中的bit进行标记,后续只需要遍历卡表中标记过的bit对应的内存页,而无须遍历整个老年代。

不过使用bitmap可能会相当慢,因为对bitmap其中一个bit标记时,需要读取整个机器字,更新,然后写回,另外在RISC处理器上执行bit操作也需要数条指令。一个有效的性能改进是使用byte数组代替bitmap,虽然byte数组使用的内存是bitmap的8倍,但是总的内存占比仍然小于堆的1%。HotSpot VM的卡表由CardTable实现,它使用byte数组而非bitmap,CardTable::byte_for函数负责内存地址到卡表byte数组的映射,如代码清单10-7所示:

代码清单10-7 CardTable::byte_for

jbyte* CardTable::byte_for(const void* p) const {
jbyte* result = &_byte_map_base[uintptr_t(p) >> card_shift];
return result;
}

其中card_shift为9。从实现中不难看出虚拟机将一片内存页定义为512字节,每当某个内存页存在跨代引用时就将byte_map_base数组对应的项标记为dirty。

Young GC

Serial GC将新生代命名为DefNewGeneration,将老年代命名为TenuredGeneration。DefNewGeneration又将新生代划分为Eden空间和Survivor空间,而Survivor空间又可进一步划分为From、To空间,如图10-3所示。

涨薪5K的深入解析java虚拟机:垃圾回收,Serial GC,你想学吗?

图10-3 Serial GC新生代细节

YGC使用复制算法清理新生代空间。关于YGC的一个常见场景是起初在Eden空间分配小对象,当Eden空间不足时发生YGC,此时Eden空间和From空间的存活对象被标记,接着虚拟机将两个空间的存活对象转移到To空间,如果To空间不能容纳对象,那么会转移到老年代。如果To空间能够容纳对象,Eden空间和From空间清空,From空间和To空间交换角色,此时存在一个空的Eden空间、存在部分存活对象的From空间以及空的To空间,当下次YGC发生时,重复上述步骤。

当一些对象在多次YGC后仍然存活时,可以认为该对象生命周期较长,不属于朝生夕死的对象,所以GC会晋升该对象,将其从新生代的对象晋升到老年代。除了上述提到的普通情况外,还有一些特殊情况需要考虑,如起初Eden空间无法容纳大对象,老年代无法容纳晋升对象等。完整YGC逻辑的实现过程如代码清单10-8所示,它也包括了特殊清空的处理:

代码清单10-8 DefNewGeneration::collect

void DefNewGeneration::collect(...) {...
if (!collection_attempt_is_safe()) {// 检查老年代是否能容纳晋升对象
heap->set_incremental_collection_failed();
return;
}
FastScanClosure fsc_with_no_gc_barrier(...);
FastScanClosure fsc_with_gc_barrier(...);
CLDScanClosure cld_scan_closure(...);
FastEvacuateFollowersClosure evacuate_followers(...);
{ // 从GC Root出发扫描存活对象
StrongRootsScope srs(0);
heap->young_process_roots(&srs, &fsc_with_no_gc_barrier,
&fsc_with_gc_barrier, &cld_scan_closure);
}
evacuate_followers.do_void();// 处理非GC Root直达、成员字段可达的对象
... // 特殊处理软引用、弱引用、虚引用、final引用
// 如果可以晋升,则清空Eden、From空间;交换From、To空间;调整老年代晋升阈值
if (!_promotion_failed) {
eden()->clear(SpaceDecorator::Mangle);
from()->clear(SpaceDecorator::Mangle);
swap_spaces();
} else {
// 否则通知老年代晋升失败,仍然交换From和To空间
swap_spaces();
_old_gen->promotion_failure_occurred();
}
...}

在做YGC之前需检查此次垃圾回收是否安全(
collection_attempt_is_safe)。所谓是否安全是要判断在新生代全是需要晋升的存活对象的最坏情况下,老年代能否安全容纳这些新生代。如果可以再继续做YGC。

young_process_roots()会扫描所有类型的GC Root,并扫描卡表记忆集找出老年代指向新生代的引用,然后使用快速扫描闭包将它们复制到To空间。快速扫描闭包即FastScanClosure,它将针对一个对象(线程、对象、klass等)的操作抽象成闭包操作,然后传递到处理连续对象的逻辑代码中。由于HotSpot VM使用的C++ 98语言标准没有lambda表达式,所以只能使用类模拟出闭包[1]。FastScanClosure闭包如代码清单10-9所示:

代码清单10-9 FastScanClosure闭包

template <class T> inline void FastScanClosure::do_oop_work(T* p) {
// 从地址p处获取对象
T heap_oop = RawAccess<>::oop_load(p);
if (!CompressedOops::is_null(heap_oop)) {
oop obj = CompressedOops::decode_not_null(heap_oop);
// 如果对象位于新生代
if ((HeapWord*)obj < _boundary) {
// 如果对象有转发指针,相当于已复制过,那么可以直接使用已经复制后的对象,否则
// 需要复制oop new_obj = obj->is_forwarded()
?obj->forwardee(): _g->copy_to_survivor_space(obj);
RawAccess<IS_NOT_NULL>::oop_store(p, new_obj);
if (is_scanning_a_cld()) { // 根据情况设置gc_barrier
do_cld_barrier();
} else if (_gc_barrier) {
do_barrier(p);
}
}
}
}

从GC Root和老年代出发,所有能达到的对象都是活对象,FastScanClosure会应用到每个活对象上。如果遇到已经设置了转发指针的对象,即已经复制过的,则直接返回复制后的对象,否则使用如代码清单10-10所示的copy_to_survivor_space进行复制:

代码清单10-10 copy_to_survivor_space

oop DefNewGeneration::copy_to_survivor_space(oop old) {
size_t s = old->size();
oop obj = NULL;
// 在To空间分配对象
if (old->age() < tenuring_threshold()) {
obj = (oop) to()->allocate_aligned(s);}
// To空间分配失败,在老年代分配
if (obj == NULL) {
obj = _old_gen->promote(old, s);
if (obj == NULL) {
handle_promotion_failure(old);
return old;
}
} else {
// To空间分配成功
const intx interval = PrefetchCopyIntervalInBytes;
Prefetch::write(obj, interval); // 预取到缓存
// 将对象复制到To空间
Copy::aligned_disjoint_words((HeapWord*)old,(HeapWord*)obj,s);
// 对象年龄增加
obj->incr_age();
age_table()->add(obj, s);
}
// 在对象头插入转发指针(使用新对象地址代替之前的对象地址,并设置对象头GC bit)
old->forward_to(obj);
return obj;
}

copy_to_survivor_space()视情况将对象复制到To空间或者晋升到老年代,然后为老对象设置新对象地址,即可转发指针(ForwardingPointer)。设置转发指针的意义在于GC Root可能存在两个指向相同对象的槽位,如果简单移动对象,并将槽位修改为新的对象地址,第二个GC Root槽位就会访问到错误的老对象地址,而设置转发指针后,后续对老对象的访问将转发到正确的新对象上。

上述过程会触碰到GC Root和老年代出发直接可达的对象,并将它们移动到To空间(或者晋升老年代),这些移动后的对象可能包含引用字段,即可能间接可达其他对象。Serial GC维护一个save_mark指针和已分配空间顶部(to()->top())指针,To空间底部到save_mark的区域中的对象表示自身和自身字段都扫描完成的对象,save_mark到空间顶部的区域中的对象表示自身扫描完成但是自身字段未完成的对象。

FastEvacuateFollowersClosure的任务就是扫描save_mark到空间顶部的对象,遍历它们的字段,并将这些能达到的对象移动到空间底部到save_mark的区域,然后向前推进save_mark,直到save_mark等于空间顶部,扫描完成。

由于新生代对象可能移动到To空间,也可能晋升到老年代,所以上述逻辑对于老年代也同样适用。

Full GC

由于历史原因,FGC的实现位于serial/genMarkSweep。虽然从名字上看SerialGC的FGC的实现似乎是基于标记清除算法,但是实际上FGC是基于标记压缩算法实现,如图10-4所示。

涨薪5K的深入解析java虚拟机:垃圾回收,Serial GC,你想学吗?

图10-4 Serial GC

FGC使用的标记整理算法是基于Donald E. Knuth提出的Lisp2算法:首先标记(Mark)存活对象,然后把所有存活对象移动(Compact)到空间的一端。FGC始于
TenuredGeneration::collect,它会在GC前后记录一些日志,可以使用-Xlog:gc*输出这些日志,如代码清单10-11所示:

代码清单10-11 FGC日志

GC(1) Phase 1: Mark live objects
GC(1) Phase 2: Compute new object addresses
GC(1) Phase 3: Adjust pointers
GC(1) Phase 4: Move objects

日志显示FGC过程分为四个阶段,如图10-5所示。

涨薪5K的深入解析java虚拟机:垃圾回收,Serial GC,你想学吗?

图10-5 Serial GC的FGC的四个阶段

1. 标记存活对象(Mark Live Object)

第一阶段虚拟机遍历所有类型的GC Root,然后使用XX::oops_do(root_closure)从该GC Root出发标记所有存活对象。XX表示GC Root类型,root_closure表示标记存活对象的闭包。root_closure即
MarkSweep::FollowRootClosure闭包,给它一个对象,就能标记这个对象、标记迭代标记对象的成员,以及标记对象所在的栈的所有对象及其成员,如代码清单10-12所示:

代码清单10-12 标记存活对象

template <class T> inline void MarkSweep::follow_root(T* p) {
// 如果引用指向的对象不为空且未标记
T heap_oop = RawAccess<>::oop_load(p);
if (!CompressedOops::is_null(heap_oop)) {
oop obj = CompressedOops::decode_not_null(heap_oop);
if (!obj->mark_raw()->is_marked()) {
mark_object(obj); // 标记对象
follow_object(obj); // 标记对象的成员
}
}
follow_stack(); // 标记引用所在栈
}
// 如果对象是数组对象则标记数组,否则标记对象的成员inline void MarkSweep::follow_object(oop obj) {
if (obj->is_objArray()) {
MarkSweep::follow_array((objArrayOop)obj);
} else {
obj->oop_iterate(&mark_and_push_closure);
}
}
void MarkSweep::follow_stack() { // 标记引用所在的整个栈
do {
// 如果待标记栈不为空则逐个标记
while (!_marking_stack.is_empty()) {
oop obj = _marking_stack.pop();
follow_object(obj);
}
// 如果对象数组栈不为空则逐个标记
if (!_objarray_stack.is_empty()) {
ObjArrayTask task = _objarray_stack.pop();
follow_array_chunk(objArrayOop(task.obj()), task.index());
}
}while(!_marking_stack.is_empty()||!_objarray_stack.is_empty());
}
// 标记数组的类型的Class和数组成员,比如String[] p = new String[2]
// 对p标记会同时标记java.lang.Class,p[1],p[2]
inline void MarkSweep::follow_array(objArrayOop array) {
MarkSweep::follow_klass(array->klass());
if (array->length() > 0) {
MarkSweep::push_objarray(array, 0);}
}

2. 计算对象新地址(Compute New Object Address)

标记完所有存活对象后,Serial GC会为存活对象计算出新的地址,然后存放在对象头中,为接下来的对象整理(Compact)做准备。计算对象新地址的思想是先设置cur_obj和compact_top指向空间底部,然后从空间底部开始扫描,如果cur_obj扫描到存活对象,则将该对象的新地址设置为compact_top,然后继续扫描,重复上述操作,直至cur_obj到达空间顶部。

3. 调整对象指针(Adjust Pointer)

虽然计算出了对象新地址,但是GC Root指向的仍然是老对象,同时对象成员引用的也是老的对象地址,此时通过调整对象指针可以修改这些指向关系,让GC Root指向新的对象地址,然后对象成员的引用也会相应调整为引用新的对象地址。

4. 移动对象(Move object)

当一切准备就绪后,就在新地址为对象分配了内存,且引用关系已经修改,但是新地址的对象并不包含有效数据,所以要从老对象地址处将对象数据逐一复制到新对象地址处,至此FGC完成。Serial GC将重置GC相关数据结构,并用日志记录GC信息。

世界停顿

在10.1.2节讲过,世界停顿(Stop The World,STW)即所有Mutator线程暂停的现象。Serial GC的YGC和FGC均使用单线程进行,所以GC工作时所有Mutator线程必须暂停,Java堆越大,STW越明显,且长时间的STW对于GUI程序或者其他要求伪实时、快速响应的程序是不可接受的,所以STW是垃圾回收技术中最让人诟病的地方之一:一方面所有Mutator线程走到安全点需要时间,另一方面STW后垃圾回收工作本身也需要大量时间。那么,能否利用现代处理器多核,并行化STW后垃圾回收中的部分工作呢?关于这一点,Parallel GC给出了一份满意的答案。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值