前言
在前两篇文章中,我对垃圾收集的思想和垃圾收集的行为都有了一定的分析,但是我们要知道的是,垃圾收集这种操作,是实实在在的存在于一个垃圾收集器中的。
不同的垃圾收集器,可能会使用不同的垃圾收集思想,不同的垃圾收集算法,不同的垃圾收集行为。也因此,如果要真正的理解我们的垃圾收集,我们需要深入到每一个的垃圾收集器中,才能了解到这些思想,这些算法,这些行为,错乱组成的各个垃圾收集器,会有哪些优势和劣势,这样我们才能更深入的明白关于JVM的垃圾收集。
正文
垃圾收集器的发展历史已经非常久远了,远的有出名的单线程垃圾收集器Serial收集器,近的比较出位的是我们G1垃圾收集器,ZGC垃圾收集器,Shenandoah收集器等等。
这些垃圾收集器,有的是年代久远的最基础的垃圾收集器,有的是最近才推出的。有的是Oracle官方的垃圾收集器,有的不是。有的使用分代收集思想的垃圾收集器,有的是使用分区收集思想的垃圾收集器,甚至两者都有。这些垃圾收集器相互配合,或者是单打独斗,组成了对整个JVM里面需要回收的内存区域的垃圾收集工作。
但是由于垃圾收集器的历史非常的博大精深,而且数量繁多,在本文中,我只选取几个经典的,或者比较独特的,或者是影响力比较大的垃圾收集器进行分析,如果有垃圾收集器没有被我所讲述,那也是很正常的事情,大家有需要的话可以自己私下去找一找,看一看。
在这之前,要说明一下的是,我们所谓的Minor GC,Major GC,Full GC,Mixed GC,这些gc都是一种垃圾收集行为,那么我们的垃圾收集器,就是这些行为概念的具体实现。
1.Serial收集器
Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。
Serial收集器是只专属于新生代的垃圾收集器,也就是说,这个垃圾收集器,是只收集新生代的垃圾的,那么也意味着,单单是这一个垃圾收集器,是无法满足JVM的整个堆的收集,必然是需要其他的垃圾收集器配合它,一般配合的是CMS垃圾收集器。
Serial收集器是一个单线程工作的收集器,但是这个单线程的意思,不是说这个收集器只使用一个线程去完成垃圾收集工作,而是说,当Serial垃圾收集器收集垃圾的时候,是不允许其他的工作线程工作的,必须暂停其他所有的工作线程,直到它收集完成。
这也就是我们经常所说的“STW”——Stop The World!
虽然这种垃圾收集器有着这样巨大的毛病,但不意味着这个垃圾收集器在当前这个时代已经没有用处了。
事实上,迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,这个优势就是简单而且高效的垃圾收集能力。
- 在内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;
- 对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
- 在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。
1.1 Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
- 在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用。
- 作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
2.ParNew收集器
ParNew收集器实质上是Serial收集器的多线程并行版本,也是属于收集新生代垃圾的垃圾收集器。
除了同时使用多条线程进行垃圾收集之外,ParNew收集器其余的行为和Serial收集器极致的相似。
包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX: PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等,ParNew收集器都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是很有好处的。
ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。
可以说直到CMS的出现才巩固了ParNew的地位,但成也萧何败也萧何,随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。
G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。
由于这种属于垃圾收集器技术的出现,CMS逐渐的被G1所取代,而我们的ParNew垃圾收集器也变得不受重视,甚至直接取消了-XX:+UserParNewGC这个参数,这就意味着,ParNew和CMS只能互相搭配使用,再也没有其他收集器能够和它们配合了。
这从某种程度上来讲,意味着ParNew垃圾收集器合并到CMS垃圾收集器了,成为CMS专门收集新生代的组成部分。
也因此,ParNew成为了HotSpot虚拟机中第一款退出了历史舞台的垃圾处理器。
3.Parallel Scavenge收集器
Parallel Scavenge收集器也是属于收集新生代的垃圾收集器,当然也是基于标记-复制算法实现的垃圾收集器,也是能够并行收集的多线程收集器,和ParNew垃圾收集器有着很多相同的特性。
Parallel Scavenge垃圾收集器,当然也有着它的特性,在它之前的垃圾收集器,ParNew和Serial垃圾收集,特点都不是很明显。
而Paralle Scavenge垃圾收集器,是更集中于如何达到一个可控制的吞吐量的一款垃圾收集器。
所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
不幸的是,高吞吐量和低暂停时间,是一对相互矛盾的目标。
在保持程序运行产生的垃圾始终是那么多的情况下,如果要想将gc的运行时间减少(低暂停时间),那么也就意味着,一样多的垃圾,需要更多次数的gc来进行处理,gc的频繁执行,导致了吞吐量的降低。
如果为了高的吞吐量,那么我们可能要减少gc运行的次数,但是次数的减少,但是堆里面的垃圾数量仍然是这么多,也就是说单个次数的gc的处理垃圾时间会变长,暂停时间会变高。
如果将新生代的内存空间变少,收集的垃圾变少,确实也可以降低垃圾回收的暂停时间,但是必然导致垃圾回收的次数变多,因为内存变少了,这样的话,垃圾回收次数变多,吞吐量也会随着降低了。
也因此,有利有弊,选择Parallel Scavenge垃圾收集器,要考虑清楚。
3.1 Parallel old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
这个收集器是直到JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。
由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来得优秀。
直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
4.CMS垃圾收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,是对老年代进行垃圾收集的一款垃圾收集器,使用的垃圾回收算法是标记-清除算法。
因为CMS是一种获取最短回收停顿时间为目标的收集器,因此它的运行过程,比之前介绍的其他几款垃圾收集器要复杂点。整个步骤分为四个步骤,包括:
- 初始标记:需要停顿用户线程,但是仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
- 并发标记:并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
- 重新标记:需要停顿用户线程,是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见3.4.6节中关于增量更新的讲解),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
- 并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
CMS的优秀的地方很明显,就是并发收集,低暂停时间。
但是CMS并没有达到完美的地步,它还有以下几个缺点:
- 由于是并发收集,那么在收集垃圾的时候,必然会占用一部分处理器的资源,虽然不会导致用户线程停顿,但是却会因为占用了一部分线程而导致应用程序变慢,降低了吞吐量。
- 在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的。程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。
- 由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。
- CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
5. G1收集器
G1是一款跨时代的垃圾收集器。
它是一款能够建立起“停顿时间模型”的收集器,这个停顿时间模型的意思是指这个垃圾收集器,当我们指定在一个长度为M毫秒的时间片段的时候,这个垃圾收集器消耗在垃圾收集上的时间大概率不会超过M毫秒。
在G1之前,所有的垃圾收集器都是面向分代收集的思路,但是G1和其他的垃圾收集器不同,它虽然也使用了分代收集的思想,但是更与众不同的是,它开创了面向局部收集的设计思路和基于Region的内存布局形式。
在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。
而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。
虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异,G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
G1收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
G1收集器的运作过程大致可划分为以下四个步骤:
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针(新分配的对象地址)的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB(原始快照)记录。
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以*选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
当然,如此不同凡响的垃圾收集器,在实现的过程中,需要解决的问题自然也比其他的垃圾收集器要多,如下:
5.1 Region里面存在的跨Region引用对象如何解决?
使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃 圾收集器有着更高的内存占用负担。
5.2 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,CMS收集器采用增量更新算法实现,而G1 收集器则是通过原始快照(SATB)算法来实现的。
此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。
G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要*冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。
5.3 怎样建立起可靠的停顿预测模型?
G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得 出平均值、标准偏差、置信度等统计信息。
这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
6. ZGC收集器
ZGC收集器是一款基于Region内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
这款垃圾收集器,是非常复杂的,和其他的垃圾收集器有很大的区别,也是处于实验性质的,现阶段业界对这款垃圾收集器的文献资料还不够多,因此我也就不班门弄斧了。
总结
垃圾收集的大体流程终于结束了,但是我始终觉得,我还有很多地方没有说清楚,不是没有时间,而是确实是精力顾不上,写着写着就忘了。
不过,能梳理一次垃圾回收的概念,我觉得我对整个垃圾回收,都有了很深的了解,当然,也希望对您也有一定的帮助。
下一篇文章,暂时还没想好要写啥,再看吧。