JVM-17(垃圾收集器)下

目录

17.6 CMS回收器:低延迟

垃圾收集底层算法实现

记忆集与卡表

17.7 G1回收器:区域化分代式

17.8 垃圾回收器总结

17.9 GC日志总结


17.6 CMS回收器:低延迟

  • -XX:+UseConcMarkSweepGC===>只能用于老年代
  • CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机第一款真正意义上的并发收集器,他第一次实现了让垃圾收集线程与用户线程同时工作.
  • CMS收集齐的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间.停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验
    • 目前很大一部分的Java应用集中在互联网或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间越短,给用户带来较好的体验,CMS收集器就非常符合这类应用的需求
  • CMS的垃圾收集算法采用标记-清除算法,并且也会”Stop-the-world”
  • 不幸的是,CMS收集器作为老年代的收集器,新生代只能选择PraNew或者Serial收集器中的一个.
  • CMS的工作原理:整个过程分为4个主要阶段:初始标记,并发标记,重新标记,并发清除

  •  
    • 初始标记(Initial-Mark)阶段:在这个阶段,程序中所有的工作线程将会因为STW机制而出现短暂的暂停,这个阶段的只要任务仅仅只是标记出GC Roots能直接关联到的对象.一旦标记完成之后就会恢复被暂停的所有应用的线程.由于直接关联对象比较小,所以这里的速度非常快
    • 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,用户线程可以与垃圾收集线程一起并发执行.因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变
    • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修改并发标记期间,因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记稍长一些,但也比并发标记阶段的时间短.主要用到三色标记里的增量更新算法(见下面详解)做重新标记
    • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间.由于不需要移动存活对象(采用的是标记-清除算法),所以这个阶段也是可以与用户线程同时并发的.并发清除过程中,可能老年代突然会有一个大对象,该对象并没有被标记,是否会被删除掉??????
    • 并发重置:重置本次GC过程中的标记数据
  • 尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和重新标记这两个阶段中仍然需要执行STW机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要STW,只是尽可能的缩短暂停时间
  • 由于最耗时的并发标记与并发清除阶段都是不需要暂停工作,所以整体的回收是低停顿的.
  • 另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序有足够的内存可用.因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了在进行收集,而是当堆内存达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行,要是CMS运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure”失败,这时虚拟机将启动后备预案,临时启用Serial Old收集器来重新进行老年代的垃圾收集,这时候停顿时间就很长了.
  • CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免的会产生一下内存碎片.那么CMS在为新对象分配内存空间时,将无法使用指针碰撞技术,而只能够选择空闲列表(Free List)执行内存分配

  • 为什么不把算法换成Mark-Compact呢
    • 答案其实很简单,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存没有使用(保证用户线程能够继续执行,前提是他的运行的资源不受影响).Mark-Compact更适合STW这种常见下使用
  • CMS的优缺点
    • 优点
      • 并发收集
      • 低延迟
  • CMS的弊端
    • 会产生内存碎片:并发清除后,在无法分配大对象的情况下,不得不提前触发Full GC,当然通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后在做整理
    • CMS收集器对CPU资源非常敏感:在并发阶段,他虽然不会导致用户停顿,但是会因为垃圾回收占用了一部分线程而导致应用程序变慢,总吞吐量会降低 
    • CMS收集器无法处理浮动垃圾(并发标记阶段和并发清理阶段,用户线程产生的新垃圾):在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些未被回收的内存空间
    • 执行过程中的不确定性:CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了在进行收集,而是当堆内存达到某一阈值时,便开始进行回收,如果此刻又来了大对象,但是此时老年代放不下(特别是在并发标记和并发清理阶段会出现),也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收
  • CMS收集器可以设置的参数
    • -XX:+UseConcMarkSweepGC 手动指定使用CMS收集器执行内存回收任务
      • 开启该参数后会自动将-XX:+UseParNew打开.即ParNew(Young区)+CMS(old区)+Serial Old的组合
    • -XX:+ConcGCThreads:并发的GC线程数
    • -XX:+CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
    • -XX:+UseCMSInitiatingOccupanyOnly:只是用设定的阈值,如果不指定,JVM仅在第一次使用设定值,后续会自动调整
    • -XX:+CMSInitiatingOccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始回收
      • JDK5及以前的版本默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收.JDK6及以上版本默认值为92%
      • 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效的降低CMS得触发频率,减少老年代回收得次数,可以较为明显的改善应用程序性能.反之,如果应用程序内使用率增长很快,则应该降低阈值,以避免触发老年代串行收集器.因此通过该选项便可以有效降低Full GC的执行次数.
    • -XX:+UseCMSCompactAtFullCollection 用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生.不过由于内存压缩整理过程中无法并发执行,所带来的问题就是停顿时间变得更长了.
    • -XX:CMSFullGCsBeforeCompaction 设置在执行多少次后Full GC 后对内存空间进行压缩整理
    • -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minorGC,目的是减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时80%都在标记阶段
    • -XX:+CMSParallelInitialMarkEnabled:表示在初始化标记的时候多线程执行,缩短STW
    • -XX:+CMSParallelRemarkEnabled:表示在重新标记的时候多线程执行,缩短STW
    • -XX:ParallelCMSTheads 设置CMS得线程数量
      • CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是年轻代并行收集器的线程数,当CPU资源比较紧张时,收到CMS收集器线程的1影响,应用程序的性能在垃圾回收阶段可能会非常糟糕.
  • 小结
    • 如果你想要最小化地使用内存和并行开销,请选Serial GC
    • 如果你想要最大化应用程序的吞吐量,请选Parallel GC
    • 如果你想要最小化GC中断或者停顿时间,请选CMS GC

垃圾收集底层算法实现

在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标(浮动垃圾)和漏标的情况就有可能发生。漏标的问题主要引入了三色标记算法来解决(标记存活对象)

三色标记算法是把Gc roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:

  • 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有对象引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
  • 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
  • 白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。

多标-浮动垃圾

在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。

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

漏标-读写屏障

漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB) 。

增量更新就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后(重新标记), 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。

原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)

以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。

写屏障

给某个对象的成员变量赋值时,其底层代码大概长这样:

/**
* @param field 某对象的成员变量,如 a.b.d 
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) { 
    *field = new_value; // 赋值操作
} 

 所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):

void oop_field_store(oop* field, oop new_value) {  
    pre_write_barrier(field);          // 写屏障-写前操作
    *field = new_value; 
    post_write_barrier(field, value);  // 写屏障-写后操作
}
  • 写屏障实现SATB

当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:

void pre_write_barrier(oop* field) {
    oop old_value = *field;    // 获取旧值
    remark_set.add(old_value); // 记录原来的引用对象
}
  • 写屏障实现增量更新

当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:

void post_write_barrier(oop* field, oop new_value) {  
    remark_set.add(new_value);  // 记录新引用的对象
}

读屏障

oop oop_field_load(oop* field) {
    pre_load_barrier(field); // 读屏障-读取前操作
    return *field;
}

读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:

void pre_load_barrier(oop* field) {  
    oop old_value = *field;
    remark_set.add(old_value); // 记录读取到的对象
}

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新
  • G1,Shenandoah:写屏障 + SATB
  • ZGC:读屏障

工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

为什么G1用SATB?CMS用增量更新?

我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。

记忆集与卡表

在新生代做GCRoots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。

在新生代开辟了一小块空间,维护了所有老年代对新生代的引用

为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。

垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。

老年代按照512字节进行划分,只有其中的某一个卡页的某一个对象引用到新生代的某一个对象         ,将这个卡页标记为direty,扫描的时候通过卡表找到那些标记为1的下标,做年轻代扫描的时候除了本身的GcRoots指向的对象之后也会去扫描卡页中的对象

hotspot使用一种叫做“卡表”(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比为Java语言中HashMap与Map的关系。

卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。 

hotSpot使用的卡页是2^9大小,即512字节

一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.

GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。

卡表的维护

卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。

Hotspot使用写屏障维护卡表状态。

17.7 G1回收器:区域化分代式

  • -XX:+UseG1GC
  • 因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region,物理上不连续).使用不同的Region来表示Eden,幸存者0区,幸存者1区,,老年代等.
  • G1 GC有计划的避免在整个Java堆中进行全区域的垃圾收集.GC跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收最大的Region
  • 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)

G1的目标:在延迟可控的情况下获得尽可能高的吞吐量

  • G1是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量的机器,以极高的概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征
  • JDK9以后的默认垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old组合;CMS在JDK9中被标记为废弃.
  • G1回收器的优势
    • 并行与并发
      • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力.此时用户线程STW
      • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说不会在整个回收阶段发生完全阻塞应用程序的情况.
    • 分代收集
      • 从分代上看,G1依然属于分带型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden和Survivor区.但从堆的结构上看,他不要求整个Eden区,老年代或者老年代都是连续的的,也不再坚持固定大小和固定数量
      • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代.
      • 和之前的各类回收器不同,他同时兼顾年轻代和老年代,对比其他回收器,或者工作在年轻代或者工作在老年代. 

  • 空间整合
    • CMS: “标记-清除”算法,内存碎片,若歌词GC后进行一次碎片整理
    • G1将内存划分为一个个的region.内存的回收是以region作为基本单位的.Region之间是复制算法,但整体是实际可看作是标记-压缩算法.两种算法都可以避免内存碎片.这种特性有利于程序长时间的运行,分配大对象时不会因为找不到连续内存空间而提前触发下一次GC.尤其是当Java堆非常大的时候,G1的优势更加明显.
  • 可预测的停顿时间模型
    • 这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,通过参数"-XX:MaxGCPauseMillis"指定,这个停顿时间再怎么低也得有个限度。 它默认的停顿目标为两百毫秒,一般来说, 回收阶段占到几十到一百甚至接近两百毫秒都很正常, 但如果我们把停顿时间调得非常低, 譬如设置为二十毫秒, 很可能出现的结果就是由于停顿目标时间太短, 导致每次选出来的回收集只占堆内存很小的一部分, 收集器收集的速度逐渐跟不上分配器分配的速度, 导致垃圾慢慢堆积。 很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间, 但应用运行时间一长就不行了, 最终占满堆引发Full GC反而降低性能, 所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的
    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制
    • G1跟踪各个Region里面的垃圾堆积的价值的大小(回收所获得空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region.保证了G1收集器在有限的时间内可以获取尽可能高的收集效率.
    • 相对于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多
  • G1回收器的缺点
    • 相比较于CMS,G1还不1具备全方位,压倒性优势.比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高
    • 从经验上来说,在1小内存应用上CMS的表现大概率会优先于G1,而G1在大内存应用上则发挥其优势.平衡点在6-8GB之间
  • G1回收器的参数设置
    • -XX: +UseG1GC 手动指定使用G1收集器执行内存回收任务
    • -XX: G1HeapRegionSize设置每个Region的大小.值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆的大小划分出约2048个区域.默认是堆内存的1/2000
    • -XX: MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到).默认值是200ms
    • -XX: ParallelGCThreads 设置STW工作线程数的值.最多设置为8
    • -XX: ConcGCThreads 设置并发标记的线程数.将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右
    • -XX: InitiatingHeapOccupancyPercent 老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
    • -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)

    • -XX:G1MaxNewSizePercent:新生代内存最大空间(默认是整堆的60%)

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

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

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

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

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

  • 常见的操作步骤
    • 开启G1垃圾收集器
    • 设置堆内存的最大内存
    • 设置最大的停顿时间

G1中提供了三种垃圾回收模式:YoungGC.MixedGC和FullGC,在不同的条件下被触发.

  • G1回收器的适用场景
    • 面向服务端应用,针对具有大内存,多服务的机器(在普通大小的堆里表现并不惊喜)
    • 最主要的应用是需要低GC延迟,并具有大量的应用程序提供解决方案;
      • 在堆大小约6GB或者更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC的停顿时间不会过长)
  • 用来替换掉JDK1.5中的CMS收集器:
    • 超过50%的Java堆被活动数据占用
    • 对象分配频率或年代提升频率变化很大
    • GC停顿时间过长(长于0.5至1秒)
  • HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程.
  • 分区Region:化整为零
    • 使用G1收集器时,JVM目标是不超过2048个Region(JVM源码里TARGET_REGION_NUMBER 定义),实际可以超过该值,但是不推荐。一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"-XX:G1HeapRegionSize"手动指定Region大小,整体被控制在1MB到32MB之间且为2的N次幂,即1,2,4,16,32MB,但是推荐默认的计算方式.所有的Region大小相同,且在JVM生命周期内不会被改变.虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离了,他们都是一部分Region(不需要连续)的集合.通过Region的动态分配实现逻辑上的连续.默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。

  • 一个region有可能属于Eden,Survivor或者Old/Tenured内存区域.但是一个region只可能属于一个角色.图中的E表示该region属于Eden内存区域,S表示属于Survivor内存区域,O表示属于Old区域.图中空白的表示未被使用的内存空间
  • G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块,主要用于存储大对象,如果对象大小为1.5M,每个Region大小为2M,1.5>2*50%,超过0.5个region,就放到H.大小为6M的对象则会为他分配3个region(MixedGC和FullGC的时候会进行回收)
  • 设置H的原因:
    • 对于堆中的大对象,默认直接会被分配到老年代,但是如果他是一个短期存在的大对象,就会对垃圾收集器造成负面影响.为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象.如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储,为了能找到连续的H区,有时候不得不启动Full GC.G1的大多数行为都把H区作为老年代的一部分来看待.

  • Remembered Set
    • 一个对象被不同区域引用的问题
    • 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确???
    • 在其它的分代收集器,也存在这样的问题(G1更突出
    • 回收新生代也不得不同时扫描老年代??这样的话会降低Minor GC的效率
      • 解决方法:
        • 无论是G1还是其它的分代收集器,JVM都是使用Remembered Set来避免全局扫描;
        • 每个Region都有一个对应的Remembered Set;
        • 每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;
        • 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器检查老年代对象是否引用了新生代对象);
        • 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在的Region对应的Remembered Set中;
        • 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏.

G1收集器一次GC(主要值Mixed GC)的运作过程大致分为以下几个步骤:

  • 初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
  • 并发标记(Concurrent Marking):同CMS的并发标记
  • 最终标记(Remark,STW):同CMS的重新标记
  • 筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了ZGC,Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)
  • G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。 

  •  G1回收器垃圾回收过程主要包括如下三个环节:
    • 年轻代GC(Young GC)
    • 老年代并发标记过程(Concurrent Marking)
    • 混合回收(Mixed GC)
    • 如果需要单线程,独占式,高强度的Full GC还是继续存在的.它针对GC的评估失败提供了一种失败保护机制,即强力回收

  • 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集过程是一个并行的独占式收集器.在年轻带回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收,然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及到.
  • 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程
  • 标记完成马上开始混合回收过程.对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分.和年轻代不同,老年代的G1回收器和其他GC不同的,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了.同时,这个老年代的Region是和年轻代一起被回收的.
    • 举个例子:一个web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约为2G的内存.G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收.
  • G1回收器回收过程详细介绍

每个regigon都配一个Rset,记录哪些regison指向其中的一个对象

  • G1回收过程一:年轻代GC
    • JVM启动时,G1先准备好Eden区(初始化大小),程序在运行过程中不断创建对象到Eden区,YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC
    • 年轻代垃圾回收只会回收Eden区和Survivor区.
    • YGC时,首先G1停止应用程序的运行(STW),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段(幸存者区不会主动触发YGC)

  • 然后开始如下的回收过程
    • 第一阶段,扫描根
      • 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等.根引用连同RSet记录的外部引用作为扫描存活对象的入口
    • 第二阶段,更新RSet
      • 处理dirty card queue(见备注)中的card,更新RSet.此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用.
    • 第三阶段,处理RSet
      • 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被任务是存活的对象
    • 第四阶段,复制对象
      • 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阈值会被复制到Old区中空的内存分段.如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间.
    • 第五阶段,处理应用
      • 处理Soft,Weak,Phantom,Fianl,JNI Weak等引用.最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程中可以达到内存整理的效果,减少碎片.

  • G1回收过程二:并发标记过程
    • 初始化标记:标记从根节点直接可达的对象,这个阶段是STW的,并且会触发一次年轻代GC,
    • 根区域扫描:G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象.这一过程必须在YGC之前完成(YGC要动幸存者区中的对象,该对象可能在老年代有引用,造成不该删除的删除掉).
    • 并发标记:在整个堆中进行并发标记(和应用程序并发执行),此过程可能被youngGC中断,在此并发标记阶段,若发现区域对象中的所有的对象都是垃圾,那这个区域会被立即回收.同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)
    • 再次标记(Remark):由于程序持续进行,需要修正上一次的标记结果.是STW的.G1中采用了比CMS更快的初始快照算法(SATB)
    • 独占清理:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域,为下阶段做铺垫.是STW的
    • 并发清理阶段: 识别并清理完全空闲的区域
  • G1回收过程三:混合回收(Mixed GC)
    • 当越来越多的对象晋升到老年代old Region时,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region以及大对象区.这里需要注意:是一部分老年代,而不是全部老年代.根据期望的GC停顿时间确定old区垃圾收集的优先顺序即选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制.也需要注意的是Mixed GC并不是Full GC(回收一部分老年代而不是全部老年代(低延迟)).正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

  •   
    • 并发标记结束后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来.默认情况下,这些老年代的内存分段会分8次(可以通过 -XX: G1MixedGCCountTarget设置)被回收
    • 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段.混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段.具体过程参考上面的年轻代回收过程.
    • 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段.垃圾占内存分段比例越高,越会先回收.并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例达到65%才会被回收,如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间.
    • 混合回收并不一定要进行8次,有一个阈值-XX:G1HeapWasterPercent.默认值为10%,意思是只允许整个堆内存中只有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收.因为GC会花费很多的时间但是回收到的内存很少.
  • G1回收过程四:Full GC
    • 停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)

    • G1的初衷就是要避免Full GC的出现.但是如果上述方式不能正常工作,G1会停止应用程序的执行(STW),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长.
    • 要避免Full GC的发生,一旦发生需要调整.什么时候会发生Full GC呢?比如堆内存太小.当G1在复制存活对象的时候没有空的内存分段可用,则会退回到Full GC,这种情况可以通过增大内存解决.
    • 导致 G1 Full GC的原因可能有两个
      • Evacuation的时候没有足够的to-space来存放晋升的对象;
      • 并发处理过程完成之前空间耗尽.
  • 设置暂停时间比较短可以回收的region比较少,此时用户线程产生的垃圾多.
  • 从Oracle官方透漏出来的信息可获知,回收阶段(Evacuation)其实也有想过设计成与用户程序一起并发执行,但这件事情做起来笔记复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后的出现的低延迟垃圾收集器(ZGC)中,另外考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案.
  • G1回收器优化建议
    • 避免使用-Xmn或者-XX:NewRatio等相关选项显示设置年轻代大小
    • 固定年轻代的大小会覆盖暂停时间目标
    • 假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc.

    • G1 GC的吞吐量是90%的应用程序时间和10%的垃圾收集时间
    • 评估G1 GC的吞吐量的时候,暂停时间目标不要太过苛刻.目标太过苛刻表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量.
  • 什么场景适合使用G1
    • 50%以上的堆被存活对象占用
    • 对象分配和晋升的速度变化非常大
    • 垃圾回收时间特别长,超过1秒
    • 8GB以上的堆内存(建议值)
    • 停顿时间是500ms以内
  • 每秒几十万并发的系统如何优化JVM
    • Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
    • G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。

17.8 垃圾回收器总结

  • Serial => Parallel(并行) => CMS(并发) => G1 => ZGC
  • Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升
  • 怎么选择收集器
    • 优先调整堆的大小让JVM自适应完成.
    • 如果内存小于10m,使用串行收集器
    • 如果是单核,单机程序,并且没有停顿时间的要求,串行收集器
    • 如果是多CPU,需要提高吞吐量,允许停顿时间超过1S,选择并行或者JVM自己选择
    • 如果是多CPU,追求停顿时间,需快速响应(比如延迟不能超过1S,如互联网应用),使用并发收集器;官方推荐G1.现在互联网的项目,基本都是使用G1.
    • 针对特定场景,特定需求,选择合适的收集器

17.9 GC日志总结

  • 内存分配与垃圾回收的参数列表
    • -XX:+PrintGC        输出GC日志.类似:-verbose:gc
    • -XX:+PrintGC        输出GC的详细日志
    • -XX:+PrintGC        输出GC的时间戳(以基准时间的形式)
    • -XX:+PrintGC        输出GC的时间戳(以日期的形式.如2013-05-04T21:53:59.234+0800)
    • -XX:+PrintGC         在进行GC的前后打印出堆的信息
    • -Xloggc:../logs/gc.log  日志文件的输出路径

  • 参数解析:
    • GC,Full GC:GC的类型,GC只在新生代上进行,Full GC包括永久代(方法区),新生代,老年代
    • 80832k->19298k:堆在GC前的大小和GC后的大小
    • 228840k : 现在的堆的大小.
    • 0.0084018secs : GC持续的时间.

  • 参数解析
    • GC,Full GC:同样是GC的类型
    • Allocation Failure: GC的原因
    • PSYoungGen:使用了Parallel Scavenge并行垃圾收集器的新生代GC前后大小的变化
    • ParOldGen:使用了Parallel old并行垃圾收集器的老年代GC前后的变化
    • Metaspace : 元数据区GC前后的大小,JDK1.8中引入了元数据区以替代永久区
    • Xxx secs : 指GC花费的时间
    • Times: User指的是垃圾收集器花费的所有CPU时间, sys:花费在等待系统的调用或系统事件的事件,
    • real : GC从开始到结束的时间,包括其他进程占用时间片的实际时间
  • 如果有”Full”则说明了STW
  • 使用Serial收集器在新生代的名字是Default New Generation.因此是”[DefNew”
  • 使用Parallel Scanvange收集器在新生代的名字是”[PSYoungGen”
  • 老年代的收集和新生代道理一样,名字也是收集器决定的
  • 使用G1收集器的话`,会显示为”garbage-first heap”
  • Allocation Failure:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了.
  • [PSYoungGen: 5986k->696k(8704k)] 5986k->704k(9216k)
    • 中括号内: GC回收前的年轻代大小,回收后的大小,(年轻代总大小)
    • 括号外: GC回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)
  • User代表用户态回收耗时,sys内核态回收耗时,real实际耗时,由于多核的原因,时间总和可能会超过real时间

  • GC日志分析

Jdk8中直接把4M的大对象放入老年代.三个2m的小对象放在伊甸园中

  • GC日志分析工具

                GCViewer,GCEasy,GCHisto,GCLogViewer,Hpjmeter,garbagecat

17.19 ZGC收集器(-XX:+UseZGC)

参考文章:Main - Main - OpenJDK Wiki

http://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf

ZGC是一款JDK 11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于是Azul System公司开发的C4(Concurrent Continuously Compacting Collector) 收集器。

ZGC目标

如下图所示,ZGC的目标主要有4个:

  • 支持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收集器是一款基于Region内存布局的, 暂时不设分代的, 使用了读屏障、 颜色指针等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器。

ZGC的Region可以具有如图3-19所示的大、 中、 小三类容量:

  • 小型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架构特性的。

ZGC运作过程

ZGC的运作过程大致可划分为以下四个大的阶段:

  • 并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新颜色指针(见下面详解)中的Marked 0、 Marked 1标志位。
  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
  • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障(见下面详解))所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。

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

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

颜色指针

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个mark标记?

每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。

GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。

GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。

通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。

颜色指针的三大优势:

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

读屏障

之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。

在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。

那么我们该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用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 Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销:

那么,判断对象是Bad Color还是Good Color的依据是什么呢?就是根据上一段提到的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。

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

ZGC存在的问题

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

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

解决方案

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

ZGC参数设置

启用ZGC比较简单,设置JVM参数即可:-XX:+UnlockExperimentalVMOptions 「-XX:+UseZGC」。调优也并不难,因为ZGC调优参数并不多,远不像CMS那么复杂。它和G1一样,可以调优的参数都比较少,大部分工作JVM能很好的自动完成。下图所示是ZGC可以调优的参数:

ZGC触发时机

ZGC目前有4中机制触发GC:

  • 定时触发,默认为不使用,可通过ZCollectionInterval参数配置。
  • 预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用。
  • 分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC(耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间)。
  • 主动触发,(默认开启,可通过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

下图有连线的可以搭配使用

JDK 1.8默认使用 Parallel(年轻代和老年代都是)

JDK 1.9默认使用 G1

安全点与安全区域

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

这些特定的安全点位置主要有以下几种:

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

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

安全区域又是什么?

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

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

因此 JVM 引入了 Safe Region。

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

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值