经典垃圾收集器
经典收集器之间的关系如图,七种作用于不同分代的收集器。两个收集器之间存在连线,就说明它们可以搭配使用。图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器:
- 新生代收集器:Serial、ParNew、Parallel Scavenge
- 老年代收集器:CMS、Serial Old、Parallel Old
- 整堆收集器: G1
名词解释
并行(Parallel)
并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
并发(Concurrent)
并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
吞吐量
吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
吞
吐
量
=
运
行
用
户
代
码
的
时
间
运
行
用
户
代
码
的
时
间
+
运
行
垃
圾
手
机
时
间
吞吐量 = {运行用户代码的时间\over 运行用户代码的时间+运行垃圾手机时间}
吞吐量=运行用户代码的时间+运行垃圾手机时间运行用户代码的时间
如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
Serial收集器
新生代收集器,Serial收集器是最基础、历史最悠久的新生代收集器。
特点
简单而高效。内存资源受限的环境,额外内存消耗(Memory Footprint)是最小的;单核处理器或处理器核心数较少的环境,没有线程交互的开销,可以获得最高的单线程收集效率。
缺点
单线程工作,进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束(Stop The World)。
适用场景
客户端模式下,如用户桌面的应用场景以及部分微服务应用中。
ParNew收集器
新生代收集器,Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。
特点
多线程,可使用处理器核心数量多时,垃圾回收效率高;默认开启的收集线程数与处理器核心数量相同,可限制垃圾收集的线程数。
缺点
和Serial收集器一样存在Stop The World问题。
适用场景
服务端模式下,JDK 7之前的遗留系统中首选的新生代收集器,原因是除了Serial收集器外,目前只有它能与CMS收集器配合工作。
Parallel Scavenge收集器
新生代收集器,与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。
特点
基于标记-复制算法;多线程,能够并行收集;主要关注吞吐量,目标是达到一个可控制的吞吐量(Throughput);自适应调节策略。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量:
1、-XX:MaxGCPauseMillis参数,控制最大垃圾收集停顿时间;
一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值(垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的)。
2、-XX:GCTimeRatio参数,直接设置吞吐量大小;
一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
自适应调节策略
开关参数 -XX:+UseAdaptiveSizePolicy,激活后不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
适用场景
高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
Serial Old收集器
老年代收集器,Serial Old是Serial收集器的老年代版本。
特点
单线程收集器,使用标记-整理算法。
适用场景
供客户端模式下的HotSpot虚拟机使用;服务端模式可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用;另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本。
特点
支持多线程并发收集,基于标记-整理算法实现。
适用场景
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加ParallelOld收集器这个组合。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
特点
以获取最短回收停顿时间为目标,基于标记-清除算法实现,并发收集、低停顿。
运作过程
- 初始标记(CMS initial mark):标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop TheWorld”;
- 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
- 重新标记(CMS remark):为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,停顿时间比初始标记阶段稍长,比并发标记阶段的时间远远短,需要“Stop TheWorld”
- 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS收集器的运作步骤中并发和需要停顿的阶段如图:
适用场景
较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验的应用,如集中在互联网网站或者基于浏览器的B/S系统的服务端上的很大一部分Java应用。
缺点
- 对CPU资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量;
- 无法处理浮动垃圾,可能出现Concurrent Model Failure失败而导致另一次Full GC的产生;
- 因为采用标记-清除算法所以会存在空间碎片的问题,导致大对象无法分配空间,不得不提前触发一次Full GC。
Garbage First收集器
Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
适用场景
G1是一款主要面向服务端应用的垃圾收集器。
特点
- Mixed GC模式:面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。
- 整体来看是基于“标记-整理”算法实现的收集器,局部(两个Region之间)上看又是基于“标记-复制”算法实现,运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。
- 可预测的停顿时间模型:将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?
使用记忆集避免全堆作为GC Roots扫描,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。。
缺点:G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担,根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。
在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,CMS收集器采用增量更新算法实现,而G1收集器则是通过**原始快照(SATB)**算法来实现的。
此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop TheWorld”。
怎样建立起可靠的停顿预测模型?
用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,G1收集器的停顿预测模型是以衰减均值(DecayingAverage)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。
“衰减均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。Region的统计状态越新越能决定其回收的价值,通过这些信息预测开始回收,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
运作过程
- 初始标记(Initial Marking):标记一下GC Roots能直接关联到的对象,修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。需要停顿线程,但耗时很短,是借用进行Minor GC的时候同步完成的,所以在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
低延迟垃圾收集器
Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种目标。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”。
Shenandoah收集器
Shenandoah收集器是第一款由非Oracle开发的垃圾收集器,由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献给了OpenJDK。是一款只有OpenJDK才会包含,而OracleJDK里反而不存在的收集器。
对比G1
和G1一样,Shenandoah也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的Region。
但在管理堆内存方面,它与G1至少有三个明显的不同之处:
1、支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发;
2、默认不使用分代收集的,不会有专门的新生代Region或者老年代Region的存在,出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。
3、Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。
连接矩阵可以简单的理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记。在回收时通过这张表格就可以得出哪些Region 之间产生了跨代引用。
工作过程
- 初始标记(Initial Marking):与G1一样,标记与GCRoots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
- 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
- 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。
- 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。
- 并发回收(Concurrent Evacuation):核心差异,在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中,并通过读屏障和被称为“Brooks Pointers”的转发指针来解决,在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址的困难点。并发回收阶段运行的时间长短取决于回收集的大小。
- 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。建立了一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务。初始引用更新时间很短,会产生一个非常短暂的停顿。
- 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
- 最终引用更新(Final Update Reference):修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
- 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。
三个最重要的并发节点:并发标记、并发回收、并发引用更新。
BrooksPointer
Shenandoah用以支持并行整理的核心概念——BrooksPointer转发指针。
在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己(和句柄有一些相似,都是一种间接性的对象访问方式,差别是句柄存储在句柄池中,转发指针是在每一个对象头前面。)
当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作,如图:
多线程竞争问题
通过比较并交换(Compare And Swap,CAS)操作来保证并发时对象的访问正确性的。
缺点
数量庞大的读屏障带来的性能开销,高运行负担使得吞吐量下降。
ZGC收集器
ZGC(Z Garbage Collector),在JDK 11中新加入的具有实验性质的低延迟垃圾收集器,由Oracle公司研发的,基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
内存布局
ZGC的内存布局与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小。
在x64硬件平台下,ZGC的Region可以具有大、中、小三类容量:
- 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
- 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
- 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象。
并发整理算法的实现
Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样用到了读屏障,但与Shenandoah完全不同,它采用的染色指针技术。
染色指针是一种直接将少量额外的信息存储在指针上的技术,ZGC的染色指针直接把标记信息记在引用对象的指针上。
Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存仍够充分满足大型服务器的需要。ZGC的染色指针技术即利用剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。由于这些标志位压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)。
染色指针的三大优势
1、染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。
2、染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,就可以省去一些专门的记录操作。
3、染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
多重映射
Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,如图:
运作过程
ZGC的运作过程大致可划分为以下四个大的阶段:
-
并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。
-
并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(RelocationSet)。此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
-
并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
指针的“自愈”(Self-Healing)能力
得益于染色指针,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象。
优点
1、只有第一次访问旧对象会陷入转发,也就是只慢一次,对比Shenandoah的Brooks转发指针,每次对象访问都必须付出的固定开销,ZGC对用户程序的运行时负载要低一些。
2、一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(转发表除外),即使堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。
-
并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
浮动垃圾产生
一次完整的并发收集,假设全过程要持续十分钟以上,在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,新对象很难进入当次收集的标记范围,就只能全部当作存活对象来看待,但其中绝大部分对象都是朝生夕灭的,这就产生了大量的浮动垃圾。
目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。
“NUMA-Aware”的内存分配
NUMA(Non-Uniform MemoryAccess,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构。
在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,保证高效的内存访问。ZGC之前的收集器就只有针对吞吐量设计的ParallelScavenge支持NUMA内存分配,如今ZGC也成为另外一个选择。