Bootstrap

关于垃圾收集器G1与ZGC

一、G1收集器(JKD9默认收集器)

-XX:+UseG1GC

G1(Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多个处理器及大容量内存的机器,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能的特征。
在这里插入图片描述
在这里插入图片描述

G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可有2048个Region。

一般Region大小等于堆大小除以2048,如对大小为4096M,则Region大小为2M。

-XX:G1HeapRegionSize:手动指定Region大小,推荐不指定,使用默认计算方式。

G1保留年轻代和老年代的概念,但不再使用物理隔阂,均为连续或不连续的Region的集合。

默认年轻代对堆内存的占比为5%,若堆大小为4096M,则年轻代就占据200M左右的内存空间,对应大约100个Region。

-XX:G1NewSizePercent:设置新生代初始占比。
在系统运行中,JVM会不停给年轻代增加更多的Region,但最多新生代的占比不会超过60%,可通过上面参数调整。

年轻代中的Eden和Survivor对应的Region仍是8:1:1

一个Region可能之前是年轻代,在完成垃圾回收之后又可能变为老年代,Region区域的功能是动态的。

G1收集器对于对象转移到老年代的原则与之前的收集器相同,唯一不同的是对大对象的处理

G1专门分配给大对象的Region叫Humongous区,不会让大对象直接进入老年代的Region。

G1对大对象的判断规则:一个对象超过了一个Region大小的50%即为大对象

例如若每个Region为2M,有个大对象超过了1M,则会被放入Humongous中;若太大,则会横跨多个Region来存放。

Humongouw专门存放短期巨型对象,不用直接进入老年代,可节约老年代的空间,避免因为老年代空间不足而触发的GC开销。

G1收集器执行一次GC的运作过程步骤:

  1. 初始标记(Initial Mark, STW):

    暂停所有的其它线程,并记录gc roots直接能引用的对象,速度很快

  2. 并发标记(Concurrent Marking):

    同CMS的并发标记;

  3. 最终标记(Remark,STW):

    同CMS的重新标记;

  4. 筛选回收(Cleanup,STW):

    先要对各个Region的回收价值和成本进行排序根据用户所期望的GC停顿时间来制定回收计划

    -XX:MaxGCPauseMillis:指定GC停顿时间

    例如老年代此时有1000个Region都满了,由于预设的GC停顿时间,本次垃圾回收可能只能停顿200ms,根据之前回收成本计算得出回收其中800个Region刚好需要200ms,那么就只会回收800个Region的回收集合(Collection Set),尽量将GC导致的停顿时间控制在指定范围内。

    此阶段其实也可以做到和用户程序一起并发执行,但因为其只回收一部分Region,且时间由用户控制,因此停顿用户线程来大幅提高收集效率才是最佳选择。

    年轻代和老年代的回收算法均采用复制算法,将一个Region中的存活对象复制到另一个Region中,但不会像CMS那样回收之后出现内存碎片需要进行整理,G1采用复制算法回收几乎不会出现太多内存碎片
    在这里插入图片描述

CMS回收阶段是和用户线程一起并发执行的,G1由于内部实现过于复杂,故暂时没法实现并发收集,但到了Shenandoah就实现了并发收集了,可看成是G1的升级版

G1收集器在后台维护一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(也是G1收集器Grabage-First这个名字的由来)。

例如一个Region花200ms能回收10M垃圾,另一个Region花50ms能回收20M垃圾,在回收时间有限的情况下,G1会优先回收后者Region。

这种使用Region划分内存空间及有优先级的区域回收方式,保证G1收集器在有限时间内可以进行尽可能高的收集效率。

JDK1.7以上版本的Java虚拟机重要的进化特征:

  1. 并行与并发

    G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或CPU核心)来缩短STW的停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍可以通过并发方式让Java线程继续执行。

  2. 分代收集

    G1虽然不需要其他收集器配合即可独立管理整个GC堆,但仍旧保留了分代概念。

  3. 空间整合

    与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部来看是基于复制算法实现的。

  4. 可预测的停顿

    这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,可让使用者明确指定在一个长度M毫秒的时间片段内完成垃圾收集。

    XX:MaxGCPauseMillis:指定时间片段。

    由用户指定期望的停顿时间是G1收集器很强大的一个功能,通过设置不同的期望停顿时间,可取得不同场景中关注吞吐量和关注延迟之间的最佳平衡。但对于设置的期望停顿时间必须要符合实际,毕竟G1冻结用户线程来复制对象,停顿时间要有个最低限度。

    默认的停顿时间为200ms,一般情况,回收阶段占据几十到一百,甚至接近两百毫秒都为正常。但若将停顿时间调得非常低,譬如20ms,很可能会出现由于停顿时间太短,导致每次选出来的回收集合只占内存极小部分,由此引发收集器收集的速度越发跟不上分配器分配的速度,最终导致垃圾逐渐堆积。

    很可能一开始收集器还能从空闲的堆内存中获得一些喘息时间,但随着应用的运行时间拉长就逐渐无法喘息,最终堆会被垃圾占满而触发full gc,导致性能降低,因此将期望停顿时间设为一两百毫秒或两三百毫秒是比较合理的。

G1垃圾收集分类

YoungGC

现有的Eden区放满后并不会立刻触发YoungGC,而是G1会先计算现在Eden区回收大概需多久时间,若回收时间远小于参数-XX:MaxGCPauseMills设定的值,则增加年轻代的Region,继续给新对象存放,不会马上YoungGC,直到下一次Eden区放满,当G1计算的回收时间接近参数-XX:MaxGCPauseMills设定的值,则就触发YoungGC。

MixedGC

非full gc,老年代的堆占有率达到参数-XX:InitiatingHeapOccupancyPercent设定的值则触发,回收所有的Young区和部分Old区(根据期望的GC停顿时间确定Old区垃圾收集的优先顺序)及大对象Humongous区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空Region能够承载拷贝对象就会触发一次full gc

Full GC

停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)

G1收集器参数设置

-XX:+UseG1GC:使用G1收集器

-XX:ParallelGCThreads:指定GC工作的线程数量;

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

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

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

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

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

-XX:MaxTenuringThreshold:最大年龄阈值(默认15);

-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),例如堆默认有2048个Region,若有接近1000个Region都是老年代的Region,则可能就要触发MixedGC了;

-XX:G1MixedGCLiveThresholdPercent(默认85%) :Region中的存活对象低于这个值时才会回收该Region,若超过这个值,存活对象过多,回收的的意义不大;

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

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

G1垃圾收集器优化建议

若参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。

那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。

或者是年轻代gc后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。

因此核心是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证年轻代gc别太频繁的同时,还要考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc

适合使用G1的场景

  1. 50%以上的堆被存活对象占用;
  2. 对象分配和晋升的速度变化非常大;
  3. 垃圾回收时间特别长,超过1秒;
  4. 8GB以上的堆内存(建议值);
  5. 停顿时间是500ms以内。

优化每秒十几万并发系统的JVM

Kafka类似的支撑高并发消息系统,每秒处理几万甚至几十万消息很正常,一般来说部署kafka需要用大内存机器(如64G),即给年轻代分配个三四十G的内存用来支撑高并发处理,此处涉及到一个问题,之前常说的对于eden区的young gc是很快的,在这种情况下则不可能,因为内存太大,处理要花不少的时间,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟,这就意味整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,这显然不行。

因此要进行优化,便可使用G1收集器,设置-XX:MaxGCPauseMills为50ms,假设50ms能够回收三四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。

G1天生就适合这种大内存机器的JVM运行,可以比较完美地解决大内存垃圾回收时间过长的问题。

二、ZGC收集器

-XX:+UseZGC

JDK11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于是Azul System公司开发的C4(Concurrent Continuously Compacting Collector) 收集器。
在这里插入图片描述

JDK11开始支持Linux64位版本,JDK14开始支持Windows版本。

ZGC目标

在这里插入图片描述

  • 支持TB量级的堆。

目前使用的生产环境的硬盘还没达到TB级,也就是说可以满足未来至少十年内所有JAVA应用的需求。

  • 最大GC停顿时间不超10ms。

目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的。

  • 奠定未来GC特性的基础。
  • 最糟糕的情况下吞吐量会降低15%。

这并非是多大的缺点,因为其停顿时间足够优秀。可以通过扩容解决吞吐量问题。此外,Oracle官方提到它最大的优点是:它的停顿时间不会随着堆的增大而增长!换句话说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下!

ZGC不分代(暂时)

单代,即ZGC没有分代。之前的垃圾回收器之所以分代,是因为源于“大部分对象朝生夕死”的假设,事实上大部分系统的对象分配行为也确实符合这个假设。
ZGC不分代其实是因为分代实现起来麻烦,作者就先实现出一个比较简单可用的单代版本,后续会优化。

ZGC内存布局

ZGC收集器是一款基于Region内存布局的, 暂时不设分代, 使用读屏障、 颜色指针等技术来实现可并发的标记-整理算法, 以低延迟为首要目标的垃圾收集器。
在这里插入图片描述

ZGC的Region分为大、 中、 小,三类容量:

  • 小型Region(Small Region) : 容量固定为2MB, 用于放置小于256KB的小对象。
  • 中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。
  • 大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配的, 因为复制一个大对象的代价非常高昂。

重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,详情见下文

NUMA-aware

NUMA对应的有UMA,UMA即Uniform Memory Access Architecture,
NUMA就是Non Uniform Memory Access Architecture。

UMA表示内存只有一块,所有CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有竞争就会有锁,有锁效率就会受到影响,且CPU核心数越多,竞争就越激烈。

NUMA每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,效率自然更高。
在这里插入图片描述
服务器的NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC能自动感知NUMA架构并充分利用NUMA架构特性。

颜色指针

Colored Pointers,即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中。
在这里插入图片描述
每个对象有一个64位指针,这64位被分为:

  • 18位:预留给以后使用;
  • 1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
  • 1位:Remapped标识,设置此位的值后,对象未指向relocation set中

relocation set表示需要GC的Region集合

  • 1位:Marked1标识;
  • 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
  • 42位:对象的地址(所以它可支持2^42=4T内存)。

2个marked标识

每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
GC周期1:使用marked0, 则周期结束所有引用marked标记都会成为01。
GC周期2:使用marked1, 则将marked标记为10,所有引用都能被重新标记。
通过对配置ZGC后对象指针分析可知,对象指针必须是64位,因此ZGC无法支持32位操作系统,同样的也就无法支持压缩指针(CompressedOops,压缩指针也是32位)。

颜色指针的三大优势

  1. 一旦某个Region的存活对象被移走后,此Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,因此理论上只要还有一个空闲Region,ZGC就能完成收集。
  2. 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
  3. 颜色指针具备强大的扩展性,作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

Colored Pointers:译为颜色指针或染色指针

读屏障

之前的GC都是采用写屏障Write Barrier,而ZGC则采用了完全不同的方案,即读屏障Load Barrier,这个是ZGC一个非常重要的特性。
在标记和移动对象的阶段,每次从堆里对象的引用类型中读取一个指针的时候,都需要加上一个Load Barrier。
如下方代码所示:
在这里插入图片描述
第1行代码会尝试读取堆中的一个对象引用obj.fieldA并赋给引用o。

fieldA也是一个对象时才会加上读屏障

若此时对象在GC时被移动了,JVM就会在之后加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针修正到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,且不需要STW。

JVM判断对象是否被移动过:
利用之前提到的颜色指针,若指针是Bad Color,那么程序还不能往下执行,需要slow path,即修正指针;若指针是Good Color,则会正常往下执行。

此动作非常像JDK并发中用到的CAS自旋,读取的值发现已经失效了,需要重新读取。
而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。

后面3行代码都不需要加读屏障:
Object p = o这行代码并没有从堆中读取数据;
o.doSomething()也没有从堆中读取数据;
obj.fieldB不是对象引用,而是原子类型。
正是因为Load Barrier的存在,所以会导致配置ZGC的应用的吞吐量会变低。
官方的测试数据是需要多出额外4%的开销:
在这里插入图片描述
判断对象为Bad Color或Good Color:
根据前文所提的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,即可知道当前对象是Bad Color或是Good Color。

虽然低42位指针可以支持4T内存,但却不能通过预约更多位给对象地址来达到支持更大内存。
因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T。

ZGC运作过程

四大阶段:

在这里插入图片描述

  1. 并发标记(Concurrent Mark)

    与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新染色指针中的Marked 0、 Marked 1标志位。

  2. 并发预备重分配(Concurrent Prepare for Relocate)

    此阶段需根据特定的查询条件统计出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。

  3. 并发重分配(Concurrent Relocate)

    重分配是ZGC执行过程中的核心阶段,此过程把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,若用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障)所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的自愈(Self-Healing)能力。

ZGC的颜色指针因为“自愈”能力,所以只有第一次访问旧对象会变慢, 一旦重分配集中某个Region的存活对象都复制完毕后,此Region就会立即释放用于新对象的分配,但是转发表不能释放, 因为可能还有其他访问在使用这个转发表。

  1. 并发重映射(Concurrent Remap)

进行修正整个堆中指向重分配集中旧对象的所有引用,但ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,因为同样都要遍历所有对象,合并便可节省一次遍历对象图的开销。一旦所有指针都被修正后, 原来记录新旧对象关系的转发表就可以释放掉了。

ZGC存在的问题

ZGC最大的问题是浮动垃圾。
ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。
若ZGC全过程需要执行10分钟,在此期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。

ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时地被回收

解决方案

目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但这也是一个治标不治本的方案。
若要从根本上解决这个问题,还需引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。

ZGC参数设置

启用ZGC比较简单,设置JVM参数即可:
-XX:+UseZGC
-XX:+UnlockExperimentalVMOptions
ZGC调优参数并不多,远不像CMS那么复杂。与G1一样,可以调优的参数都比较少,大部分工作JVM能很好地自动完成。

ZGC可以调优的参数

在这里插入图片描述

ZGC触发时机

  1. 定时触发

默认不使用,可通过ZCollectionInterval参数配置。

  1. 预热触发

最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用。

  1. 分配速率

基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC。

耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间

  1. 主动触发

默认开启,可通过ZProactive参数配置
距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发。

三、垃圾收集器的选择

  1. 优先调整堆的大小让服务器自己选择;
  2. 若内存小于100M,使用串行收集器;
  3. 若是单核,且没有停顿时间的要求,串行或JVM自己选择;
  4. 若允许停顿时间超过1秒,选择并行或者JVM自己选择;
  5. 若响应时间最重要,且不能超过1秒,使用并发收集器;
  6. 4G以下可以用Parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC。

有连线的可搭配使用:
在这里插入图片描述

四、安全点与安全区域

安全点:

指代码中一些特定的位置,当线程运行到这些位置时,它的状态是确定的,这样JVM就可以安全地进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发,是需要等待所有线程运行到安全点后才能触发。

上述安全点位置:

  1. 方法返回之前;
  2. 调用某个方法之后;
  3. 抛出异常的位置;
  4. 循环的末尾。

大体实现思想:当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
轮询标志的地方和安全点是重合的。

安全区域:

Safe Point是对正在执行的线程设定的。

如若一个线程处于Sleep或中断状态,它就不能响应JVM的中断请求,再运行到Safe Point上,因此JVM引入了Safe Region。

Safe Region是指在一段代码片段中,引用关系不会发生变化。在此区域内的任意地方开始GC都是安全的。

;