您的位置 首页 java

深入解析java虚拟机:垃圾回收,最大并发标记清除垃圾回收器

CMS GC

回收策略

CMS GC的全称是最大并发标记清除垃圾回收器(Mostly Mark andSweep Garbage Collector),可以使用-XX:+UseConcMarkSweepGC开启。CMS GC的新生代清理仍然使用与Parallel GC类似的方式,即开启多个线程一起清理,且在这个过程中,Mutator线程不能工作。从算法上来说,该过程与Serial GC、Parallel GC的YGC完全一致;从逻辑上来说,该过程与Parallel GC的Young GC几乎一致,所以这里不再赘述。不同点是CMS GC多了个专门针对老年代的Old GC,图10-8简单说明了Old GC的概念。

图10-8 CMS GC

垃圾回收策略有很多名称,如Young GC、Full GC、Minor GC、Major GC和Mixed GC等,实际上对于HotSpot VM来说,只有Partial GC和Full GC。Partial GC表示只清理堆的部分区域。Minor GC与Young GC等价,都表示只清理新生代,Old GC表示只清理老年代,Mixed GC表示清理整个新生代和部分老年代,它们都属于Partial GC。Full GC表示清理整个堆,通常它等价于Major GC。本文主要是用Young GC(以下简称YGC)和Full GC(以下简称FGC)两种表示。

CMS GC除了有负责清理新生代的YGC、特殊情况下的FGC外,还有只回收老年代的垃圾回收策略,即Old GC。Old GC大部分过程允许Mutator线程和GC线程一起进行,此时Mutator线程无须停止,这种方式称为并发垃圾回收,所使用的算法称为并发标记清除算法。

对象丢失问题

传统的标记清除算法分为标记、清除两个阶段。为了将它改造为并发算法,CMS GC将标记清除算法细分为初始标记、并发标记、预清理、可中断预清理、重新标记、并发清理,重置几个阶段,其中只有初始标记和重新标记需要STW,其他最耗时的阶段允许GC线程和Mutator线程一起进行。正是因为它有两个阶段需要STW,所以CMS GC的名字是最大程度(Mostly)的并发而非完全(Completely)并发。Mutator线程和GC线程一起工作会造成一些问题,如图10-9所示。

图10-9 并发标记问题

三色抽象(Tricolor Abstraction)可以简洁地描述回收过程中对象状态的变化,所以本节将使用三色抽象描述对象标记过程:图10-9中黑色表示对象及成员都被处理,浅色网格表示对象本身已处理,白色表示未处理对象。

起初垃圾回收器已经处理了A、B、C对象,并正在处理E对象成员。由于Mutator线程可以与GC线程一起工作,所以Mutator线程可以更新B对象的引用,使其指向D对象,并删除G对象对D对象的引用。由于B对象已经被标记为黑色对象,不会再做扫描,所以GC只会继续处理E对象,并清扫未被标记的D对象。更进一步,研究表明,只要同时满足以下两条要求就会造成存活对象丢失:

Mutator线程插入了从黑色对象指向白色对象的新引用;

Mutator线程删除了从灰色对象指向该白色对象的所有可能路径。

“垃圾回收器只能清理垃圾”是垃圾回收器最重要的原则,如果只是简单地引入并发算法,则会违背该原则,因此,并发垃圾回收器必须处理对象丢失问题。

常用的解决对象丢失的方法有增量更新(Incremental Update)和SATB(Snapshot At The Beginning,起始快照)技术。

增量更新的原理是打破第一个条件,通过写屏障记录下Mutator线程对黑色对象的增量修改,然后重新扫描这些黑色对象,以图10-9为例,当删除G到D的引用,并添加B到D的引用时,增量更新的写屏障会记录对象G并将它标记为灰色以等待二次处理。

SATB的原理是打破第二个条件,同样的例子,SATB写屏障会将D放入标记栈等待后续处理。

CMS GC使用增量更新技术,具体实现方式是复用其他分代GC处理跨代引用的卡表和写屏障代码,只要黑色对象写入白色对象的引用,就记录在卡表中以等待后续重新标记阶段再次扫描。这样做的问题是由于卡表本来用于处理跨代引用,每次YGC后都会重置,导致CMS GC需要的数据可能被重置掉,因此CMS GC引入了mod-union表,当CMS GC的Old GC进行并发标记时,每发生一次YGC,就会在重置卡表前更新mod-union表的对应数据。

Old GC周期

CMS GC在Old GC中实现了并发标记清除算法,在创建CMSCollector时,虚拟机会同时创建ConcurrentMarkSweepThread(以下简称CMS GC线程),用于负责Old GC的实际工作,如代码清单10-16所示:

代码清单 10-16 ConcurrentMarkSweepThread::run_service

 void ConcurrentMarkSweepThread::run_service() {
...
while (!should_terminate()) {
sleepBeforeNextCycle(); // 阻塞一段时间,直到下一次Old GC发生
if (should_terminate()) break; // 如果请求退出则终止CMS GC线程
GCIdMark gc_id_mark;
GCCause::Cause cause = _collector->_full_gc_requested ?
_collector->_full_gc_cause : GCCause::_cms_concurrent_mark;
_collector->collect_in_background(cause); // 清理老年代
}
}  

CMS GC线程会进入一个循环,每次它调用sleepBeforeNextCycle()时会阻塞一段时间,唤醒后使用CMSCollector::collect_in_background()清理老年代,如代码清单10-17所示:

代码清单 10-17 collect_in_background

 void CMSCollector::collect_in_background(GCCause::Cause cause) {
...
switch (_collectorState) {
// 初始标记(STW)
case InitialMarking:{ReleaseForegroundGC x(this);
stats().record_cms_begin();
VM_CMS_Initial_Mark initial_mark_op(this);
VMThread::execute(&initial_mark_op);
}
break;
// 并发标记
case Marking: markFromRoots();break;
// 预清理
case Precleaning: preclean();break;
// 可中断预清理
case AbortablePreclean: abortable_preclean();break;
// 重新标记(STW)
case FinalMarking:{
ReleaseForegroundGC x(this);
VM_CMS_Final_Remark final_remark_op(this);
VMThread::execute(&final_remark_op);
}
break;
// 并发清理
case Sweeping: sweep(); // fallthrough
case Resizing: {
ReleaseForegroundGC x(this);
MutexLockerEx y(...);
CMSTokenSync z(true);
if (_collectorState == Resizing) {
compute_new_size();save_heap_summary();
_collectorState = Resetting;
}
break;
}
// 重置垃圾回收器的各种数据结构
case Resetting: ... break;
case Idling:
default: ShouldNotReachHere();break;
}
...
}  

collect_in_background实现了一个完整的Old GC,代码使用状态机模式,通过_collectorState状态转换来切换到不同的垃圾回收周期,简化了代码逻辑。

1. 初始标记

初始标记(InitiaMarking)是Old GC的第一个周期,它需要Mutator线程暂停,这一步通过安全点来保障,而虚拟机中能开启安全点的操作只能是VMThread,所以InitialMarking阶段会创建一个VM_CMS_Initial_Mark的VMOperation,当VMThread执行该VMOperation并协调所有线程进入安全点后,会调用checkpointRootsInitialWork()进行初始标记,如代码清单10-18所示:

代码清单 10-18 chekcpointRootsInitialWork

 void CMSCollector::checkpointRootsInitialWork() {
// 确保位于安全点,并且处于InitialMarking阶段
assert(SafepointSynchronize::is_at_safepoint(), ...);
assert(_collectorState == InitialMarking, "just checking");
...
// 新生代指向老年代的引用
MarkRefsIntoClosure notOlder(_span, &_markBitMap);
...
if (CMSParallelInitialMarkEnabled) {
... // 使用多线程进行初始标记
CMSParInitialMarkTask tsk(this, &srs, n_workers);
if (workers->total_workers() > 1) {
workers->run_task(&tsk);
} else {
tsk.work(0);
}
} else {
... // 使用单线程进行初始标记
heap->cms_process_roots(...,¬Older, &cld_closure);
}
...
}  

代码清单10-18说明了并发和并行并不是互斥的概念,并发标记清除把整个标记清除细分为几个阶段,然后以STW的方式执行其中两个阶段,其他阶段允许Mutator线程和GC一起工作,在STW的两个阶段,垃圾回收器还可以充分发挥多核处理器的优势,使用多个线程进行回收工作,减少STW时间。

为了进一步减少STW时间,初始标记只会扫描并标记GC Root指向老年代的直接引用以及新生代指向老年代的直接引用,而所有间接引用都由后面的并发标记处理。

2. 并发标记

初始标记是从GC Root和新生代指向老年代记忆集出发,寻找直接可达的对象,接下来并发标记(Marking)是从这些对象出发,寻找间接可达的对象。

这一步由markFromRoots()完成,该函数内部会创建CMSConcMarkingTask并发标记。CMSConcMarkingTask包括标记逻辑和工作窃取逻辑,前者由do_scan_and_mark完成,后者由do_work_stealing完成。

标记的逻辑是每当发现初始标记的存活对象cur,就将它放入_markStack,然后进入循环。每次从_markStack中弹出一个对象,扫描cur的成员引用,直到_markStack为空,这是一个典型的广度优先搜索过程,只是CMS GC在扫描cur成员引用时稍有改变,它不会将扫描到的cur的成员全部放入_markStack,而是选择性地放入,如图10-10所示。

图10-10 BFS过程中处理cur对象的成员引用

假设cur表示对象C。对象C有成员对象A和E,A的地址位于C的前面,垃圾回收器会标记A,并扫描A的成员引用B;B的地址位于C前面,标记B并扫描B的成员引用D;D的地址位于C后面,只标记D,将D的成员放入_markStack但是不继续扫描(本例中对象D没有成员);接着处理对象E,E地址位于C后面,所以只标记不扫描它的成员引用。

总结来说,扫描策略是找到存活对象cur,如果它的成员对象地址位于cur前面,则标记并继续扫描成员对象,如果它的成员对象地址位于cur后面,则只标记不扫描成员对象。这样做实际上结合了广度优先搜索和深度优先搜索,好处是减小了_markStack的大小,在该例中_markStack最大仅包含一个元素,若直接使用广度优先搜索会导致_markStack快速膨胀,虚拟机内存空间不足的情况。

3. 预清理

并发预清理和并发可中断预清理(Precleaning &&AbortablePreclean)是可选步骤,如果关闭-XX:-CMSPrecleaningEnabled,虚拟机会跳过它直接执行下一阶段的重新标记。

如果上一阶段并发标记过程中Mutator线程修改了对象引用关系,比如创建了新生代指向老年代的引用,那么预清理可以发现这些修改,并标记老年代的对象图。可中断预清理与之类似,它会尝试若干次预清理过程,直到次数到达GC允许的上限,或者超过指定时间。两个阶段的意义在于做尽可能多的标记工作,减少下一阶段重新标记的STW时间。

4. 重新标记

重新标记(FinalMarking)过程会再次停止全部Mutator线程(STW),只允许垃圾回收线程。

因为初始标记到重新标记的间隔允许Mutaor线程和GC线程一起进行,所以可能产生大量从新生代指向老年代的引用,即新生代记忆集大增,也可能之前新生代已经存活的很多对象变成了死亡对象,但是GC不知道这个事实,仍然从GC Root和新生代记忆集出发标记存活对象,使本该死亡的对象被标记为存活对象,产生浮动垃圾。这是分代垃圾回收器面临的常见问题,如果开启-XX:+CMSScavengeBeforeRemark,在重新标记前GC会先对新生代进行垃圾回收,这样可以有效减少新生代记忆集大小,继而减少重新标记造成的STW时间。注意,以上讨论仅在两次STW标记期间新生代记忆集大增,或者大量新生代记忆集的对象从存活转变为死亡时才成立,如果随意开启该选项可能适得其反。

除了重新标记新增可选的新生代回收步骤外,重新标记过程与初始标记过程大致一样,两者都是向VMThread投递VMOperation,区别在于前者的VMOperation调用checkpointRootsInitialWork,后者调用checkpointRootsFinalWork,如代码清单10-19所示:

代码清单 10-19 重新标记

 void CMSCollector::checkpointRootsFinalWork() {
...
// 根据虚拟机参数使用多线程重新标记或者使用单线程重新标记
if (CMSParallelRemarkEnabled) {
do_remark_parallel();
} else {
do_remark_non_parallel();
}
...// 处理虚引用、弱引用等特殊引用
refProcessingWork();
_collectorState = Sweeping; // 修改状态,下一步是并发清理
}  

并发清理

并发清理(Sweeping)是指通过寻找卡表中标记为未被标记的页,找到对应的老年代空间,然后使用SweepClosure清理这些空间的无用对象。

并发模式失败

在CMS GC已经处于Old GC过程中时,如果垃圾回收器再被请求FGC,可能意味着Old GC的回收速度跟不上分配速度,此时CMS GC将会报告并发模式失败(如果此次FGC是用户请求的,如System.gc()调用或Heap dump等,那么会报告并发模式中断,只有GC自主发起的才被称为并发模式失败),并启用备用方案,使用单线程标记整理算法的FGC。单线程的FGC会造成应用程序长时间停顿,严重影响程序响应时间。

堆碎片化

CMS GC的Old GC使用(并发的)标记清除算法而不是像SerialGC、Parallel GC一样使用标记整理算法,原因在于如果使用标记整理算法,GC只能在标记阶段(大部分时间)并发:整理阶段由于需要移动对象,整个阶段需要STW,这对致力于减少STW时间的CMS GC来说是不可接受的。

相比之下,标记清除算法允许标记阶段(大部分时间)并发,同时清理阶段不需要移动对象,也可以并发进行,所以CMS GC选择了标记清除算法。标记清除算法使得并发变得简单,却带来了新的问题:它不会移动(整理)对象,随着时间的流逝,使得老年代空间的碎片化问题越来越严重,直到最后不能分配或者容纳晋升任何对象。此时CMS GC通常启用后备方案,即使用标记整理的FGC先对全堆做一次整理,处理碎片化问题,然后再继续。尽管存在上述缺陷,但不可否认,作为第一个HotSpot VM并发垃圾回收器,CMS GC为GC指明了未来的方向,使后续的GC在CMS GC的基础上,取长补短,不断完善。第一个后继者是并发垃圾回收器G1GC。

本文给大家讲解的内容是深入解析java虚拟机:垃圾回收,最大并发标记清除垃圾回收器

  1. 下篇文章给大家讲解的是深入解析java虚拟机:垃圾回收,G1 GC;
  2. 觉得文章不错的朋友可以转发此文关注小编;
  3. 感谢大家的支持!

文章来源:智云一二三科技

文章标题:深入解析java虚拟机:垃圾回收,最大并发标记清除垃圾回收器

文章地址:https://www.zhihuclub.com/181030.shtml

关于作者: 智云科技

热门文章

网站地图