详解JVM垃圾收集

175 阅读33分钟

判断是否可回收

常用于判断对象是否可被回收有两种算法:

引用计数算法

用于FlashPlayer、Python语言

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

原理简单,判定效率也很高,但有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

可达性分析算法

用于java 、C#

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(ReferenceChain),如果从GCRoots到这个对象不可达时,则证明此对象是不可能再被使用的。

固定可作为GC Roots的对象:在虚拟机栈(栈帧中的本地变量表)中引用的对象; Java类的引用类型静态变量;在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用;Native方法引用的对象;Java虚拟机内部的引用,譬如基本数据类型的包装类及常驻的异常对象;所有被同步锁(synchronized关键字)持有的对象;还可以有其他对象“临时性”地加入,譬如只针对新生代的垃圾收集,需要考虑对象是否被老年代所引用,故也需要一并加入GC Roots集合中(记忆集)。

GCRoots查找及安全点枚举

一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,HotSpot使用一组称为OopMap的数据结构来记录这些信息,并不需要真正一个不漏地从方法区等GC Roots开始查找。

安全点决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

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

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

记忆集、卡表、写屏障

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。卡表是记忆集的一种实现。

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page),一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡页标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的卡页,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

通过写屏障(Write Barrier)技术维护卡表状态,写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-WriteBarrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。

三色标记

为了解决在并发标记过程中,存活对象漏标的情况,GC HandBook把对象分成三种颜色: 1、黑色:自身以及可达对象都已经被标记。 2、灰色:自身被标记,可达对象还未标记。 3、白色:还未被标记

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

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

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

SATB要维持“在GC开始时活的对象”的状态这个逻辑snapshot。除了从root出发把整个对象图mark下来之外,当引用关系发生变化的时候,通过pre-write barrier函数会把这种这种变化记录并记(log)在一个队列里,在JVM源码中这个队列叫satb_mark_queue。在remark阶段会扫描这个队列,通过这种方式,旧的引用所指向的对象就会被标记上,其子孙也会被递归标记上,这样就不会漏标记任何对象。这样,等concurrent marker到达某个对象时,这个对象的所有引用类型字段的变化全都有记录在案,就不会漏掉任何在snapshot里活的对象。当然,很可能有对象在snapshot中是活的,但随着并发GC的进行它可能本来已经死了,但SATB还是会让它活过这次GC。

logging write barrier

为了尽量减少write barrier对应用mutator(在垃圾收集场景下将应用程序称为 mutator)性能的影响,G1将一部分原本要在barrier里做的事情挪到别的线程上并发执行。 实现这种分离的方式就是通过logging形式的write barrier:mutator只在barrier里把要做的事情的信息记(log)到一个队列里,然后另外的线程从队列里取出信息批量完成剩余的动作。

以SATB write barrier为例,每个Java线程有一个独立的、定长的satb_mark_queue,mutator在barrier里只把old_value压入该队列中。一个队列满了之后,它就会被加到全局的SATB队列集合SATBMarkQueueSet里等待处理,然后给对应的Java线程换一个新的、干净的队列继续执行下去。

并发标记(concurrent marker)会定期检查全局SATB队列集合的大小。当全局集合中队列数量超过一定阈值后,concurrent marker就会处理集合里的所有队列:把队列里记录的每个oop都标记上,并将其引用字段压到标记栈(marking stack)上等后面做进一步标记。

类型卸载

允许类型回收(还需要-Xnoclassgc参数进行控制)的情况:

该类所有的实例都已经被回收;

加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。

该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

垃圾回收算法

标记-清除

CMS收集器

执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低;

内存空间的碎片化,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制

一般用于新生代(HotSpot虚拟机的Serial、ParNew、Parallel Scavenge等新生代收集器,)

“半区复制”:分为大小相同的两块,可用空间只有一半,浪费了一半内存

“Appel式回收”:把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion),即直接进入老年代。

无内存碎片,但在对象存活率较高时就要进行较多的复制操作,效率将会降低。复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是Stop The World的

标记-整理

一般用于老年代(Serial Old,Parallel Scavenge Old)

移动式的回收算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,需要Stop The World。移动则内存回收时会更复杂,不移动则内存分配时会更复杂(譬如通过“分区空闲分配链表)。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量(用户程序及收集器的效率总和)来看,移动对象会更划算。

另一种做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。——CMS收集器

垃圾收集器

垃圾收集器中,并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程(吞吐量受影响)都在运行。

Serial收集器

单线程工作的收集器,新生代收集器(可与Serial Old 配合)。进行垃圾收集时“Stop The World”(由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉),简单而高效(与其他收集器的单线程相比),额外内存消耗最小

ParNew收集器

Serial收集器的多线程并行版本,新生代收集器(可与CMS 配合)。除了同时使用多条线程进行垃圾收集外,其余行为与Serial收集器一致。

Parallel Scavenge收集器

吞吐量优先收集器,新生代。控制最大垃圾收集停顿时间(-XX:MaxGCPauseMillis参数)、直接设置吞吐量大小(-XX:GCTimeRatio参数)以及根据当前系统的运行情况收集性能监控信息,动态调整新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等参数以提供最合适的停顿时间或者最大的吞吐量(+UseAdaptiveSizePolicy)。自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。

Serial Old收集器

Serial收集器的老年代版本,单线程收集器,一般作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

CMS收集器

一种以获取最短回收停顿时间为目标的收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。

  • 初始标记(Stop The World)

    仅仅只是标记一下GC Roots能直接关联到的对象,速度很快

  • 并发标记

    从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与用户线程一起并发运行

  • 重新标记(Stop The World)

    修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。耗时比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

  • 并发清除

    最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的(耗时最长的并发标记和并发清除阶段中可以与用户线程并发)。

在CMS中,也有RSet的概念,在老年代中有一块区域用来记录指向新生代的引用。

缺点:

CMS默认启动的回收线程数是(处理器核心数量+3)/4,当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大(并发阶段)。

由于CMS收集器无法处理“浮动垃圾”(程序在CMS并发阶段所产生的新的垃圾对象,一部分垃圾对象是出现在标记过程结束以后,需要留待下一次垃圾收集时再清理掉),CMS必须预留一部分空间供并发收集时的程序运作使用,要是预留的内存无法满足程序分配新对象的需要,则会出现一次“并发失败”(Con-current Mode Failure),实际上是 full gc 的时候 cms gc 还在进行中导致抛这个错。

究其原因是因为分配速率太快导致堆不够用,回收不过来因此产生 full gc。。

CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,需要移动存活对象(标记—整理算法),无法并发,停顿变长

Shenandoah收集器(谢南多厄)

OpenJDK支持,OracleJDK不支持。能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。

Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系。

与G1的区别在于回收阶段:

  • 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate GarbageRegion)。
  • 并发回收(Concurrent Evacuation):Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。Shenandoah将会通过读屏障和被称为“BrooksPointers”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。
  • 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
  • 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
  • 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
  • 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate GarbageRegions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

转发指针:实现对象移动与用户程序并发的一种解决方案。在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。从结构上来看,Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位(关于对象定位详见第2章)有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。通过对象头上的BrooksPointer来保证并发时原对象与复制对象的访问一致性。

Epsilon收集器

“自动内存管理子系统”,只负责堆的管理和对象的分配,不进行垃圾收集

传统Java有着内存占用较大,在容器中启动时间长,即时编译需要缓慢优化等特点,这对大型应用来说并不是什么太大的问题,但对短时间、小规模的服务形式就有诸多不适。为了应对新的技术潮流,最近几个版本的JDK逐渐加入了提前编译、面向应用的类数据共享等支持。Epsilon也是有着类似的目标,如果读者的应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。

G1收集器

JDK9成为服务端模式下的默认垃圾收集器。开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒。

相关概念

Region

G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。事实上,这些region最后又被分别标记为Eden,Survivor和old。这里的eden,survivor和old已经是一个标签,也就是说只是一个逻辑表示,不是物理表示。

Region中的一部分空间(每个 region 会维持 TAMS (top at mark start)指针,分别是 prevTAMS 和 nextTAMS 分别标记两次并发标记开始时候 Top 指针的位置。Top 指针就是 region 中最新分配对象的位置,在标记时,如果该区域又分配了一些对象,那么top指针就会移动,所以 nextTAMS 和 Top 之间区域的对象都是新分配的对象都认为其是存活的)划分出来用于并发回收过程中的新对象分配(内存回收的速度赶不上内存分配的速度同样会Stop The World,JDK10后由于合并了Shenandoah的代码获得多线程Full GC的支持)。

Region大小为1M到32M,默认个数为2048个

巨型对象(humongous object,H-obj):当新建对象大小超过 Region 大小一半时,直接在新的一个或多个连续 Region 中分配

RSet

RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。

Region作为单次回收的最小单元,在处理跨Region引用对象上,维护有自己的记忆集(其实是一个Hash Table,key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index),这些记忆集会记录下别的Region指向自己的指针(G1的记忆集和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间)。AWS Lambda 中 Java 运行时就是使用的 Serial GC,可以大大降低单个 function 的启动和运行开销。

CSet收集集合

收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。年轻代收集(YGC)的CSet只容纳年轻代分区,而混合收集(Mixed GC)会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

  • 候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;
  • 同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。

并发标记周期 Concurrent Marking Cycle

这个阶段将会为混合收集周期识别垃圾最多的老年代分区。

整个周期完成根标记、识别所有(可能)存活对象,并计算每个分区的活跃度,从而确定GC效率等级。

当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。如果Mixed GC周期结束后老年代使用率还是超过45%,那么会再次触发全局并发标记过程,这样就会导致频繁的老年代GC,影响应用吞吐量。同时老年代空间不大,Mixed GC回收的空间肯定是偏少的。可以适当调高IHOP的值,当然如果此值太高,很容易导致年轻代晋升失败而触发Full GC,所以需要多次调整测试。

整个并发标记周期将由初始标记(Initial Mark)、根分区扫描(Root Region Scanning)、并发标记(Concurrent Marking)、重新标记(Remark)、清除(Cleanup)几个阶段组成。

其中,初始标记(随年轻代收集一起活动)、重新标记、清除是STW的,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集(YGC)。

初始标记(initial marking)

Stop The World。扫描根集合,标记所有从根集合可直接到达的对象并将它们的字段压入扫描栈(marking stack)中等到后续扫描。G1使用外部的bitmap来记录mark信息,在分代式G1模式中,初始标记阶段借用young GC的暂停,因而没有额外的、单独的暂停阶段。

根分区扫描(Root region scanning)

在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。

并发标记(concurrent marking)

并发阶段。由参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)控制启动线程数量。不断从扫描栈取出引用递归扫描整个堆里的对象图。每扫描到一个对象就会对其标记,并将其字段压入扫描栈。重复扫描过程直到扫描栈清空。过程中还会扫描SATB write barrier所记录下的引用。

所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。 如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。

最终标记(final marking,在实现中也叫remarking)

Stop The World。在完成并发标记后,处理satb mark queue中的对象,确保这部分对象在本轮GC是存活的。同时这个阶段也进行弱引用处理(reference processing)。 注意这个暂停与CMS的remark有一个本质上的区别,那就是这个暂停只需要扫描SATB buffer,而CMS的remark需要重新扫描mod-union table里的dirty card外加整个根集合,而此时整个young gen(不管对象死活)都会被当作根集合的一部分,因而CMS remark有可能会非常慢。

清理(cleanup)

Stop The World。清点和重置标记状态。

  • 更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集
  • 识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期。

MixedGC

分代式G1模式下有两种选定CSet的子模式,分别对应young GC与mixed GC:

  • Young GC:生成对象时,G1会选一个分区并指定他为eden分区,当这块分区用满了之后,G1会选一个新的分区作为eden分区,这个操作会一直进行下去直到达到eden分区上限,也就是说eden分区已经被占满,那么会触发一次年轻代收集。选定所有young gen里的region。通过控制young gen的region个数来控制young GC的开销。young gen region总是在CSet内。因此分代式G1不维护从young gen region出发的引用涉及的RSet更新。
  • Mixed GC:选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region。在用户指定的开销目标范围内尽可能选择收益高的old gen region。当达到IHOP参数并完成并发标记周期之后,混合收集周期就启动了,mixed GC正在进行中G1不会启动并发标记,反之亦然。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,JVM通过参数控制混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。

G1的调优

G1的调优目标主要是在避免FULL GC和疏散失败的前提下,尽量实现较短的停顿时间和较高的吞吐量。关于G1 GC的调优,需要记住以下几点:

  1. 不要自己显式设置新生代的大小(用Xmn-XX:NewRatio参数),如果显式设置新生代的大小,会导致目标时间这个参数失效。
  2. 由于G1收集器自身已经有一套预测和调整机制了,因此我们首先的选择是相信它,即调整-XX:MaxGCPauseMillis=N参数,这也符合G1的目的——让GC调优尽量简单,这里有个取舍:如果减小这个参数的值,就意味着会调小新生代的大小,也会导致新生代GC发生得更频繁,同时,还会导致混合收集周期中回收的老年代分区减少,从而增加FULL GC的风险。这个时间设置得越短,应用的吞吐量也会受到影响。官方有个tips:时间小于等于程序响应客户端的时间的10%,比如http服务响应浏览器的时间一般就1000ms,那么可以设置停顿时间是100ms。
  3. 针对混合垃圾收集的调优。如果调整这期望的最大暂停时间这个参数还是无法解决问题,即在日志中仍然可以看到FULL GC的现象,那么就需要自己手动做一些调整,可以做的调整包括:
  • 调整G1垃圾收集的后台线程数,通过设置-XX:ConcGCThreads=n这个参数,可以增加后台标记线程的数量,帮G1赢得这场你追我赶的游戏;
  • 调整G1垃圾收集器并发周期的频率,如果让G1更早得启动垃圾收集,也可以帮助G1赢得这场比赛,那么可以通过设置-XX:InitiatingHeapOccupancyPercent这个参数来实现这个目标,如果将这个参数调小,G1就会更早得触发并发垃圾收集周期。这个值需要谨慎设置:如果这个参数设置得太高,会导致FULL GC出现得频繁;如果这个值设置得过小,又会导致G1频繁得进行并发收集,白白浪费CPU资源。通过GC日志可以通过一个点来判断GC是否正常——在一轮并发周期结束后,需要确保堆剩下的空间小于InitiatingHeapOccupancyPercent的值。
  • 调整G1垃圾收集器的混合收集的工作量,即在一次混合垃圾收集中尽量多处理一些分区,可以从另外一方面提高混合垃圾收集的频率。在一次混合收集中可以回收多少分区,取决于三个因素:(1)有多少个分区被认定为垃圾分区,-XX:G1MixedGCLiveThresholdPercent=n这个参数表示如果一个分区中的存活对象比例超过n,就不会被挑选为垃圾分区,因此可以通过这个参数控制每次混合收集的分区个数,这个参数的值越大,某个分区越容易被当做是垃圾分区;(2)G1在一个并发周期中,最多经历几次混合收集周期,这个可以通过-XX:G1MixedGCCountTarget=n设置,默认是8,如果减小这个值,可以增加每次混合收集收集的分区数,但是可能会导致停顿时间过长;(3)期望的GC停顿的最大值,由MaxGCPauseMillis参数确定,默认值是200ms,在混合收集周期内的停顿时间是向上规整的,如果实际运行时间比这个参数小,那么G1就能收集更多的分区。

垃圾收集器的权衡

应用程序的主要关注点是什么?如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。

对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,而对于更大的堆内存,可重点考察一下G1。

调试

查看GC基本信息,在JDK 9之前使用-XX:+PrintGC,JDK 9后使用-Xlog:gc。

查看GC详细信息,在JDK 9之前使用-XX:+PrintGCDetails,在JDK 9之后使用-X-log:gc*

查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用-XX:+PrintHeapAtGC,JDK 9之后使用-Xlog:gc+heap=debug

查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用-XX:+Print-GCApplicationConcurrentTime以及-XX:+PrintGCApplicationStoppedTime,JDK 9之后使用-Xlog:safepoint

内存分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

在运行时通过-Xms20M、-Xmx20M、-Xmn10M这三个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:Survivor-Ratio=8决定了新生代中Eden区与一个Survivor区的空间比例是8∶1

在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中(详见第2章)。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,否则fullGC