JVM垃圾回收-三色标记法

JVM垃圾回收

简单回顾

关于垃圾回收算法,基本就是那么几种:

  • 标记-清除
  • 标记-复制
  • 标记-整理
  • 分代:新生代/老年代,每代采取不同的回收算法,以提高整体的分配和回收效率。
  • 分区:把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理

无论使用哪种算法,标记总是必要的一步。这是理算当然的,你不先找到垃圾,怎么进行回收?

垃圾回收器的工作流程大体如下:

  1. 标记出哪些对象是存活的,哪些是垃圾(可回收);
  2. 进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用。
    接下来着重来看下标记的部分。

如何确定垃圾

引用计数法

在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。

可达性分析

为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。

GC roots

第一种是 虚拟机栈中的引用的对象 。在程序中正常创建一个对象,对象会在堆上开辟一块空间,同时会将这块空间的地址作为引用保存到虚拟机栈中,如果对象生命周期结束了,那么引用就会从虚拟机栈中出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种情况是最常见的。
第二种是我们 在类中定义了全局的静态的对象 ,也就是使用了 static 关键字,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为 GC Roots 是必须的。
第三种便是 常量引用 ,就是使用了 static final 关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为 GC Roots。
第四种是在 使用 JNI 技术时,有时候单纯的 Java 代码并不能满足我们的需求,我们可能需要在 Java 中调用 C 或 C++的代码,因此会使用 Native 方法,JVM 内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为 GC Roots。

注:不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。

第一次标记

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记。

第二次标记

经过第一次标记后的对象,根据 此对象是否有必要执行finalize()方法 进行筛选,随后会由收集器对F-Queue中的对象进行第二次小规模的标记。具体如下:

  • 经过第一次标记后的对象,根据 此对象是否有必要执行finalize()方法 进行筛选。被判定为确实有必要执行finalize()方法的对象将会被放置在一个名为F-Queue的队列之中。
  • 假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
  • 稍后会由一条由虚拟机自动建立的、低调度优先级的 Finalizer线程 去执行F-Queue中对象的finalize()方法。
  • finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行 第二次小规模的标记。如果对象在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,那在第二次标记时它将被移出 “即将回收” 的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
  • 这种自救的机会只有一次,因为对象的finalize()方法最多只会被系统调用一次。

JVM中的CMS、G1垃圾回收器所使用垃圾回收标记算法即为三色标记法。

三色标记法

三色标记算法思想

三色标记法将对象的颜色分为了黑、灰、白,三种颜色。
白色:该对象没有被标记过。(对象垃圾)
灰色:该对象已经被标记过了,但该对象下的属性没有全被标记完。(GC需要从此对象中去寻找垃圾)
黑色:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)

假设现在有白、灰、黑三个集合(表示当前对象的颜色,算法思想具体实现方式不同),其遍历访问过程为:
1 初始时,所有对象都在 【白色集合】中;
2 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
3 从灰色集合中获取对象:
3.1. 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
3.2. 将本对象 挪到 【黑色集合】里面。
重复步骤3,直至【灰色集合】为空时结束。
结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。
注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

当Stop The World (以下简称 STW)时,对象间的引用 是不会发生变化的,可以轻松完成标记。
而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

多标

并发标记的过程中,若一个已经被标记成黑色或者灰色的对象,突然变成了垃圾,由于不会再对黑色标记过的对象重新扫描,所以不会被发现,那么这个对象不是白色的但是不会被清除,重新标记也不能从GC Root中去找到,所以成为了浮动垃圾,浮动垃圾对系统的影响不大,留给下一次GC进行处理即可。

漏标

并发标记的过程中,一个业务线程将一个未被扫描过的白色对象断开引用成为垃圾(删除引用),同时黑色对象引用了该对象(增加引用)(这两部可以不分先后顺序);因为黑色对象的含义为其属性都已经被标记过了,重新标记也不会从黑色对象中去找,导致该对象被程序所需要,却又要被GC回收,此问题会导致系统出现问题。
在这里插入图片描述
不难分析,漏标只有同时满足以下两个条件时才会发生:
条件一:灰色对象 断开了 白色对象的引用(直接或间接的引用);即灰色对象 原来成员变量的引用 发生了变化。
条件二:黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了 新的引用。

而CMS与G1,两种回收器在使用三色标记法时,都采取了一些措施来应对这些问题,CMS对增加引用环节进行处理(Increment Update),G1则对删除引用环节进行处理(SATB)。

CMS 之 Increment Update

CMS 收集过程

[GC [1 CMS-initial-mark: 13991K(20288K)] 14103K(22400K), 0.0023781 secs]
[GC [DefNew: 2112K->64K(2112K), 0.0837052 secs] 16103K->15476K(22400K), 0.0838519 secs]
...
[GC [DefNew: 2077K->63K(2112K), 0.0126205 secs] 17552K->15855K(22400K), 0.0127482 secs]
[CMS-concurrent-mark: 0.267/0.374 secs]
[GC [DefNew: 2111K->64K(2112K), 0.0190851 secs] 17903K->16154K(22400K), 0.0191903 secs]
[CMS-concurrent-preclean: 0.044/0.064 secs]
[GC [1 CMS-remark: 16090K(20288K)] 17242K(22400K), 0.0210460 secs]
[GC [DefNew: 2112K->63K(2112K), 0.0716116 secs] 18177K->17382K(22400K), 0.0718204 secs]
[GC [DefNew: 2111K->63K(2112K), 0.0830392 secs] 19363K->18757K(22400K), 0.0832943 secs]
...
[GC [DefNew: 2111K->0K(2112K), 0.0035190 secs] 17527K->15479K(22400K), 0.0036052 secs]
[CMS-concurrent-sweep: 0.291/0.662 secs]
[GC [DefNew: 2048K->0K(2112K), 0.0013347 secs] 17527K->15479K(27912K), 0.0014231 secs]
[CMS-concurrent-reset: 0.016/0.016 secs]
[GC [DefNew: 2048K->1K(2112K), 0.0013936 secs] 17527K->15479K(27912K), 0.0014814 secs
]

1)初始标记(CMS initial mark)
2)并发标记(CMS concurrent mark)
Concurrent Preclean(并发预清理)
3)重新标记(CMS remark)
4)并发清除(CMS concurrent sweep)
Concurrent Reset(并发重置)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。

  • 初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快;
  • 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
  • 重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;
  • 并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,降低了应用停顿时间。

写屏障

针对新增的引用,利用写屏障(update_barrier_set)将其记录下来等待遍历,即增量更新(Incremental Update)实现漏标处理。
在这里插入图片描述
翻译:当使用CMS时,我们必须使用release语义将对应于p的card table标记为dirty,以防止CMS看到脏卡,而p指向的不是新的vlaue v,因为这两个存储可能会重新排序,引发可见性问题。注意,cms有一个并发的预清理阶段,当java线程运行时,它读取card table,并标记被Dirty对象直接或间接引用的对象,然后清除Card标识。

CMS 收集过程值得注意的是,除了预清理阶段需要遍历写屏障的Dirty card 中的记录 ,在重新标记阶段,还需要重新扫描遍历GC Roots(当然标记过的无需再遍历了),这是由于CMS对于astore_x等指令不添加写屏障的原因。

G1 之 SATB

G1 收集

G1 特点在于可控的停顿时间,可以用来取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间。如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。

G1(Garbage First)物理内存不再分代,而是由一块一块的Region组成,但是逻辑分代仍然存在。G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。
在这里插入图片描述

Card Table(多种垃圾回收器均具备)

  1. 辅助YoungGC
    由于在进行YoungGC时,我们在进行对一个对象是否被引用的过程,需要扫描整个Old区,所以JVM设计了CardTable,将Old区分为一个一个Card,一个Card有多个对象;如果一个Card中的对象有引用指向Young区,则将其标记为Dirty Card,下次需要进行YoungGC时,只需要去扫描Dirty Card即可,无需遍历整个Old区。

  2. 辅助CMS三色标记 Incremental Update
    CMS 并发标记阶段

  • 新生代的对象晋升到老年代;
  • 直接在老年代分配对象;
  • 老年代对象的引用关系发生变更;
    为了提高重新标记的效率,本阶段会把这些发生变化的对象所在的Card标识为Dirty,这样后续就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代。

RSet(Remembered Set)

是辅助GC过程的一种结构,典型的空间换时间工具,和Card Table有些类似。
后面说到的CSet(Collection Set)也是辅助GC的,它记录了GC要收集的Region集合,集合里的Region可以是任意年代的。
在GC的时候,对于old->young和old->old的跨代对象引用,只要扫描对应的CSet中的RSet即可。 逻辑上说每个Region都有一个RSet,RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。
而Card Table则是一种points-out(我引用了谁的对象)的结构,每个Card 覆盖一定范围的Heap(一般为512Bytes)。G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。每个Region中都有一个RSet,记录其他Region到本Region的引用信息;使得垃圾回收器不需要扫描整个堆找到谁引用当前分区中的对象,只需要扫描RSet即可。
在这里插入图片描述

CSet(Collection Set)

一组可被回收的分区Region的集合, 是多个对象的集合内存区域。

youngGC

年轻代收集概念上和其他分代收集器大差不差的,但是它的年轻代会动态调整,进行扩容或缩容。
如图,所有选定的Eden区和Suvivor区都进入新的Surivor区(标记复制算法),或者晋升到Old 区
在这里插入图片描述
Fully Yong GC 流程

  • STW(Stop The World)
  • 构建CS【Collection Set】(Eden+Survivor)
  • 扫描GC Roots
  • Update RS:排空Dirty Card Queue,并更新Remember Set(因为RSet是先写日志,再通过一个Refine线程进行处理日志来维护RSet数据的,这里的更新RSet就是为了保证RSet日志被处理完成,RSet数据完整才可以进行扫描)
  • Process RS:在Remember Set中找到被哪些老年代的对象跨代引用的。
  • Object Copy:常规的对新生代进行标记复制算法
  • Reference Processing:回收可以被回收的引用类型
  1. 强引用:强引用所指向的对象在任何时候都不会被系统回收。JVM宁愿抛出OOM异常,也不会回收强引用所指向的对象。
  2. 软引用:不会被JVM很快回收,JVM会根据当前堆的使用情况来判断何时回收。当堆使用率临近阈值时,才会去回收软引用的对象。
  3. 弱引用:在系统GC时,只要发现弱引用,不管系统堆空间是否足够,都会将对象进行回收。(即在弱引用新建后的下一次GC时就会被回收。 )
  4. 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。

Old GC 之 并发标记周期

标记周期包括以下阶段:
Initial marking phase:G1收集器扫描所有的根。该过程是和young GC的过程一样的;
Root region scanning phase:扫描Survivor Regions中指向老年代的被initial mark phase标记的引用及引用的对象,这一个过程是并发进行的。但是该过程要在下一个young GC开始之前结束;(因为所有选定的Eden区和Suvivor区通过youngGC收集,会进入新的Surivor区,也就是下个阶段的Root 区,不需要从GC roots 重新扫描,这样也就理解了要在下一个young GC开始前结束
Concurrent marking phase:并发标记阶段,标记整个堆的存活对象。该过程可以被young GC所打断。并发阶段产生的新的引用(或者引用的更新)会被SATB的write barrier记录下来;
Remark phase:也叫final marking phase。该阶段只需要扫描SATB(Snapshot At The Beginning)的buffer,处理在并发阶段产生的新的存活对象的引用。作为对比,CMS的remark需要扫描整个mod union table的标记为dirty的entry以及全部根;
Cleanup phase:清理阶段。该阶段会计算每一个region里面存活的对象,并把完全没有存活对象的Region直接放到空闲列表中。在该阶段还会重置Remember Set。该阶段在计算Region中存活对象的时候,是STW(Stop-the-world)的,而在重置Remember Set的时候,却是可以并行的;

SATB(Snapshot At The Beginning),

在应对漏标问题时,G1使用了SATB方法来做,具体流程:

  • 在开始标记的时候生成一个快照图标记存活对象
  • 在一个引用断开后,要将此引用推到GC的堆栈里,保证白色对象(垃圾)还能被GC线程扫描到(在write barrier写屏障(pre_write_barrier)里把所有旧的引用所指向的对象都变成非白的)。
  • 配合Rset,去扫描哪些Region引用到当前的白色对象,若没有引用到当前对象,则回收
write barrier写屏障

整个write barrier+oop_field_store是这样的:


void oop_field_store(oop* field, oop new_value) {
  pre_write_barrier(field);             // pre-write barrier: for maintaining SATB invariant
  *field = new_value;                   // the actual store
  post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference
}

按照Yuasa式SATB barrier的设计,pre-write barrier里面的抽象逻辑应当如下:


void pre_write_barrier(oop* field) {
  if ($gc_phase == GC_CONCURRENT_MARK) { // SATB invariant only maintained during concurrent marking
    oop old_value = *field;
    if (old_value != null && !is_marked(old_value)) {
      mark_object(old_value);
      $mark_stack->push(old_value); // scan all of old_value's fields later
    }
  }
}

这比原本的Yuasa式设计少了些东西:没有检查目标对象是否已经mark,也不去对目标对象做mark和扫描它的字段。实际上该做的事情还是得做,只是不在这里做而已。那放在那里做呢放到了后面的logging barrier,这个后面讲到。

Pre-write barrier的实际代码有好几个版本,其中最简单明白的版本是:

  // This notes that we don't need to access any BarrierSet data
  // structures, so this can be called from a static context.
  template <class T> static void write_ref_field_pre_static(T* field, oop newVal) {
    T heap_oop = oopDesc::load_heap_oop(field);
    if (!oopDesc::is_null(heap_oop)) {
      enqueue(oopDesc::decode_heap_oop(heap_oop));
    }
  }

enqueue动作的实际代码则在G1SATBCardTableModRefBS::enqueue(oop pre_val)。

它判断当前是否在concurrent marking phase用的是:

JavaThread::satb_mark_queue_set().is_active()

logging write barrier

为了尽量减少write barrier对应用mutator性能的影响,G1将一部分原本要在barrier里做的事情挪到别的线程上并发执行。
实现这种分离的方式就是通过logging形式的write barrier:mutator只在barrier里把要做的事情的信息记(log)到一个队列里,然后另外的线程从队列里取出信息批量完成剩余的动作。

以SATB write barrier为例,每个Java线程有一个独立的、定长的SATBMarkQueue,mutator在barrier里只把old_value压入该队列中。一个队列满了之后,它就会被加到全局的SATB队列集合SATBMarkQueueSet里等待处理,然后给对应的Java线程换一个新的、干净的队列继续执行下去。

并发标记(concurrent marker)会定期检查全局SATB队列集合的大小。当全局集合中队列数量超过一定阈值后,concurrent marker就会处理集合里的所有队列:把队列里记录的每个oop都标记上,并将其引用字段压到标记栈(marking stack)上等后面做进一步标记。

SATB效率高于增量更新的原因?
因为SATB在重新标记环节只需要去重新扫描那些被推到堆栈中的引用,并配合Rset来判断当前对象是否被引用来进行回收;
并且在最后G1并不会选择回收所有垃圾对象,而是根据Region的垃圾多少来判断与预估回收价值(指回收的垃圾与回收的STW时间的一个预估值),将一个或者多个Region放到CSet中,最后将这些Region中的存活对象压缩并复制到新的Region中,清空原来的Region。

TAMS 指针

在 GC 过程中新分配的对象都当做是活的,其他不可达的对象就是死的。如何知道哪些对象是 GC 开始之后新分配的呢?G1 在 Region 中通过 top-at-mark-start (TAMS) 指针来解决这个问题,分别使用 prevTAMS 和 nextTAMS 来记录新分配的对象。示意图如下:

每个 Region 记录着两个 top-at-mark-start (TAMS) 指针,分别为prevTAMS 和 nextTAMS。在 TAMS 以上的对象就是新分配的,因而被视为隐式 marked。
G1 的 concurrent marking 用了两个 bitmap:
在这里插入图片描述

一个 prevBitmap 记录第 n-1 轮 concurrent marking 所得的对象存活状态。由于第 n-1 轮 concurrent marking 已经完成,所以这个 bitmap 的信息可以直接使用。
一个 nextBitmap 记录第 n 轮 concurrent marking 的结果。这个 bitmap 是当前将要或正在进行的 concurrent marking 的结果,尚未完成,所以还不能使用。
其中 top 是该 Region 的当前分配指针,[bottom, top) 是当前该 Region 已用的部分,[top, end) 是尚未使用的可分配空间。

[bottom, prevTAMS):这部分里的对象存活信息可以通过 prevBitmap 来得知。
[prevTAMS, nextTAMS):这部分里的对象在第 n-1 轮 concurrent marking 是隐式存活的。
[nextTAMS, top):这部分里的对象在第 n 轮 concurrent marking 是隐式存活的。

G1会不会进行Full GC?
会,当内存满了的时候就会进行Full GC;且JDK10之前的Full GC,为单线程的,所以使用G1需要避免Full GC的产生。

解决方案:

  • 加大内存;
  • 提高CPU性能,加快GC回收速度,而对象增加速度赶不上回收速度,则Full GC可以避免;
  • 降低进行Mixed GC触发的阈值,让Mixed GC提早发生(默认45%)
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值