垃圾收集器

垃圾收集器是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定

两者之间存在连线的收集器可以相互搭配使用,收集器所处区域表示其属于新生代收集器还是老年代收集器

HotSpot虚拟机垃圾收集器

Serial收集器

Serial收集器是最基本、发展历史最有悠久、基于复制算法、单线程新生代收集器。单线程的意义并不仅仅是使用一个CPU一条收集线程去完成垃圾收集工作,更重要的是在垃圾收集时必须暂停其他所有工作线程。Serial收集器到目前为止,依然是JAVA虚拟机运行在Client模式下的默认新生代收集器。与其他收集器的单线程比Serial收集器简单高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互开销,可以获得最高的单线程收集效率。可能会产生较长时间的停顿。使用-XX:+UseSerialGC参数指定虚拟机使用Serial收集器。

Serial收集器运行示意图

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop-The-World、对象分配规整、回收策略等都与Serial收集器完全一样,两者也共用了相当多的代码。

ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器除了Serial收集外,目前只有ParNew收集器能与CMS收集器配合工作。但ParNew收集器在单CPU环境中绝对不会比Serial收集器效果好,甚至由于存在线程交互开销,两个CPU环境中都不能百分百保证可以超越Serial收集器。

ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后默认的新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。

ParNew收集器运行示意图

Parallel Scavenge收集器

Parallel Scavenge是一个使用复制算法、并行的多线程新生代收集器。CMS等收集器关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目的是达到一个可控的吞吐量自适应调节策略是Parallel Scavenge与ParNew收集器的一个重要区别。

Parallel Scavenge收集器通过使用-XX:UseAdaptiveSizePolicy开关参数来设置是否使用自适应调节策略,当该参数打开时,就不需要手工通过-Xmn参数指定新生代的大小、-XX:SurvivorRatio参数指定Eden与Servivor区的比例、-XX:PretenureSizeThreshold参数直接晋升到老年代的对象大小(大于这个参数的对象将直接在老年代分配)等细节,虚拟机会根据当前系统运行情况收集性能监控信息动态调整这些参数以提供最合适的停顿时间最大吞吐量。只需要把基本的最大堆内存设置好,然后使用-XX:MaxGCPauseMillis参数或者-XX:GCTimeRatio参数给虚拟机设立一个优化目标,具体细节参数调节工作就由虚拟机自动完成了。

-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间。该参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值,但并不是把该参数设置得小就能使得系统的垃圾收集速度变快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的(e.g.把新生代调小,原来10秒收集一次,每次停顿100毫秒,现在5秒收集一次,每次停顿70毫秒);

-XX:GCTimeRatio垃圾收集时间占总时间的比率,相当于吞吐量的倒数,该参数允许的值是一个大于0且小于100的整数默认为99允许最大1%的垃圾收集时间。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间) 也就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。

Parallel Scavenge收集器运行示意图

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它是一个使用标记整理算法单线程的收集器,主要也是给Client模式下的虚拟机使用。

如果在Service模式下,主要有两种用途:一是在JDK1.5之前版本中与Parallel Scavenge收集器搭配使用,二是作为CMS收集器的后备预案,在CMS发生Concurrent Mode Failure时使用。

Serial Old收集器运行示意图

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,它是一个使用标记整理算法多线程的收集器。

该收集器在JDK1.6中提供,在此之前如果新生代选择了Parallel Scavenge收集器老年代只能选择Serial Old收集器。由于Serial Old收集器是单线程的即使使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化,在老年代很大且硬件条件比较高级的环境中,这种组合吞吐量甚至还不一定有ParNew加CMS的组合好。

注重吞吐量以及CPU资源敏感的场合,优先考虑Parallel Scavenge加Parallel Old收集器的组合。

Parallel Old收集器运行示意图

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的且基于标记清除算法的收集器。且它是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程用户线程基本上同时工作。

CMS收集器运作过程相对于前面几种收集器来说更复杂一些,整个过程分为初始标记、并发标记、重新标记、并发清除4个步骤。其中初始标记、重新标记两个步骤需要Stop-The-world初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快并发标记阶段就是进行GC Roots Tracing的过程重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,整个阶段停顿时间一般比初始标记稍长比并发标记时间短。整个过程耗时最长的是并发标记并发清理过程收集器线程都可以与用户线程一起工作,总体上CMS收集器的内存回收过程是与用户线程一起并发执行的。CMS垃圾收集整个过程分为一下几个步骤:

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

CMS收集器运行示意图

CMS收集器对CPU资源非常敏感。在并发阶段它虽然不会导致用户线程停顿,但会占用一部分线程而导致应用程序变慢,总吞吐量降低CMS默认启动的回收线程数是:(CPU数量 + 3)/ 4。当CPU不足4个时CMS对用户程序的影响可能变得很大。为了应付这种情况虚拟机提供了一种称为增量式并发收集器,和使用抢占式来模拟多任务机制的思想一样,在并发标记、整理时让GC线程用户线程交替运行,尽量减少GC线程的独占资源的时间,垃圾收集过程会更长,但对用户程序影响会显得少一些,但实践证明这种方式效果一般目前已经不提倡使用。

CMS收集器无法处理浮动垃圾,可能出现Concurrent Mode Failure失败而导致另一次Full GC产生。由于垃圾收集阶段用户线程还在运行,也就需要留有足够内存空间给用户线程使用,因此CMS收集器不能像其他收集器等到老年代几乎完全被填满再进行收集。当CMS运行期间预留内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败,这时虚拟机就会启动后备预案,临时使用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长。可以通过参数-XX:CMSInitiatingOccupancyFraction来设置触发百分比

CMS收集器是基于标记清除算法会产生大量空间碎片。空间碎片过多时,在老年代还有很大空间剩余时,给大对象分配内存空间时无法找到足够大的连续的内存空间,而不得不提前触发Full GC。为了解决该问题CMS提供-XX:+UseCMSCompactAtFullCollection开关参数默认开启,用于Full GC时开启内存碎片合并整理,但内存整理过程无法并发故停顿时间将变长。还提供-XX:CMSFullGCsBeforeCompaction参数来设置执行多少次不压缩Full GC后执行一次压缩的GC默认为0

浮动垃圾是指由于CMS并发清理阶段用户线程还在运行故会产生新的垃圾,但这部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉他们,只好留待下一次GC时再清理。

G1收集器

G1时一款面向服务端应用的垃圾收集器,其他收集器收集的范围都是整个新生代或者老年代,而G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然保留了老年代新生代的概念,但老年代和新生代不再被物理隔离,它们都是一部分Region(不需要连续)的集合

新生代老年代不再物理隔离

G1 Region

JVM最多可以有2048Region,一般Region大小等于堆大小除以2048,可以用参数-XX:G1HeapRegionSize手动指定Region大小,但推荐默认计算方式默认年轻代对堆内存的占比是5% ,可以通过-XX:G1NewSizePercent设置新生代初始占比,在系统运行中会不停的给年轻代增加更多的Region,但最多新生代占比不会超过60%,可通过-XX:G1MaxNewSizePercent调整。年轻代中的Eden和Survivor对应的region比例默认还是8:1:1

G1有专门分配大对象的Humongous,而非让大对象直接进入老年代Region中。对象大小超过一个Region大小的50%就是大对象,且若大对象太大,可能会横跨多个Region存放,Humongous区专门存放短期巨型对象,不用直接进老年代,可节约老年代的空间,避免因老年代空间不够的GC开销。Full GC时也会将Humongous区一并回收

G1收集器会跟踪各个Region里面的垃圾堆积的价值大小,即回收所获得的空间大小以及回收所需时间经验值,在后台维护一个优先列表,每次根据允许收集的时间,优先回收价值最大的Region,这也是Garbage-First名称的由来

与其他收集器相比G1收集器具备并行与并发、分代收集、空间整合、可预测停顿4个特点:

  • G1收集器可以使用并行的方式来缩短Stop-The-World停顿时间,且可以通过并发的方式让Java程序继续执行。
  • G1收集器可以不用其他收集器的配合独立管理整个GC堆,任然保留分代的概念,采用不用的方式去处理新建的对象和已经存活了一段时间、熬过多次GC的旧对象以获得更好的收集效果。
  • G1收集器整体来看是基于标记整理算法实现的收集器,从局部(两个Region之间)来看是基于复制算法来实现的,这意味着G1运作期间不会产生内存碎片
  • G1收集器除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗再垃圾收集上的时间不得超过N毫秒。之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。可用-XX:MaxGCPauseMillis参数指定期望的GC停顿时间,默认的停顿目标为两百毫秒

一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。在G1收集器中Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,都是使用记忆集来避免全堆扫描每个Region都有一个与之对应的记忆集,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个写屏障暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,在分代中就检查是否老年代中的对象引用了新生代中的对象,如果是便通过卡表把相关引用信息记录到被引用对象所属的Region的记忆集中,当进行内存回收时,在进行GC根节点枚举范围中加入记忆集即可保证不对全堆扫描也不会遗漏。

G1收集器的的运作大致分为初始标记、并发标记、最终标记、筛选回收4个步骤。

  • 初始标记阶段仅仅标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建对象,需要停顿线程,但耗时很短
  • 并发标记阶段是从GC Roots开始对堆中对象进行可达性分析找出存活对象,耗时较长但可与用户程序并发执行
  • 最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Rememberd Set Logs里面,最终标记阶段需要把Rememberd Set Logs的数据合并到Rememberd Set,这个阶段需要停顿线程可并行执行
  • 筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段也可以做到与用户程序并发执行,但因为只回收一部分Region时间是用户可控的,,而且停顿用户线程将大幅提高收集效率。

G1收集器运行示意图

不要将停顿目标时间设置太短, 否则可能导致每次选出来的回收集只占堆内存很小一部分, 收集器收集速度逐渐跟不上分配器分配的速度,导致垃圾堆积,很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但时间一长最终占满堆引发Full GC反而降低性能, 故通常把期望停顿时间设置为一两百毫秒两三百毫秒会是比较合理的。

G1垃圾收集分类

YoungGC

并非现有的Eden区放满了立刻触发,G1会计算现在Eden区回收大概要多久时间,若回收时间远远小于参数-XX:MaxGCPauseMills设定值,则增加年轻代region,继续给新对象存放,直到下一次Eden区放满,G1计算回收时间接近参数-XX:MaxGCPauseMills设定的值,则会触发Young GC

MixedGC

老年代的堆占有率达到参数-XX:InitiatingHeapOccupancyPercent设定值时触发,根据期望的GC停顿时间确定old区垃圾收集的优先顺序回收所有Young部分Old以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,把存活的对象拷贝到别的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

-XX:G1MixedGCLiveThresholdPercent:region中存活对象低于该值则回收该region,默认85%,若超过该值,存活对象过多回收意义不大。
-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收默认8次,在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
-XX:G1HeapWastePercent: GC过程空出来的region是否充足阈值,混合回收时对Region回收是基于复制算法,把要回收的Region里存活对象放入其他Region,然后该Region中垃圾对象全部清理,在回收过程就会不断空出新的Region,一旦空闲出来的Region数量达到了堆内存的5%,会立即停止混合回收,意味着本次混合回收结束

优化建议

-XX:MaxGCPauseMills设置很大,导致系统运行很久,年轻代可能都占用了堆内存的60%,此时才触发年轻代GC,存活下来的对象可能会很多,此时会导致Survivor区域放不下那么多对象,就会进入老年代中。或年轻代GC后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。故核心在于调节-XX:MaxGCPauseMills参数的值,在保证年轻代GC不太频繁的同时,还得考虑每次GC后存活对象有多少,避免存活对象太多快速进入老年代,频繁触发Mixed GC

适合场景

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

  • 50%以上的堆被存活对象占用
  • 对象分配和晋升的速度变化非常大
  • 垃圾回收时间特别长,超过1秒
  • 8GB以上的堆内存
  • 停顿时间是500ms以内

ZGC收集器

JDK11中新加入,是一款基于Region内存布局的, 暂时不分代的, 使用了读屏障颜色指针等技术来实现可并发的标记-整
理算法的, 以低延迟为首要目标实验性质一款垃圾收集器。有四大目标:

  • 支持TB量级的堆
  • 最大GC停顿时间不超10ms
  • 奠定未来GC特性的基础
  • 最糟糕的情况下吞吐量会降低15%

ZGC堆Region

为大、 中、 小三类容量,小型Region容量固定为2MB, 用于放置小于256KB的小对象;中型Region容量固定为32MB,用于放置大于等于256KB小于4MB的对象;大型Region容量不固定可以动态变化,但必须为2MB整数倍, 用于放置4MB以上的大对象。

每个大型Region中只会存放一个大对象, 也预示着虽然名字叫作大型Region, 但实际容量完全有可能小于中型Region,最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配,重分配是ZGC的一种处理动作,用于复制对象的收集器阶段, 因为复制一个大对象的代价非常高昂。

NUMA-aware

NUMA对应UMA。UMA表示内存只有一块,所有CPU都去访问这一块内存,则会存在竞争问题,争夺内存总线访问权,有竞争就会有锁,有锁效率就会受到影响,而且CPU核心数越多,竞争就越激烈。NUMA为每个CPU对应一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存:

UMA and NUMA

服务器NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC是能自动感知NUMA架构并充分利用NUMA架构特性。

颜色指针

ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中:

颜色指针

每个对象有一个64位指针,这64位被分为:
18位:预留给以后使用
1位:Finalizable标识,此位与并发引用处理有关,表示该对象只能通过finalizer才能访问
1位:Remapped标识,设置此位的值后,对象未指向需要GC的Region集合
1位:Marked1标识,标记对象用于辅助GC
1位:Marked0标识,标记对象用于辅助GC
42位:对象的地址,故它可以支持2^42=4T内存

2个mark标记

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

  • GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01
  • GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。

对象指针必须是64位,ZGC无法支持32位操作系统,同样的也就无法支持压缩指针

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

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

读屏障

之前的GC都是采用Write Barrier,ZGC采用完全不同的方案读屏障,在标记和移动对象的阶段,每次从堆里对象的引用类型中读取一个指针的时候,都需要加上一个Load Barriers。 尝试读取堆中的一个对象引用obj.fieldA并赋给引用,若这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,该屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针修正到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,应用代码就永远都会持有更新后的有效指针,而且不需要STW。JVM是利用颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要修正指针;如果指针是Good Color,那么正常往下执行即可:

ZGC运作过程

ZGC收集器运行示意图

  • 并发标记Concurrent Mark:与G1一样并发标记是遍历对象图做可达性分析的阶段,它的初始标记Mark Start和最终标记Mark End也会出现短暂的停顿,与G1不同的是 ZGC的标记是在指针上而不是在对象上, 标记阶段会更新染色指针中的Marked 0Marked 1标志位。
  • 并发预备重分配Concurrent Prepare for Relocate:该阶段需要根据特定查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集Relocation Set。每次回收都会扫描所有Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
  • 并发重分配Concurrent Relocate:重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表Forward Table记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集中,若用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障读屏障截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的自愈能力
  • 并发重映射Concurrent Remap修正整个堆中指向重分配集中旧对象的所有引用,但对象引用存在自愈功能,故该重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里中完成,反正它们都是要遍历所有对象的,合并后节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。

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

ZGC触发时机

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

  • 定时触发,默认为不使用,可通过ZCollectionInterval参数配置。
  • 预热触发,最多三次,在堆内存达到10%、20%、30%时触发
  • 分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC,耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间。
  • 主动触发,默认开启,可通过ZProactive参数配置,距上次GC堆内存增长10%,或超过5分钟时,对比距上 次GC的间隔时间跟49 * 一次GC的最大持续时间,超过则触发。

总结

收集器 串并行/并发 新生代/老年代 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 CPU环境Client模式
Serial Old 串行 老年代 标记-整理 响应速度优先 CPU环境Client模式、CMS后备预案
ParNew 并行 新生代 复制算法 响应速度优先 CPU环境Server模式与CMS配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 互联网站或B/S系统服务端
G1 并发 新生代&老年代 标记-整理
复制算法
响应速度优先 面向服务端应用,将来替换CMS

选择垃圾收集器

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

三色标记

在并发标记过程中,标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标漏标的情况就有可能发生。 把Gc Roots可达性分析遍历对象过程中遇到的对象, 按照是否访问过这个条件标记成以下三种颜色:

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

三色标记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ThreeColorRemark {
public static void main(String[] args) {
A a = new A();
//开始做并发标记
D d = a.b.d; // 1.读
a.b.d = null; // 2.写
a.d = d; // 3.写
}
}
class A {
B b = new B();
D d = null;
}
class B {
C c = new C();
D d = new D();
}
class C {
}
class D {
}

多标

在并发标记过程中,若由于方法运行结束导致部分局部变量即GC Root被销毁,该GC Root引用的对象被扫描过,已被标记为非垃圾对象,本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为浮动垃圾

浮动垃圾并不影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。针对并发标记并发清理开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分

漏标

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

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

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

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

写屏障

所谓的写屏障,其实就是指在赋值操作前后,加入一些处理:

1
2
3
4
5
6
7
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记录下来:

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

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

1
2
3
4
void post_write_barrier(oop* field, oop new_value) {
// 记录新引用的对象
remark_set.add(new_value);
}
读屏障

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

1
2
3
4
5
6
7
8
9
10
11
oop oop_field_load(oop* field) {
// 读屏障‐读取前操作
pre_load_barrier(field);
return *field;
}

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

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

写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等 ,对于读写屏障,以HotSpot为例,其并发标记时对漏标的处理方案如下:

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

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

记忆集与卡表

新生代做GC Roots可达性扫描过程中可能会碰到跨代引用的对象,若又对老年代再扫描效率太低,在新生代引入记录集Remember Set数据结构,记录从非收集区收集区指针集合,避免把整个老年代加入GC Roots扫描。并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集行为的垃圾收集器,典型的如G1ZGCShenandoah收集器, 都会面临相同的问题。

收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。hotspot使用一种叫做卡表cardtable的方式实现记忆集,也是目前最常用的一种方式。卡表与记忆集的关系,可以类比HashMapMap的关系。

卡表是使用一个字节数组CARD_TABLE[]实现,每个元素对应一个卡页,其标识的内存区域内一块特定大小的内存块hotSpot的卡页是2^9512字节,一个卡页可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0GC时只筛选出本收集区卡表中变脏的元素加入GC Roots里。Hotspot在发生引用字段赋值时,使用写屏障维护卡表状态
记忆集-卡表