垃圾收集器的整体概述
经典垃圾收集器之间的关系图如下:
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所在的区域,则表示它是属于新生代收集器或是老年代收集器。
Serial收集器
Seria收集器是最基础、最悠久的收集器。该收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会用一个处理器或者一个条收集线程去完成垃圾收集工作,更重要的是强调在它垃圾收集时,必须暂停其所有工作线程(Stop the World),直到它收集结束。Serial Old收集器的运行过程如下:
Serial收集器是客户端模式下的默认新生代收集器,有着优于其他收集器的地方,就是简单高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。对于单核处理器或者处理器核心数较少的环境来说Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。用户桌面的应用场景分配给虚拟机的内存一般来说并不是很大,收集几十兆甚至一两百兆的新生代(仅仅指新生代使用的内存,桌面应用很少超过这个容量),收集器的停顿时间完全可以控制在十几、几十毫秒,最多一百毫秒以内,只要不是频繁发生手机,这点停顿时间对许多用户来说完全是可以接受的。所以Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
ParNew收集器
ParNew收集器实质上是Serial收集器的多线程并行版本,除了使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(如:-XX:SurvivorRatio、-XX:PretenureSizeThreShold、-XX:HandlePromotionFailure等)、收集算法、Stop the World 、对象分配规则、回收策略等都与Serial收集器完全一致。ParNew收集器的工作过程为:
ParNew收集器除了支持多线程并行收集器之外,其他与Serial收集器并没有太多创新之处,但它是不少运行在服务端模式下的HotSpot虚拟机,尤其JDK7之前的遗留系统中首选的新生代收集器,其中有一个功能、性能无关但很重要的原因是:
除了Serial收集器外,目前只有ParNew收集器能与CMS收集器配合工作
。
Parallel Scavenge收集器
Parallel Scavenge收集器也是一款高性能的新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。Parallel Scavenge的诸多特性表面上看起来与ParNew收集器非常相似,那么它们之间的区别是什么呢?
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码时间与处理器总耗时间的比值。即:吞吐量=运行用户代码时间/运行用户代码时间+运行垃圾收集时间
。Parallel Scavenge收集器运行过程如下:
如果虚拟机完成某项任务,用户代码加上垃圾收集总耗时100min,其中垃圾收集占用1min,那么吞吐就是99%。停顿时间越短就越适合需要与用户交互或者需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐则可以最高效的利用处理器资源,尽快完成程序的运算任务,主要适合后台运算而不需要太多交互的分析任务。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制 最大垃圾收集停顿时间的
-XX:MaxGCPauseMillis
参数以及直接设置吞吐量大小的-XX:GCTineRatio
参数。
-
-XX:MaxGCPauseMillis
是设置允许的值是一个大于0的毫秒数,收集器将尽量保证内存回收花费的时间不超过用户的设定值。不过大家不要以为该值设置的越小能使得系统垃圾收集速度变快,垃圾收集停顿时间缩短是牺牲吞吐量和新生代空间为代价换取的:系统把新生代调小一些,收集300M新生代内存肯定比500M快,但也直接导致垃圾收集发生的更频繁,原来10s收集一次,每次停顿时间100ms,现在为5s一次,每次停顿70ms。停顿时间在下降,但是吞吐量也降下来了。 -
-XX:GCTineRatio
参数设置在0到100之间的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数,譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%即1/(1+19),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
Parallel Scavenge收集器被称为吞吐量优先收集器,除了上述中的参数设置外,还有一个参数-XX:+UseAdaptiveSizePolicy
,这是一个开关参数,当这个参数激活后,就不需要人工指定新生代大小、Eden与Survivor的大小比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreShold)等参数,虚拟机会自动根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式成为垃圾收集的自适应的调节策略(GC Ergonomics)。如果我们对收集器的运作不太了解,手工优化困难的话,使用Parallel Scavenge收集器配合自适应调整策略,把内存管理交给虚拟去完成也是不错的选择,我们只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxPauseMillis
参数(更关注最大停顿时间)或者-XX:GCTimeRatio(更关注吞吐量)参数非虚拟机设立一个优化目标,具体细节参数的调节工作由虚拟机完成。自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。
Serial Old收集器
Serial Old收集器是单线程老年代收集器,使用的是标记-整理算法。这个收集器的意义主要也是提供在客户端模式下的HotSpot虚拟机使用。如果在服务端使用,它也可能有两种用途:
- 一种是JDK1.5及之前的版本与Parallel Scavenge收集器搭配使用
- 另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用
Serial Old收集器的工作过程如下:
Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现,在此收集器出现之前(JDK1.6之前)Parallel Scavenge收集器只能与Serial Old收集器搭配使用,直到Parallel Old收集器出现之后,”吞吐量优先“收集器有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场景下,可以优先考虑Parallel Scavenge收集器加Parallel Old收集器。Parallel Old收集器运行过程如下:
CMS收集器
CMS(Concurrent Mark Sweep)
收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能的短,以给用户良好的交互体验。
CMS收集器是基于标记-清除算法实现的,主要运作过程主要包含四个步骤:
- 初始标记(CMS inital mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
- 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时比较长不需要停顿用户线程,用户线程可以与垃圾收集线程一起并发运行。
- 重新标记(CMS remark):重新标记阶段主要是为了修正并发标记,因用户线程继续运作而导致标记产生变化的那一部分对象的标记记录(增量更新)。这个阶段的停顿时间通常会比初始标记阶段稍长一些,但远比并发标记阶段的时间短。
- 并发清除(CMS concurrent sweep):清理删除标记阶段判断已经死亡的对象,这个阶段也是可以与用户线程同时并发的。
初始标记阶段和重新标记阶段这两个步骤任然需要Stop The World
。由于整个过程中耗时最长的并发标记和并发清除阶段,都是垃圾收集线程和用户线程一起工作,所以从总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。CMS收集器运行过程如下:
CMS是一款优秀的收集器,它的主要的优点已经在名字上体现出来:并发收集、低停顿。一些官网的公开文档也称之为“并发低停顿收集器”。但是CMS收集器也存在明显的缺点:
- CMS收集器对处理器资源非常敏感,事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是却会占用一部分线程)(或者处理器的计算能力)而导致应用程序变慢,减低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4 ,也就是说,如果处理器核心数在四个及以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但当处理器核心数量不足四个时,CMS对用户程序的影响可能变得很大。如果应用本来的处理器负载很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户线程的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种
“增量式并发收集器(Incrementak Concurrent Mark Sweep/i-CMS)”
的CMS收集器的变种。所做的事情和以前单核处理器年代PC机操作系统靠抢断式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替执行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些。直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,所以到JDK9发布后i-CMS模式被废弃。 - CMS收集器无法处理
“浮动垃圾(Floating Garbage)”
,有可能出现“Con-currentMode Failure” 失败进而导致空一次完全“Stop The Word”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然还会伴随有新的垃圾对象的产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉他们,之后留待下一次垃圾收集时再清理掉。这一部分就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够的空间提供给用户线程使用,因此CMS收集器不能像其他垃圾收集器那样等到老年代几乎完全填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiationOccu-pancyFraction
的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK6,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败(Concurrent Mode Failure)”,这时候虚拟机将不得不启动后备预案:冻结执行线程,临时启用Serial Old收集器来重新进行老年代的额垃圾收集,但这样停顿时间更长了。所以参数-XX:CMSInitiationOccupancyFraction
设置得太高将会容易导致大量的并发失败产生,性能反而降低,用户在生产中根据实际应用场景来权衡设置。 - CMS采用的是标记清楚算法,这就意味着收集结束时可能会存在大量的内存空间碎片。空间碎片过多时,将会给大对象分配带来很大麻烦,往往老年代还有很多剩余空间,但是无法找到足够的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。为了解决这个问题,CMS提供了一个参数
-XX:UseCMS-CompacttAtFullCollection
开关参数(默认开启,从JDK1.9开始废弃),用户CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象(Shenandoah和ZGC出现之前)是无法并发的。这样碎片问题是解决了,但是停顿时间变长了。因此虚拟机设计者还提供了另外一个参数-XX:CMSFullGCBefore-Compaction
(此参数从JDK9开始废弃),这个参数的主要作用是要求CMS收集器执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下次进入Full GC前会先进行碎片整理(默认为0,表示每次进入Full GC时都进行碎片整理)。
G1收集器
G1是一款面向服务端的垃圾收集器。G1收集器它可以面向堆内任何部分来组成回收集(Collection Set,CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。G1是基于Region的堆内存布局,虽然G1也仍遵循分代收集理论设计的,但堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续Java堆内存划分为多个大小相等的对立区域(Region),每个Region都可以根据需要,扮演新生代Eden、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已存活了一段时间、熬过多次收集的就对象都是能够获取很好的手机效果。
Region中还有一类特殊的Humongous区域,专门用来存储大对象
。G1人为只要大小超过了Region大小的一半的对象即可判定为大对象,每个Region的大小可以通过-XX:G1GeapRegionSize
设定,取值范围为1~32M,且为2的N次幂。而对于超过了整个Region容量的超级大对象,将会被放在N个连续的Humongous Region中。G1大多数行位都把Humongous Region
作为老年代的一部分进行看待。G1划分Region示意图如下:
虽然G1仍然保留新生代和老年代的概念,但是新生代和来年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的”价值“大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先列表,每次根据用户设定允许的收集停顿时间(使用参数
-XX:MAxGCPauseMillis
指定,默认值是200ms),优先处理回收价值最大的那些Region(Garbage First->G1)。这样使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。G1的实现的几个关键节点介绍:
-
G1将堆空间划分为多个独立Region,Region里面存在跨Region引用对象如何解决? 使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上
记忆集
的应用要复杂很多,每个Region都要维护自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构上实际是一中Hash表,Key是别的region的其实地址,value是一个集合,里面存储的元素是卡表的索引号。这种”双向“卡表接口(我指向了谁,谁指向了我)比原来的卡表实现更复杂,同时由于Region数量比传统收集器的分代数量明显要多的多,因此G1收集器比其他传统垃圾收集器有更高的内存占用负担(经验值大约占用java堆空间的10%~20%)。 - 并发标记阶段如何保证收集线程与用户线程互不干扰地运行? 首先要解决用户线程改变对象引用关系时,必须保证其不能打破原有对象图结构,导致标记结果出现错误,该问题的解决办法G1主要采用了原始快照(SATB)算法实现。此外垃圾收集对用户线程的影响还体现在回收过程中创建对象的内存分配上,程序要继续运行就可定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象都是被隐式标记过的,即默认他们是存活的,不在回收范围之内。如果内存回收速度赶不上内存分配速度,G1被迫使用Serial Old 进行Full GC而产生Stop the World。
-
怎样建立可靠停顿预测模型? 用户通过
-XX: MaxGcPauseMillis
参数指定停顿时间只意味着垃圾收集发生之前的期望值,G1收集器的停顿预测模型是以衰减值(Decaying Average)为理论基础来实现,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时,每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的”衰减平均值“是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表”最近的“平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过预期挺短时间的约束下获得最高的收益。
G1收集器运作过程主要可划分为以下四个步骤:
-
初始标记
:仅仅标记GC Roots直接能关联的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确的在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器这个阶段并没有额外的停顿。 -
并发标记
:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆了的对象图,找出要回收的对象,这个阶段耗时比较长,但可与用户线程并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。 -
最终标记
:对用户线程做另一个短暂的停顿,用户处理并发阶段结束后扔遗留下来的最后少量的SATA记录。 -
筛选回收
:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收那一部分Region的存活对象复制到空的Region中,在清理整个旧Region空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成。
G1运行过程示意图:
CMS与G1比较:
- CMS采用标记-清理算法产生大量的内存分片问题,G1采用标记-复制算法,垃圾收集完成后能够提供规整的可用内存,这种特性有利于程序的长时间运行,在程序为大对象分配内存时不容易因无法找到连续的内存空间而出发下一次收集。
- G1因为每个region必须维护一份卡表(记录跨region的引用关系),这将会导致G1的记忆集可能会占用整个堆容量的更大的内存空间;相比CMS卡表只要一份只需要记录老年代到新生代的引用。
- G1和CMS各自的细节实现导致了用户程序运行时的负载会不同。CMS使用的是写屏障来维护卡表,G1除了使用写后屏障来维护卡表操作外,为了实现原始快照搜索(SATB)算法,还需要写前屏障来跟踪并发时的指针变化情况。比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS在最终标记阶段停顿过长的缺点,但在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。