G1垃圾回收器

G1回收器和CMS比起来,有以下不同:

  1. 并行性: G1 在回收期间,可以由多个 GC 线程同时工作,有效利用多核计算能力。
  2. 并发性: G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此一般来说,不会在整个回收期间完全阻塞应用程序。
  3. 分代 GC: G1 依然是一个分代收集器,但是和之前回收器不同,它同时兼顾年轻代和老年代的回收工作。而对比其他回收器,它们或者工作在年轻代,或者工作在老年代。因此,这里是一个很大的不同。
  4. 空间整理: G1 在回收过程中,会进行适当的对象移动,不像 CMS, 只是简单地标记清理对象,在若干次 GC 后,CMS 必须进行一次碎片整理。而 G1 不同,它每次回收都会有效地复制对象,减少空间碎片。
  5. 可预见性:由于分区的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿也能得到较好的控制。

Region

G1垃圾回收器把堆划分成一个个大小相同的Region。在HotSpot的实现中,整个堆被划分成2048左右个Region。每个Region的大小在1-32MB之间,具体多大取决于堆的大小。

传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代(JDK 8去除了永久代,引入了元空间Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。如下图所示:

而G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。如下图所示:

除了young和old还有一类十分特殊的Humongous。就是一个对象的大小超过了某一个阈值——HotSpot中是Region的1/2,那么它会被标记为Humongous。有如下几个特征:

  • H-obj直接分配到了old gen,防止了反复拷贝移动。
  • H-obj在global concurrent marking阶段的cleanup 和 full GC阶段回收。
  • 在分配H-obj之前先检查是否超过 initiating heap occupancy percent和the marking threshold, 如果超过的话,就启动global concurrent marking,为的是提早回收,防止 evacuation failures 和 full GC。

Region可以说是G1回收器一次回收的最小单元。即每一次回收都是回收N个Region。这个N是多少,主要受到G1回收的效率和用户设置的软实时目标有关。每一次的回收,G1会选择可能回收最多垃圾的Region进行回收。与此同时,G1回收器会维护一个空间Region的链表。每次回收之后的Region都会被加入到这个链表中。

在分配内内存的时候,每一次都只有一个Region处于被分配的状态中,被称为current region。在多线程的情况下,这会带来并发的问题。G1回收器采用和CMS一样的TLABs的手段。即为每一个线程分配一个Buffer,线程分配内存就在这个Buffer内分配。但是当线程耗尽了自己的Buffer之后,需要申请新的Buffer。这个时候依然会带来并发的问题。G1回收器采用的是CAS(Compate And Swap)操作。

为线程分配Buffer的过程大概是:

1. 记录top值;

2. 准备分配;

3. 比较记录的top值和现在的top值,如果一样,则执行分配,并且更新top的值;否则,重复1;

当一个线程在自己的Buffer里面分配的时候,虽然Buffer里面还有剩余的空间,但是却因为分配的对象过大以至于这些空闲空间无法容纳,此时线程只能去申请新的Buffer,而原来的Buffer中的空闲空间就被浪费了。Buffer的大小和线程数量都会影响这些碎片的多寡。

 

Remember Set和Card Table

RS(Remember Set)是一种抽象概念,用于记录从非收集部分指向收集部分的指针的集合。在G1回收器里面,RS被用来记录从其他Region指向一个Region的指针情况。因此,一个Region就会有一个RS。这种记录可以带来一个极大的好处:在回收一个Region的时候不需要执行全堆扫描,只需要检查它的RS就可以找到外部引用,而这些引用就是initial mark的根之一。

那么,如果一个线程修改了Region内部的引用,就必须要去通知RS,更改其中的记录。为了达到这种目的,G1回收器引入了一种新的结构,CT(Card Table)——卡表。每一个Region,又被分成了固定大小的若干张卡(Card)。每一张卡,都用一个Byte来记录是否修改过。卡表即这些byte的集合。实际上,如果把RS理解成一个概念模型,那么CT就可以说是RS的一种实现方式。

在RS的修改上也会遇到并发的问题。因为一个Region可能有多个线程在并发修改,因此它们也会并发修改RS。为了避免这样一种冲突,G1垃圾回收器进一步把RS划分成了多个哈希表。每一个线程都在各自的哈希表里面修改。最终,从逻辑上来说,RS就是这些哈希表的集合。哈希表是实现RS的一种通常的方式之一。它有一个极大的好处就是能够去除重复。这意味着,RS的大小将和修改的指针数量相当。而在不去重的情况下,RS的数量和写操作的数量相当。

https://upload-images.jianshu.io/upload_images/2579123-e0b8898d895aee05.png

RS的虚线表名的是,RS并不是一个和Card Table独立的,不同的数据结构,而是指RS是一个概念模型。实际上,Card Table是RS的一种实现方式。

逻辑上说每个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。

Remember Set的写屏障

写屏障是指,在改变特定内存的值(实际上也就是写入内存)的时候额外执行的一些动作。写屏障通常用于在运行时探测并记录回收相关指针(interesting pointer),在回收器只回收堆中部分区域的时候,任何来自该区域外的指针都需要被写屏障捕获,这些指针将会在垃圾回收的时候作为标记开始的根。JAVA使用的其余的分代的垃圾回收器,都有写屏障。举例来说,每一次将一个老年代对象的引用修改为指向年轻代对象,都会被写屏障捕获,并且记录下来。因此在年轻代回收的时候,就可以避免扫描整个老年代来查找根。

G1垃圾回收器的写屏障和RS是相辅相成的,也就是记录Region内部的指针。这种记录发生在写操作之后。对于一个写屏障来说,过滤掉不必要的写操作是十分有必要的。这种过滤既能加快赋值器的速度,也能减轻回收器的负担。G1垃圾回收器采用的双重过滤

  1. 过滤掉同一个Region内部引用;
  2. 过滤掉空引用;

过滤掉这两个部分之后,可以使RS的大小大大减小。

G1的垃圾回收器的写屏障使用一种两级的log buffer结构:

  1. global set of filled buffer:所有线程共享的一个全局的,存放填满了的log buffer的集合;
  2. thread log buffer:每个线程自己的log buffer。所有的线程都会把写屏障的记录先放进去自己的log buffer中,装满了之后,就会把log buffer放到 global set of filled buffer中,而后再申请一个log buffer;

Collect Set

Collect Set(CSet)是指,在Evacuation阶段,由G1垃圾回收器选择的待回收的Region集合。G1垃圾回收器的软实时的特性就是通过CSet的选择来实现的。对应于算法的两种模式fully-young generational mode和partially-young mode,CSet的选择可以分成两种:

  • 在fully-young generational mode下:该模式下CSet将只包含young的Region。G1将调整young的Region的数量来匹配软实时的目标;
  • 在partially-young mode下:该模式会选择所有的young region,并且选择一部分的old region。old region的选择将依据在Marking cycle phase中对存活对象的计数。G1选择存活对象最少的Region进行回收。

对于old->young和old->old的跨代对象引用,只要扫描对应的CSet中的RSet即可

SATB(snapshot-at-the-beginning)

SATB(snapshot-at-the-beginning),是最开始用于实时垃圾回收器的一种技术。G1垃圾回收器使用该技术在标记阶段记录一个存活对象的快照

然而在并发标记阶段,应用可能修改了原本的引用,比如删除了一个原本的引用。这就会导致并发标记结束之后的存活对象的快照和SATB不一致。G1是通过在并发标记阶段引入一个写屏障来解决这个问题的:每当存在引用更新的情况,G1会将修改之前的值写入一个log buffer(这个记录会过滤掉原本是空引用的情况),在最终标记(final marking phase)阶段扫描SATB,修正SATB的误差。

SATB的log buffer如RS的写屏障使用的log buffer一样,都是两级结构,作用机制也是一样的。

Marking bitmaps和TAMS

Marking bitmap是一种数据结构,其中的每一个bit代表的是一个可用于分配给对象的起始地址。举例来说:

其中addrN代表的是一个对象的起始地址。绿色的块代表的是在该起始地址处的对象是存活对象,而其余白色的块则代表了垃圾对象。

G1使用了两个bitmap,一个叫做previous bitmap,另外一个叫做next bitmap。previous bitmap记录的是上一次的标记阶段完成之后的构造的bitmap;next bitmap则是当前正在标记阶段正在构造的bitmap。在当前标记阶段结束之后,当前标记的next bitmap就变成了下一次标记阶段的previous bitmap。

TAMS(top at mark start)变量,是一对用于区分在标记阶段新分配对象的变量,分别被称为previous TAMS和next TAMS。在previous TAMS和next TAMS之间的对象则是本次标记阶段时候新分配的对象。如图:

白色region代表的是空闲空间,绿色region代表是存活对象,橙色region代表的在此次标记阶段新分配的对象。注意的是,在橙色区域的对象,并不能确保它们都事实上是存活的。

 

算法详解

G1提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的。

  •  * Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
  • * Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

整个算法可以分成两大部分:

  1. Marking cycle phase:标记阶段,该阶段是不断循环进行的;
  2. Evacuation phase:该阶段是负责把一部分region的活对象拷贝到空Region里面去,然后回收原本的Region空间,该阶段是STW(stop-the-world)的;

而算法也可以分成两种模式:

  1. fully-young generational mode:有时候也会被称为young GC,该模式只会回收young region,算法是通过调整young region的数量来达到软实时目标的;
  2. partially-young mode:也被称为Mixed GC,该阶段会回收young region和old region,算法通过调整old region的数量来达到软实时目标;
  3. Mixed GC可能来不及回收old region。也就说,在需要分配老年代的对象的时候,并没有足够的空间。这个时候就只能触发一次full GC。

算法会自动在young GC和mixed GC之间切换,并且定期触发Marking cycle phase。HotSpot的G1实现允许指定一个参数InitiatingHeapOccupancyPercent,在达到该参数的情况下,就会执行marking cycle phase。

算法并不使用在对象头增加字段来标记该对象,而是采用bitmap的方式来记录一个对象被标记的情况。这种记录方法的好处就是在使用这些标记信息的时候,仅仅需要扫描bitmap而已。G1统计一个region的存活的对象,就是依赖于bitmap的标记。

 

Marking Cycle Phase 并发标记周期

算法的Marking cycle phase大概可以分成五个阶段:

Initial marking phase 初始标记

它标记了从GC Root开始直接可达的对象。G1收集器扫描所有的根。该过程是和young GC的暂停过程一起的;因为之前的新生代 GC 也要进行类似的工作,所以这里的初始标记是和新生代 GC 共用同一个 STW 时间,也就是说初始标记和新生代 GC 是伴随在一起进行的。

 

Root region scanning phase根区域扫描

由于初始阶段和新生代 GC 是一同进行的,所以初始标记后(新生代 GC 结束),eden 被清空,并且存活目标被移入 survivor 区。所以,在根扫描阶段,将扫描 survivor 区直接可达的老年代区域,并标记这些直接可达的对象。可以和应用程序并发执行。但是根扫描阶段不能和下一次新生代 GC 一同进行(因为根扫描阶段会使用到 survivor 区的数据,而下一次新生代 GC 会修改 survivor 区的内容),因此如果在根扫描阶段新生代 GC 被触发(eden 空间不足),这次新生代 GC 会等到根扫描阶段结束后才进行,当这种情况发生时,新生代 GC 的停顿时间会比较长。

 

Concurrent marking phase发标记

和 CMS 类似,根据根扫描的结果(老年代中可以被 GC Root 或存活新的生代对象直接引用的对象)扫描整个堆,并做好标记,这个过程可以和应用程序并发执行。该过程可以被young GC所打断。如果在并发标记的时候,出现了引用修改(不包含新分配内存给对象),那么写屏障会把这些引用的原始值捕获下来,记录在log buffer中。而后再处理。后续的所有的标记,都是从原来的值出发,而不是从新的值出发的。

 

Remark phase重新标记

也叫final marking phase。标记那些在并发标记阶段发生变化的对象,将被回收。该阶段只需要扫描SATB(Snapshot At The Beginning)的buffer,处理在并发阶段产生的新的存活对象的引用。

该阶段是一个STW的阶段。引入该阶段的目的,是为了能够达到结束标记的目标。要结束标记的过程,要满足三个条件:

  1. concurrent marking已经追踪了所有的存活对象;
  2. marking stack是空的;
  3. 所有的log都被处理了;

前两个条件是很容易达到的,但是最后一个是很困难的。如果不引入一个STW的remark过程,那么应用会不断的更新引用,也就是说,会不断的产生log,因而永远也无法达成完成标记的条件。

 

Cleanup phase独占清理

独占清理是需要 STW 的,这一阶段的目标是计算各个区域的存活对象和 GC 对象的比例并进行排序,识别出可供混合回收的区域,实际的内存复制过程是在混合回收中进行。这个阶段还会更新记忆集Remembered Set

该阶段在计算Region中存活对象的时候,是STW(Stop-the-world)的,而在重置Remember Set的时候,却是可以并行的;

该阶段主要完成:

  1. 统计存活对象,这是利用RS和bitmap来完成的,统计的结果将会用来排序region,以用于下一次的Collect Set的选择;
  2. 重置RSet;

并发清除Evacuation

只要堆的使用率达到了某个阈值,就必然会触发Evacuation。这是为了确保在Evacuation的时候有足够的空闲Region来容纳存活对象。

在young GC的情况下,G1会选择N个region作为CSet,该CSet首先需要满足软实时的要求,而一旦已经有N个region已经被分配了,那么就会执行一次Evacuation。

G1会尽可能的执行mixed GC。唯一的限制就是mix GC也需要满足软实时的要求。

G1触发Evacuation的原则大概是:

  1. 如果被分配的young region数量满足young GC的要求,那么就会触发young GC;
  2. 如果被分配的young region数量不满足young GC,就会进一步考察加上old region的数量,能否满足old GC的要求;

三色标记法

无论是在 CMS 中,还是在 G1 中都涉及存活对象的并发标记过程,因为该过程和应用程序是并发进行的,所以应用程序可能会修改引用关系,进而出现漏标和错标。错标不会影响程序的正确性,只是会造成前面所说的浮动垃圾。但漏标则会将存活的对象当做垃圾清理掉,之后的程序运行就有可能出错。

为了分析这个漏标的问题,可以用 3 种颜色来区分一个对象被扫描的进度(三色标记法):

  • 白色:本对象未被标记为存活,标记阶段结束后,会被当做垃圾回收
  • 灰色:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完。全部访问后,会转换为黑色。
  • 黑色:本对象已访问过,而且本对象引用到的其他对象也全部访问过了。

假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在【白色集合】中;
  2. 将 GC Roots 直接引用到的对象挪到 【灰色集合】中;
  3. 从灰色集合中获取对象:
  • 将本对象引用到的其他对象全部挪到 【灰色集合】中;
  • 将本对象挪到【黑色集合】里面。
  1. 重复步骤3,直至【灰色集合】为空时结束。
  2. 结束后,仍在【白色集合】的对象即为 GC Roots 不可达,可以进行回收。

当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

多标-浮动垃圾

假设已经遍历到 E(变为灰色了),此时应用执行了 objD.fieldE = null (D > E 的引用断开):

这部分本应该回收 但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

漏标

一个 white 对象(存活对象,但是还没被扫描到)在并发标记阶段被漏标的一种情况是:

  1. 黑色对象重新引用了该白色对象;即黑色对象成员变量增加了新的引用。
  2. 灰色对象断开了白色对象的引用(直接或间接的引用);即灰色对象原来成员变量的引用发生了变化。

此时切回 GC 线程继续跑,因为 E 已经没有对 G 的引用了,所以不会将 G 放到灰色集合;尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。

最终导致的结果是:G 会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

因此,要避免这类对象的漏标,只需要打破上述两个条件中的一个即可。CMS 的方案中打破的是第一条,该方案也被称为增量更新(Incremental update),当 ObjectA.field1 = ObjectB 时,CMS 将 ObjectA 所处的内存区标记为 dirty,之后会重新扫描所有 dirty 内存区的对象,这实际上可以等价于当我要将一个 white 对象赋值到一个 black 对象的 field 上时,会将该 black 对象的颜色改成 gray,这意味着它的所有 field 会被重新扫描。

而 G1 的思路是打破第二条条件,将对象 G 记录起来,然后作为灰色对象再进行遍历即可。例如,当进行 ObjectD.field = ObjectG.field1 then ObjectE.field1 = null 赋值操作时,G1 不会重新扫描 ObjectD 的所有 field,而是将 ObjectG.field1 提前标记为 gray(本质上是将该对象放入扫描栈,栈中的对象会在并发标记阶段和重新标记阶段被扫描),然后再进行赋值。你可以理解为条件 2 中的 gray 对象的对应 field 被提前扫描了,变成了 gray。

 

G1:写屏障 + SATB

当对象 E 的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将 E 原来成员变量的引用对象 G 记录下来:

当原来成员变量的引用发生变化之前,记录下原来的引用对象。

这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的 GC Roots 确定后,当时的对象图就已经确定了。
        比如 当时 D 是引用着 G 的,那后续的标记也应该是按照这个时刻的对象图走(D 引用着 G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。

SATB 破坏了条件二:【灰色对象断开了白色对象的引用】,从而保证了不会漏标。

CMS:写屏障 + 增量更新

当对象 D 的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将 D 新的成员变量引用对象 G 记录下来:

当有新引用插入进来时,记录下新的引用对象。

这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。

增量更新破坏了条件一:【黑色对象重新引用了该白色对象】,从而保证了不会漏标。

 

ZGC:读屏障

读屏障是直接针对第一步:var G = objE.fieldG;,当读取成员变量时,一律记录下来:

这种做法是保守的,但也是安全的。因为条件一中【黑色对象重新引用了该白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。

 

G1收集器参数

‐XX:+UseG1GC -> 开启G1收集器

‐XX:G1HeapRegionSize -> 指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区

‐XX:MaxGCPauseMillis -> 目标暂停时间(默认200ms)

‐XX:G1NewSizePercent -> 新生代内存初始空间(默认整堆5%)

‐XX:G1MaxNewSizePercent -> 新生代内存最大空间

‐XX:TargetSurvivorRatio -> Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代

‐XX:InitiatingHeapOccupancyPercent -> 老年代占用空间达到整堆内存阈值(默认45%),则执行 新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近 1000个region都是老年代的region,则可能就要触发MixedGC了

‐XX:G1HeapWastePercent -> 默认5%, gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他 Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的 Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着 本次混合回收就结束了。

‐XX:G1MixedGCLiveThresholdPercent -> 默认85%,region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。

‐XX:G1MixedGCCountTarget -> 在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
G1(Garbage First)垃圾回收是一种低延迟的垃圾回收,它可以在不影响应用程序吞吐量的情况下,有效地处理大量的内存垃圾。下面是G1垃圾回收的执行流程: 1. 初始标记(Initial Mark):该阶段的目标是标记所有的根对象,并且标记从根对象直接可达的对象。为了达到这个目的,G1垃圾回收会扫描所有的Java线程的栈,以及记录下所有的GC Root。 2. 并发标记(Concurrent Mark):在初始标记之后,G1垃圾回收会开始并发的标记所有从根对象可达的对象。这是一个并发的过程,不会阻塞应用程序的执行。 3. 最终标记(Final Mark):在并发标记之后,G1垃圾回收会再次暂停应用程序的执行,以完成所有未被标记的存活对象的标记。这个过程与初始标记是类似的。 4. 筛选回收(Live Data Counting and Evacuation):在最终标记之后,G1垃圾回收会计算每个区域中存活的数据量。然后,它会选定一些区域作为回收集(Collection Set),将这些区域中的存活对象复制到空闲的区域中,并将这些区域标记为可回收的。 5. 清除(Cleanup):在筛选回收之后,G1垃圾回收会开始清理所有被标记为可回收的区域。 需要注意的是,G1垃圾回收是一个全局垃圾回收,因此它不仅仅会处理单个堆区域的垃圾回收,而是会处理整个Java堆。同时,它还会根据应用程序运行的情况,动态地调整回收集的大小,以达到最佳的垃圾回收效果。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值