G1 收集器

基础知识

性能指标

在调优Java应用程序时,重点通常放在两个主要目标上:响应性吞吐量

 响应性Responsiveness 是指应用程序对请求的数据做出响应的速度:

  • 桌面用户界面对事件的响应速度
  • 网站返回页面的速度
  • 数据库查询的返回速度

 吞吐量Throughput 专注于最大程度地提高应用程序在特定时间段内的工作量:

  • 在给定时间内完成的事务次数
  • 批处理程序在一小时内可以完成的作业数
  • 一小时内可以完成的数据库查询数

较长的暂停时间pause time对于注重响应性的应用程序是不可接受的,但对于注重吞吐量的应用程序则无伤大雅。前者重点是在短时间内做出响应,后者则侧重与长时间运行的处理效率。

GC 基础

GC Root

G1 收集器

可达性分析是 Java GC 算法的基础,基本思路就是以一系列名为 GC Roots 对象作为起始点,通过引用关系遍历对象图,如果一个对象到 GC Roots 间没有任何可达路径相连时,则说明此对象可以被回收。

G1 收集器

可以作为 GC Roots 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈中JNI(即一般说的native方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

三色标记

可达性分析中重要的一环就是遍历整个堆,并标记其中的存活对象。一种常用的标记算法是 三色标记法tri-color marking

G1 收集器

每个对象可能为以下 3 种颜色之一:

  • white — 未被标记
  • gray — 本身已标记,但部分引用的对象未被标记(动图的黄色对象)
  • black — 本身已标记,且所有引用的对象完成标记(动图的蓝色对象)

标记算法从 GC Roots 出发遍历堆,可达对象先标记 gray,然后再标记 为 black。

遍历完成之后所有可达对象都是 black 的,此时所有标记为 white 的对象都是可以回收的。

当实现并发标记算法时,必须防止 white 对象被漏标,否则可能导致不该回收的对象被回收。

分代收集

传统垃圾收集器将堆分成三个部分:年轻代YoungGen = Eden + Survivor,老年代OldGen和永久代PermGen,每个区域内存连续且大小固定。

G1 收集器

  • 年轻代:一次性使用的临时对象(例如:方法中构造的临时对象)
  • 老年代:被长期引用的常驻对象(例如:缓存对象、单例对象)
  • 永久代:JVM 运行过程中一直存在的对象(例如:字符串常量、类信息)

将堆内存进行划分后,可以按照对象生命周期长短,在不同区域使用不同的回收算法,提高 GC 的效率。

算法分类

Mark and Sweep标记-清除

G1 收集器

 用一个空闲列表free-list记录失效对象占用的内存区域,方便后续重新分配给新对象。

  • 回收原理简单,GC 停顿时间短
  • 维护空闲列表需要一定的空间开销
  • 内存碎片较多,可能导致内存分配失败

Mark-Sweep-Compact标记-整理

G1 收集器

 将所有存活对象移动到内存区域的开头,剩余的连续内存区域都是可用的空闲空间。

  • 通过指针碰撞查找空闲空间,分配速度快
  • 内存碎片少,内存分配失败概率低
  • 复制对象会导致较长时间的 GC 停顿

Mark and Copy标记-复制

G1 收集器

 将内存划分为活动区间空闲区间,前者用于动态分配对象,后者用于容纳 GC 存活对象。
 GC 时只需将存活对象从前者复制到后者,然后交换两者的角色即可。

  • 标记和复制在同一阶段同时进行,当存活对象少时回收效率极高
  • 需要预留一个空闲空间用于容纳存活对象,造成内存浪费

CMS 回顾

CMS Concurrent Mark-Sweep 是一个采用 标记-清除 算法的老年代收集器。
它通过与应用程序线程并发执行大多数垃圾回收工作,来最大程度地减少由于 GC 导致的暂停。

通常情况下,CMS 收集器不会复制或压缩活动对象,这意味着无需移动活动对象即可完成垃圾回收。
然而过多的内存碎片可能造成分配失败,最终导致 FullGC。可以通过分配更大的堆来规避这一问题。

CMS 对老年代的回收可以分为以下几个步骤:

  • Initial Mark \(\tiny \textsf{(STW)}\) 初始标记

    G1 收集器

    • 标记 GC Roots 直接可达的老年代对象
    • 遍历新生代存活对象,标记直接可达的老年代对象
  • Concurrent Mark 并发标记

    GC 线程遍历 Initial Mark 阶段标记出来存活的老年代对象,然后递归标记这些可达的对象。

    G1 收集器

    该阶段与应用线程并发运行,期间会发生新生代对象晋升、老年代对象引用关系更新,需要对这些对象进行重新标记,避免发生遗漏。

    G1 收集器

    CMS 用一个card-table管理老年代,并发标记过程中,某个对象的引用关系发生了变化,则将对象所在的内存块标记为 Dirty Card

    CMS 使用增量更新incremental update解决并发修改导致的漏标问题:把 black 对象重新标记为 grey,下次重新扫描其引用。

  • Preclean 预清理

    这一阶段主要是处理 Concurrent Mark 阶段中引用关系改变,导致没有标记到的存活对象的。通过并发地重新扫描这些对象,预清理阶段可以减少 Remark 阶段的 STW。

    G1 收集器

    这个阶段会处理前一个阶段被标记为 Dirty Card 的部分,将其中变化了的对象作为 GC Root 再进行扫描并重新标记。

  • Abortable Preclean 可终止的预清理

    这个阶段作用与 Preclean 类似,但可以通过设置 扫描时长(默认5秒)或 Eden 区使用占比(默认50%)控制本阶段的结束时机。

    增加这一阶段的原因,是期待这期间能发生一次 YoungGC 清理无效的年轻代对象,减少 Remark 阶段扫描年轻代的时间。

  • Remark \(\tiny \textsf{(STW)}\) 重新标记

    这个阶段同时扫描 YoungGen 与 OldGen,重新标记整个老年代中所有存活对象。

    由于之前的 Concurrent MarkPreclean 阶段是与用户线程并发执行的,年轻代对老年代的引用可能已经发生了改变,Remark 要花很多时间处理这些改变,会导致长时间的 STW。

    此外,即使新生代的对象已经不可达了,CMS 也会使用这些不可达的对象当做的 GC Roots 来扫描老年代,导致部分失效的老年代对象无法被及时回收。

    可以加入参数 -XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次 YoungGC,回收掉年轻代的对象无用的对象。这样进行年轻代扫描时,只需要扫描 Survivor 区的对象即可,一般 Survivor 区非常小,这大大减少了扫描时间。

  • Concurrent Sweep 并发清理

    G1 收集器

    至此,老年代所有存活的对象已经被标记完成。这个阶段主要是清除那些没有标记的对象并且回收空间。

    被回收的空间会被添加到 空闲列表中,以供以后分配。这一过程可能会对空闲空间进行合并,但是不会移动存活对象。

    由于该阶段是与应用线程并发运行的,自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,无法在当次收集中处理掉它们。只好留待下一次GC时再清理掉。这一部分垃圾就称为 浮动垃圾

  • Resetting 重置

    清除数据结构,并重置定时器,为下一轮 GC 做准备。

G1 算法

设计目的

G1 Garbage-First 是一种服务器端的垃圾收集器:

  • 可以与应用程序线程并行运行,减少 STW
  • 整理空闲空间减少内存碎片,但不引入较长的 GC 暂停时间
  • 提供可预测的GC暂停时间,无需牺牲很多吞吐量

G1 能够在大内存的多处理器计算机上,保证 GC 暂停时间可控,并实现高吞吐量。

其最终目的是取代 CMS 成为服务端 GC 更好的解决方案:

  • 采用 标记-整理 算法,可以避免使用细粒度的空闲列表进行分配。简化了收集器设计并消除了潜在的碎片问题。
  • 使用 增量回收incremental collecting 算法,其 GC 暂停时间比 CMS 更具可预测性,并允许用户指定期望的暂停时间。

基本概念

G1 将堆划分为一组大小相等的且连续的堆区域Region

G1 收集器

G1 中新生代与老年代不再连续,每个区域可以在 EdenSurvivorOld 之间切换角色。此外,还有一类被称为 Humongous 的巨型区域,用于容纳体积 ≥ 标准区域大小的50%的对象。

JVM 通常会将内存划分为 2000个区域,每个大小从 1 到 32Mb 不等,由 JVM 在启动时通过 -XX:G1HeapRegionSize 指定。

每个区域会被进一步细分成多个卡片Card,每个大小为 512Kb,用于实现细粒度的引用统计。

分区设计可以避免一次收集整个堆,每次 GC 只收集区域的一个子集 CSetcollection set

G1 收集器

根据回收区域的不同,可以将 GC 分为:

  • YoungGCCSet 只包含 Young 区域
  • MixedGCCSet 同时包含 YoungOld 区域
  • FullGC: 回收整个堆(可用空间耗尽时触发,单线程执行)

G1 根据存活对象的字节数统计每个区域的 活跃度liveness,然后根据期望停顿时间来确定该 CSet 的大小,并保证那些垃圾多(活跃度低)的区域会被优先回收,故此得名 垃圾优先

G1 的执行过程可以表示为由 3 个阶段组成的循环:

G1 收集器

Young GC

堆中一开始只有 YoungGen,因此只会触发 YoungGC,将 EdenSurvivor 区域中的活动对象复制到另一个空闲的 Survivor 区域。

G1 收集器
G1 收集器

G1 中将 将存活对象复制到其他区域 的过程称为 疏散Evacuation。为了减少停顿时间,疏散工作由多个 GC 线程并行完成。

YoungGC 过程中会根据预期目标停顿时间 -XX:MaxGCPauseMillis 动态调整新生代的大小,通过 -XX:G1NewSizePercent 参数可以人为干预这一过程,但会让预期停顿时间参数失效。

当堆的整体占用空间足够大时(超过45%),就会进入 Concurrent Marking 阶段。通过 -XX:InitiatingHeapOccupancyPercent 选项可以配置这一行为。

Concurrent Marking

与 CMS 类似,G1 中的并发标记包括多个阶段,其中一些阶段是并发的,另一些阶段则会 STW。

  • Initial Mark \(\tiny \textsf{(STW)}\) 初始标记

    扫描并标记 GC Root 对象直接可达的老年代存活对象。

    Initial Mark 并没有独立的执行阶段,而是嵌入 YoungGC 中执行的,其停顿时间会被分摊,因此实际的开销非常低。

  • Root Region Scan 扫描根区域

    扫描 Root Region 并标记所有可达的老年代存活对象。

    此处的 Root Region 就是先前 YoungGC 中生成的 Survivor 区域,其包含的对象都会被视为 GC Root

    为了避免移动对象对标记产生影响,该过程必须在下次 YongGC 启动前完成。

  • Concurrent Mark 并发标记

    启动并发标记线程,扫描并标记整个堆中的存活对象(线程数可以通过 -XX:ConcGCThread 进行配置)。

    为了避免重复标记,G1 使用 SATBsnapshot-at-the-beginning算法解决漏标问题:

    应用线程对在 Concurrent Mark 执行期间进行的所有并发更新,都应保留先前的已知标记信息。

    该约束通过预写屏障pre-write barrier实现:

    Concurrent Mark 扫描过程中,当应用线程修改某个字段时,会将先前的引用对象存储在 日志缓冲区 log buffers中,然后交由并发标记线程处理。

    为了避免移动对象对标记产生影响,该过程必须在下次 YoungGC 启动前完成。所有的标记任务必须在堆满前完成,如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行 FullGC

  • Remark \(\tiny \textsf{(STW)}\) 重新标记

    启动并行标记线程,完成对整个堆中存活对象的标记(线程数可以通过 -XX:ParallelGCThread 进行配置)。

    该阶段会暂停所有应用线程,避免发生引用更新,并完成对SATB 日志缓冲区中剩余对象的标记,找出所有未被访问的存活对象。

    该阶段还执行一些额外的清理操作,例如:

    • 卸载不可达的类(通过 -XX:+ClassUnloadingWithConcurrentMark 开启)
    • 处理引用对象(弱引用、软引用、虚引用、最终引用)
  • Cleanup 清理垃圾

    整理统计信息并识别出高收益的老年代分区,为 MixedGC 做准备。

    主要工作有:

    • RSet 梳理(后续说明)\(\tiny \textsf{(STW)}\)
    • 识别回收收益高的老年代分区(基于释放空间和暂停目标)\(\tiny \textsf{(STW)}\)
    • 直接回收完全没有活跃对象的空闲分区

    此外还会执行一些清理工作,为下一次 Concurrent Marking 做好准备。

Mixed GC

MixedGC 主要流程与 YoungGC 类似,不同的地方在于 CSet 中包含了 Old 区域。

需要注意的是,Concurrent Marking 结束后,并不一定会立即触发 MixedGC,中间可能会穿插多次的 YoungGC

当收集某个区域时,我们必须知道是否有来自非收集区域引用,来确定它们的活动性:

  • 从非收集区域到收集区域的 incoming reference 是重要的(被非收集区引用的对象必须存活)
  • 从收集区域到非收集区域的 outgoing reference 是可忽略的(非收集区域不参与GC)

但查找整个堆非常耗时,同时也失去了增量收集的优势。为了解决这一问题,G1 为每个区域维护了一个 RSetremembered set,用于记忆从其他区域指向自己的引用。

收集过程

在执行收集时,RSet 中引用信息会扮演局部 GC Roots 的角色,避免耗时的引用查找,保证每个区域的 GC 能够独立进行:

G1 收集器

注意,象如果 Old 区域中对在 Concurrent Marking 阶段被确定为垃圾,即使有外部引用,该对象也会被作为垃圾回收。

接下来发生的事情与其他收集器所做的相同:多个并行GC线程找出哪些对象是活动的,哪些对象是垃圾:

G1 收集器

最后,释放空闲区域,将活动对象移到 Survivor 区域,并在必要时创建新对象:

G1 收集器

RSet 维护

为了维护 RSet,在应用线程对字段执行写操作时,会触发写后屏障post-write barrier

如果更新后的引用是跨区域的(即从一个区域指向另一个区域),则对应的条目将出现在目标区域的
RSet 中。

为了减少写屏障带来的开销,该过程是异步的:

应用线程只负责把更新字段所在的 Card 信息插入一个
DCQ
Dirty Card Queue,然后由 Refine 线程将其拾取并将信息传播到被引用区域的 RSet。

如果应用线程插入速度过快,会导致 Refine 线程来不及处理,那么应用线程将接管 RSet 更新的任务,从而导致性能下降。

总结

并发标记增量收集 是 G1 实现高性能与可预测回收的关键。

对于 CPU 资源充足且对延迟敏感的服务端应用来说,G1 算法能够在大堆上提供良好的响应速度。

作为代价,额外的写屏障与更活跃GC线程,会对应用的吞吐量产生负面影响。

参考资料

发表评论

相关文章