JVM垃圾收集器

垃圾收集算法

垃圾收集算法包括:复制算法、标记整理算法、标记清除算法三类,都基于分代收集理论。

分代收集理论

当前虚拟机的垃圾收集都采用分代收集理论,就是根据对象存活周期的不同把内存分为几块。一般把java堆分成新生代和老年代,这样就可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活率比较高,而且没有额外的空间对它进行分配担保,所以必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。需要注意的是,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。

复制算法

 

 

为了解决效率问题,出现的“复制”收集算法。它可以把内存分为大小相同的两块,每次使用其中的一块。当这一块内存使用完后,就把还存活的对象复制到另一块去,并把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一般进行回收。

标记整理算法

 

 

根据老年代的特点推出的一种标记算法,标记过程与下方的“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

标记清除算法

 

算法分为“标记”和“清除”阶段:标记存活的对象,统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所以被标记的对象。

这是最基础的收集算法,比较简单,但是会带来两个问题

  1. 效率问题:如果需要标记的对象太多,效率就会很低;
  2. 空间问题:标记清除后会产生大量不连续的内存碎片。 

 

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

目前没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

 

 连线代表可以组合,红线代表垃圾收集器的转换,要避免这种情况。因为CMS只有在并发失败的时候才会转Serial Old,这会导致STW,影响用户体验。

 Serial收集器(-XX:+UseSerialGC开启年轻代串行收集器  -XX:+UseSerialOldGC开启老年代串行收集器

 Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。这是一个单线程收集器,它的“单线程”不仅仅意味着只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作时必须暂停其他所有的工作线程【Stop The World】,直到它收集结束。

新生代采用复制算法,老年代采用标记-整理算法。

STW会导致用户体验不佳,有卡顿感,串行的唯一好处就是简单,GC效率相对较高。Serial收集器没有线程交互的开销,所以单线程收集效率很高。

Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器。它主要有两大用途:

  1. 在JDK1.5及以前的版本中与Parallel Scavenge收集器搭配使用;
  2. 作为CMS收集器的后备方案

Parallel Scavenge收集器(-XX:+UseParallelGC 开启年轻代 -XX:+UseParallelOldGC 开启老年代

Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为【控制参数、收集算法、回收策略等】和Serial收集器类似。默认的收集线程数和CPU核数相同,也可以通过设置参数【-XX:ParallelGCThreads】指定收集线程数,但是一般不推荐修改。

Parallel Scavenge收集器关注点是吞吐量【高效率的利用CPU】,CMS等垃圾收集器的关注点更多是用户线程的停顿时间【提高用户体验】。

所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。

Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对收集器运作不太了解,可以把内存管理优化交给JVM去完成。

新生代采用复制算法,老年代采用标记-整理算法。

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU资源的场景,都可以优先考虑Parallel Scavenge收集器和Parallel Old收集器【JDK8默认的新生代和老年代收集器】。

ParNew收集器(-XX:+UseParNewGC)

ParNew收集器其实和Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。

新生代采用复制算法,老年代采用标记-整理算法。

ParNew收集器是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有ParNew能与CMS收集器【真正意义上的并发收集器】配合工作。

CMS收集器(-XX:+UseConcMarkSweepGC【old】)

CMS【Concurrent Mark Sweep】收集器是一种以获取最短回收停顿时间为目标的收集器,也就是追求STW的时间越短越好。它非常符合在注重用户体验的应用上使用,是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS收集器是以“标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器更加复杂,整个过程分为以下几步:

  1. 初始标记:暂停所有的其他线程(STW),并记录下GC Roots能直接引用的对象,速度很快;
  2. 并发标记:并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。因为用户线程继续运行,可能导致已经标记过的对象状态发送改变;
  3. 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的停顿时间稍长,但比并发标记阶段的停顿时间要短。主要用到三色标记里的增量更新算法重新标记;
  4. 并发清理:开启用户线程,同事GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象,会被标记为黑色不做任何处理;
  5. 并发重置:重置本次GC过程中的标记数据。

CMS收集器的主要优点:并发收集、低停顿;

CMS收集器的缺点:

  1. CPU资源敏感(会和服务器抢资源);
  2. 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次GC再清理了);
  3. 它使用的回收算法——“标记-清除”算法会导致收集结束时有大量的空间碎片,不过可以通过设置参数【-XX:+UseCMSCompactAtFullCollection】让JVM在执行完标记清除后再做整理;
  4. 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段,一边回收,系统一边运行,也许没回收完就再次触发Full GC,也就是“concurrent mode failure”,此时会进入Stop The World,用Serial Old垃圾收集器来回收。

CMS相关的核心参数

  1. -XX:+UseConcMarkSweepGC:启用CMS;
  2. -XX:ConcGCThreads:设置并发的GC线程数,默认是CPU核数,不建议调整;
  3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片);
  4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次;
  5. -XX:CMSInitiatingOccupancyFraction:当老年代使用达到该比例时会触发FullGC(默认是92,单位百分比)【这个参数是为了避免concurrent mode failure,如果程序中大对象比较多,建议再调小一些】;
  6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续会自动调整【如果没配置这个参数,那么上一个参数设置了也无效】;
  7. -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次Minor GC,目的在于减少老年代对年轻代的引用,降低CMS GC标记阶段时开销,一般CMS的GC耗时80%都在标记阶段;
  8. -XX:+CMSParallelInitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW的时间;
  9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW的时间。

 啰嗦一句:-XX是什么意思呢?这个X越多,代表这个命令越不稳定,后面的新版本可能会取消这个命令,反之X越少,这个命令就会越稳定。所以JDK8推荐使用CMS,JDK9就推荐使用G1了。

G1收集器(-XX:+UseG1GC)

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

官网:https://docs.oracle.com/javase/9/gctuning/garbage-first-garbage-collector.htm#JSGCT-GUID-ED3AB6D3-FD9B-4447-9EDF-983ED2F7A573

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

一般Region大小等于堆大小除以2048,不过也可以用参数【-XX:G1HeapRegionSize】手动设置Region大小。

G1保留了年轻代和老年代的概念,但不再物理隔阂了,它们可以是不连续的Region集合。

默认年轻代对堆内存的占比是5%,可以通过【-XX:G1MaxNewSizePercent】调整。在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多年轻代占比不会超过60%,可以通过【-XX:G1MaxNewSizePercent】调整。年轻代的Eden和Survivor对应的Region和之前一样是8:1:1。

一个Region可能之前是年轻代,如果进行了GC,可能会变成老年代。

G1有专门分配大对象的Region,叫作Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,如果一个对象超过了一个Region大小的50%,就会被判定为大对象而放入Humongous中,并且如果一个大对象太大,可能会横跨多个Region来存放。

Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。

Full GC的时候除了收集年轻代和老年代,也会把Humongous区一并回收。

G1收集器一次GC的运作过程步骤如下:

  • 初始标记(Initial Mark,STW):暂停所有其他线程,并记录下GC Roots直接能引用的对象,速度很快;
  • 并发标记(Concurrent Marking):同CMS的并发标记;
  • 最终标记(Remark,STW):同CMS的重新标记;
  • 筛选回收(CleanUp,STW):筛选回收阶段首先会对各Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数【-XX:MaxGCPauseMillis】指定)来制定回收计划。不管是年轻代还是老年代,回收算法主要用的是复制算法,把一个Region的存活对象复制到另一个Region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。【注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了ZGC,Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本】。

 

 

 

 


 

垃圾收集底层算法实现

三色标记 

并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况极有可能发生。

这里就用到了三色标记,把GC Roots可达性分析遍历对象过程中遇到的对象,按照“是否访问过”这个条件来标记成以下三种颜色:

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

 

 

多标——浮动垃圾

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

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

漏标——读写屏障

漏标会导致被引用的对象当成垃圾误删除,这是很严重的BUG,解决方案有:

  1. 增量更新(Incremental Update):当黑色对象插入新的指向白色对象的引用关系时,就把这个新插入的引用记录下来,等并发扫描结束之后,再把这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以理解为,黑色对象一旦插入了指向白色对象的引用之后,就变为灰色对象了
  2. 原始快照(Snapshot At The Beginning,SATB):当灰色对象要删除指向白色对象的引用关系时,就把这个要删除的引用记录下来,在并发扫描结束之后,再把这些记录过的引用关系中的灰色对象为根,重新扫描一次,这样就能扫描到白色的对象,把白色的对象直接标记为黑色。目的就是让这种对象在本轮GC中能够存活下来,等到下一轮GC的时候重新扫描,这个对象就可能是浮动垃圾

无论是引用关系记录的插入还是删除,JVM的记录操作都是通过写屏障实现的。

写屏障

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

写屏障实现SATB:当对象B的成员变量引用发生变化时,比如引用消失(a.b=null),可以利用写屏障,把A原来的成员变量的引用对象b记录下来。

写屏障实现增量更新:当对象A的成员变量引用发生变化时,比如新增引用(a.b=b),可以利用写屏障,把A新的成员变量引用对象b记录下来。

读屏障

读屏障是当读取成员变量时,一律记录下来。

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

对于读写屏障,JAVA HotSpot VM的并发标记时对漏标的方案是:

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

记忆集与卡表

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

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

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

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

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

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

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

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

 

 

感谢图灵学院的诸葛老师!!

posted @ 2022-09-05 15:36  敲代码的小浪漫  阅读(163)  评论(0编辑  收藏  举报