G1 收集器
基础知识
性能指标
在调优Java应用程序时,重点通常放在两个主要目标上:响应性 或 吞吐量。
响应性Responsiveness
是指应用程序对请求的数据做出响应的速度:
- 桌面用户界面对事件的响应速度
- 网站返回页面的速度
- 数据库查询的返回速度
吞吐量Throughput
专注于最大程度地提高应用程序在特定时间段内的工作量:
- 在给定时间内完成的事务次数
- 批处理程序在一小时内可以完成的作业数
- 一小时内可以完成的数据库查询数
较长的暂停时间Pause Time
对于注重响应性的应用程序是不可接受的,但对于注重吞吐量的应用程序来说可以接受的。前者重点是在短时间内做出响应,后者则侧重与长时间运行的处理效率。
GC 基础
GC Root
可达性分析是 Java GC 算法的基础,基本思路就是以一系列名为 GC Roots
对象作为起始点,通过引用关系遍历对象图,如果一个对象到 GC Roots
间没有任何可达路径相连时,则说明此对象可以被回收。
可以作为 GC Roots
的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中JNI(即一般说的native方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
三色标记
可达性分析中重要的一环就是遍历整个堆,并标记其中的存活对象。一种常用的标记算法是 三色标记法tri-color marking
:
每个对象可能为以下 3 种颜色之一:
- white — 未被标记
- gray — 本身已标记,但部分引用的对象完成标记(动图的黄色对象)
- black — 本身已标记,且所有引用的对象完成标记(动图的蓝色对象)
标记算法从 GC Roots 出发遍历堆,可达对象先标记 gray,然后再标记 为 black。
遍历完成之后所有可达对象都是 black 的,此时所有标记为 white 的对象都是可以回收的。
当实现并发标记算法时,必须防止 white 对象被漏标,否则可能导致不该回收的对象被回收。
分代收集
传统垃圾收集器将堆分成三个部分:年轻代YoungGen = Eden + Survivor
,老年代OldGen
和永久代PermGen
,每个区域内存连续且大小固定。
- 年轻代:一次性使用的临时对象(例如:方法中构造的临时对象)
- 老年代:被长期引用的常驻对象(例如:缓存对象、单例对象)
- 永久代:JVM 运行过程中一直存在的对象(例如:字符串常量、类信息)
将堆内存进行划分后,可以按照对象生命周期长短,在不同区域使用不同的回收算法,提高 GC 的效率。
算法分类
Mark and Sweep标记-清除
用一个空闲列表free-list
记录失效对象占用的内存区域,方便后续重新分配给新对象。
- 回收原理简单,GC 停顿时间短
- 维护空闲列表需要一定的空间开销
- 内存碎片较多,可能导致内存分配失败
Mark-Sweep-Compact标记-整理
将所有存活对象移动到内存区域的开头,剩余的连续内存区域都是可用的空闲空间。
- 通过指针碰撞查找空闲空间,分配速度快
- 内存碎片少,内存分配失败概率低
- 复制对象会导致较长时间的 GC 停顿
Mark and Copy标记-复制
将内存划分为活动区间与空闲区间,前者用于动态分配对象,后者用于容纳 GC 存活对象。
GC 时只需将存活对象从前者复制到后者,然后交换两者的角色即可。
- 标记和复制在同一阶段同时进行,当存活对象少时回收效率极高
- 需要预留一个空闲空间用于容纳存活对象,造成内存浪费
CMS 回顾
CMS Concurrent Mark-Sweep
是一个采用 标记-清除 算法的老年代收集器。
它通过与应用程序线程并发执行大多数垃圾回收工作,来最大程度地减少由于 GC 导致的暂停。
通常情况下,CMS 收集器不会复制或压缩活动对象,这意味着无需移动活动对象即可完成垃圾回收。
然而过多的内存碎片可能造成分配失败,最终导致 FullGC。可以通过分配更大的堆来规避这一问题。
CMS 对老年代的回收可以分为以下几个步骤:
-
Initial Mark (STW)
初始标记
- 标记 GC Roots 直接可达的老年代对象
- 遍历新生代存活对象,标记直接可达的老年代对象
-
Concurrent Mark
并发标记
GC 线程遍历 Initial Mark 阶段标记出来存活的老年代对象,然后递归标记这些可达的对象。
该阶段与应用线程并发运行,期间会发生新生代对象晋升、老年代对象引用关系更新,需要对这些对象进行重新标记,避免发生遗漏。
CMS 用一个
card-table
管理老年代,并发标记过程中,某个对象的引用关系发生了变化,则将对象所在的内存块标记为 Dirty Card。CMS 使用增量更新
incremental update
解决并发修改导致的漏标问题:把 black 对象重新标记为 grey,下次重新扫描其引用。 -
Preclean
预清理
这一阶段主要是处理 Concurrent Mark 阶段中引用关系改变,导致没有标记到的存活对象的。通过并发地重新扫描这些对象,预清理阶段可以减少 Remark 阶段的 STW。
这个阶段会处理前一个阶段被标记为 Dirty Card 的部分,将其中变化了的对象作为 GC Root 再进行扫描并重新标记。
-
Abortable Preclean
可终止的预清理
这个阶段作用与 Preclean 类似,但可以通过设置 扫描时长(默认5秒)或 Eden 区使用占比(默认50%)控制本阶段的结束时机。
增加这一阶段的原因,是期待这期间能发生一次 YoungGC 清理无效的年轻代对象,减少 Remark 阶段扫描年轻代的时间。
-
Remark (STW)
重新标记
:这个阶段同时扫描 YoungGen 与 OldGen,重新标记整个老年代中所有存活对象。
由于之前的 Concurrent Mark 与 Preclean 阶段是与用户线程并发执行的,年轻代对老年代的引用可能已经发生了改变,Remark 要花很多时间处理这些改变,会导致长时间的 STW。
此外,即使新生代的对象已经不可达了,CMS 也会使用这些不可达的对象当做的 GC Roots 来扫描老年代,导致部分失效的老年代对象无法被及时回收。
可以加入参数 -XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次 YoungGC,回收掉年轻代的对象无用的对象。这样进行年轻代扫描时,只需要扫描 Survivor 区的对象即可,一般 Survivor 区非常小,这大大减少了扫描时间。
-
Concurrent Sweep
并发清理
至此,老年代所有存活的对象已经被标记完成。这个阶段主要是清除那些没有标记的对象并且回收空间。
被回收的空间会被添加到 空闲列表中,以供以后分配。这一过程可能会对空闲空间进行合并,但是不会移动存活对象。
由于该阶段是与应用线程并发运行的,自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,无法在当次收集中处理掉它们。只好留待下一次GC时再清理掉。这一部分垃圾就称为 浮动垃圾。
-
Resetting
重置
清除数据结构,并重置定时器,为下一轮 GC 做准备。
G1 算法
设计目的
G1 Garbage-First
是一种服务器端的垃圾收集器:
- 可以与应用程序线程并行运行,减少 STW
- 整理空闲空间减少内存碎片,但不引入较长的 GC 暂停时间
- 提供可预测的GC暂停时间,无需牺牲很多吞吐量
G1 能够在大内存的多处理器计算机上,保证 GC 暂停时间可控,并实现高吞吐量。
其最终目的是取代 CMS 成为服务端 GC 更好的解决方案:
- 采用 标记-整理 算法,可以避免使用细粒度的空闲列表进行分配。简化了收集器设计并消除了潜在的碎片问题。
- 使用 增量回收
incremental collecting
算法,其 GC 暂停时间比 CMS 更具可预测性,并允许用户指定期望的暂停时间。
基本概念
G1 将堆划分为一组大小相等的且连续的堆区域Region
:
G1 中新生代与老年代不再连续,每个区域可以在 Eden、Survivor 与 Old 之间切换角色。此外,还有一类被称为 Humongous 的巨型区域,用于容纳体积 ≥ 标准区域大小的50%的对象。JVM 通常会将内存划分为 2000个区域,每个大小从 1 到 32Mb 不等,由 JVM 在启动时通过 -XX:G1HeapRegionSize 指定。
每个区域会被进一步细分成多个卡片Card
,每个大小为 512Kb,用于实现细粒度的引用统计。
分区设计可以避免一次收集整个堆,每次 GC 只收集区域的一个子集 CSetcollection set
,其中必然包含所有 Young 区域,同时可能包括部分 Old 区域:
根据回收区域的不同,可以将 GC 分为:
- YoungGC:CSet 只包含 Young 区域
- MixedGC: CSet 同时包含 Young 与 Old 区域
- FullGC: 回收整个堆(可用空间耗尽时触发,单线程执行)
G1 根据存活对象的字节数统计每个区域的 活跃度liveness
,然后根据期望停顿时间来确定该 CSet 的大小,并保证那些垃圾多(活跃度低)的区域会被优先回收,故此得名 垃圾优先。
G1 的执行过程可以表示为由 3 个阶段组成的循环:
Young GC
堆中一开始只有 YoungGen,因此只会触发 YoungGC,将 Eden 与 Survivor 区域中的活动对象复制到另一个空闲的 Survivor 区域。


G1 中将 将存活对象复制到其他区域 的过程称为 疏散Evacuation
。为了减少停顿时间,疏散工作由多个 GC 线程并行完成。
YoungGC 过程中会根据预期目标停顿时间 -XX:MaxGCPauseMillis 动态调整新生代的大小,通过 -XX:G1NewSizePercent 参数可以人为干预这一过程,但会让预期停顿时间参数失效。
当堆的整体占用空间足够大时(超过45%),就会进入 Concurrent Marking 阶段。通过 -XX:InitiatingHeapOccupancyPercent 选项可以配置这一行为。
Concurrent Marking
与 CMS 类似,G1 中的并发标记包括多个阶段,其中一些阶段是并发的,另一些阶段则会 STW。
-
Initial Mark (STW)
初始标记
扫描并标记 GC Root 对象直接可达的老年代存活对象。
Initial Mark 并没有独立的执行阶段,而是嵌入 YoungGC 中执行的,其停顿时间会被分摊,因此实际的开销非常低。
-
Root Region Scan
扫描根区域
扫描 Root Region 并标记所有可达的老年代存活对象。
此处的 Root Region 就是先前 YoungGC 中生成的 Survivor 区域,其包含的对象都会被视为 GC Root。
为了避免移动对象对标记产生影响,该过程必须在下次 YongGC 启动前完成。
-
Concurrent Mark
并发标记
启动并发标记线程,扫描并标记整个堆中的存活对象(线程数可以通过 -XX:ConcGCThread 进行配置)。
为了避免重复标记,G1 使用 SATB
snapshot-at-the-beginning
算法解决漏标问题:应用线程对在 Concurrent Mark 执行期间进行的所有并发更新,都应保留先前的已知标记信息。该约束是通过预写屏障
pre-write barrier
实现:Concurrent Mark 扫描过程中,当应用线程修改某个字段时,会将先前的引用对象存储在日志缓冲区log buffers
中,然后交由并发标记线程处理。为了避免移动对象对标记产生影响,该过程必须在下次 YoungGC 启动前完成。所有的标记任务必须在堆满前完成,如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行 FullGC。
-
Remark (STW)
重新标记
启动并行标记线程,完成对整个堆中存活对象的标记(线程数可以通过 -XX:ParallelGCThread 进行配置)。
该阶段会暂停所有应用线程,避免发生引用更新,并完成对SATB 日志缓冲区中剩余对象的标记,找出所有未被访问的存活对象。
该阶段还执行一些额外的清理操作,例如:
- 卸载不可达的类(通过 -XX:+ClassUnloadingWithConcurrentMark 开启)
- 处理引用对象(弱引用、软引用、虚引用、最终引用)
-
Cleanup
清理垃圾
整理统计信息并识别出高收益的老年代分区,为 MixedGC 做准备。
主要工作有:
- RSet 梳理(后续说明)
- 识别回收收益高的老年代分区 (基于释放空间和暂停目标)
- 直接回收的没有活跃对象的空闲分区
此外还会执行一些清理工作,为下一次 Concurrent Marking 做好准备。
Mixed GC
MixedGC 主要流程与 YoungGC 类似,不同的地方在于 CSet 中包含了 Old 区域。
需要注意的是,Concurrent Marking 结束后,并不一定会立即触发 MixedGC,中间可能会穿插多次的 YoungGC。
当收集某个区域时,我们必须知道是否有来自非收集区域引用,来确定它们的活动性:
- 从非收集区域到收集区域的 incoming reference 是重要的(被非收集区引用的对象必须存活)
- 从收集区域到非收集区域的 outgoing reference 是可忽略的(非收集区域不参与GC)
但查找整个堆非常耗时,同时也失去了增量收集的优势。为了解决这一问题,G1 为每个区域维护了一个 RSetremembered set
,用于记忆从其他区域指向自己的引用。
收集过程
在执行收集时,RSet 中引用信息会扮演局部 GC Roots 的角色,避免耗时的引用查找,保证每个区域的 GC 能够独立进行:
注意,象如果 Old 区域中对在 Concurrent Marking 阶段被确定为垃圾,即使有外部引用,该对象也会被作为垃圾回收。
接下来发生的事情与其他收集器所做的相同:多个并行GC线程找出哪些对象是活动的,哪些对象是垃圾:
最后,释放空闲区域,将活动对象移到 Survivor 区域,并在必要时创建新对象:
RSet 维护
为了维护 RSet,在应用线程对字段执行写操作时,会触发写后屏障post-write barrier
:
为了减少写屏障带来的开销,该过程是异步的:
Dirty Card Queue
,然后由 Refine 线程将其拾取并将信息传播到被引用区域的 RSet。如果应用线程插入速度过快,会导致 Refine 线程来不及处理,那么应用线程将接管 RSet 更新的任务,从而导致性能下降。
总结
并发标记 与 增量收集 是 G1 实现高性能与可预测回收的关键。
对于 CPU 资源充足且对延迟敏感的服务端应用来说,G1 算法能够在大堆上提供良好的响应速度。
作为代价,额外的写屏障与更活跃GC线程,会对应用的吞吐量产生负面影响。
参考资料
- //www.oracle.com/technetwork/tutorials/tutorials-1876574.html
- //plumbr.io/handbook/garbage-collection-algorithms
- //medium.com/@hansrajchoudhary_88463/evolution-of-garbage-collection-on-java-garbage-first-garbage-collection-a3f39b1a9ae0
- //juejin.cn/post/6844903960550047757#heading-10
- //segmentfault.com/a/1190000021761004