Title

HotSpot经典垃圾收集器

内容摘抄自《深入理解Java虚拟机 第三版》
这里讨论的是在JDK7 Update4之后的,JDK11正式发布之前的,OracleJDK中的HotSpot虚拟机所包含的全部可用的垃圾收集器

各款经典的垃圾收集器如下图3-6所示

3-4-1

图3-6展示了用于不同分代的收集器,如果两个收集器之间存在连线说明它们可以搭配使用,途中收集器所在位置说明是新生代收集器还是老年代收集器。

收集器并无好坏之说,只是在不同场景下选择最适合的收集器。

Update 2021-09-25

3.5.1 Serial收集器

Serial收集器是最基础的,历史最悠久的收集器。在JDK1.3之前是HotSpot虚拟机新生代收集器的唯一选择。Serial收集器是单线程工作的收集器,但它的单线程并不不仅仅是说明它只是用一个处理器一条收集线程取完成收集工作,更重要的是在它进行垃圾收集时,必须停掉其他所有工作进程,直到它收集结束。在用户不可知,不可控的情况下把用户的工作线程全部停掉,这对很多应用来说是不可接受的。下图3-7示意了Serial/Serial Old收集器的运行过程

image-20210926224328140

从上图可分析出Serial在新生代采用了标记-复制算法,Serial Old在老年代采用了标记-整理算法

事实上,Serial收集器仍然是HotSpot虚拟机的新生代的默认收集器。因为它有着优于其他收集器的地方,那就是简单高效,对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的收集器;对于单核处理器或者处理器核心较少的环境来说,Serial收集器没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

在用户桌面以及部分微服务应用中,分配给虚拟机管理的内存一般不会太大,收集几十兆甚至一两百兆(仅指新生代),Serial收集器的停顿时间完全可以控制在十几甚至几十毫秒,最多一两百毫秒,只要不频繁收集,这点停顿,用户完全无感,可接受。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好选择

3.5.2 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程升级版。除了使用多线程进行垃圾收集外,其余的行为包括Serial收集器的可用的所有控制参数(例如:-XX:SurvivorRatio, -XX:PretenureSizeThreshold, --XX:HandlePromotionFailure等),收集算法,Stop The World,对象分配规则,回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多代码。ParNew收集器的工作过程如下图3-8所示

image-20210926230156657

从上图可分析出ParNew收集器Serial收集器一样在新生代采用了标记-复制算法,Serial Old收集器在老年代采用了标记-整理算法

ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK1.7之前系统的首选新生代收集器。其中一个与功能,性能无关但其实很重要的原因是:除了Serial外,目前只有它能和CMS收集器搭配工作

在JDK5发布时,HotSpot推出了一款在强交互应用上几乎可称为划时代意义的垃圾收集器-CMS收集器。这款收集器是HtoSpot虚拟机的第一款真正意义上的支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作

遗憾的是CMS作为老年代作为老年代收集器,却无法与JDK1.4中已存在的新生代收集器Parallel Scavenge搭配使用。所以在JDK1.5中使用CMS来收集老年代时,只能选择Serial或者ParNew中一个。ParNew收集器是激活CMS(使用-XX:+UseConcMarkSweepGC选项)后的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定/禁用它。

可以说CMS的出现加固了ParNew的地位,但是随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。G1是一个面向全堆的收集器,不需要与其他收集器搭配工作。所以JDK9开始,ParNew+CMS的组合不再作为官方推荐的服务器模式下的收集器的解决方案了。官方希望G1全面取代ParNew+CMSParNew可以说是第一款退出历史舞台的垃圾收集器

Notice

从ParNew开始,逐步涉及并行和并发概念
1. 并行(Parallel):并行描述的是多条垃圾收集器之间的关系,说明同一时间有多条这样的线程协同工作,通常认为此时用户线程处于等待状态
2. 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。用户线程并未被冻结,程序仍然能相应服务请求,由于垃圾收集线程占用一定资源,应用程序处理的吞吐请求会受到一定影响

Update 2021-09-26

3.5.3 Parallel Scavenge收集器

Parallel Scavenge收集器也是新生代收集器,它同样是基于标记-复制算法实现的,也能够并行收集的收集器。Parallel Scavenge收集器的 诸多特性与ParNew非常相似。

Paralle Scavenge收集器的特点就是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能的缩短垃圾收集器收集时用户线程的定顿时间,而Parallel Scavenge收集器的目标则是达到一个可控的吞吐量(Throughput)。

\[吞吐量 = \frac{ 运行用户代码时间}{运行用户代码时间+垃圾收集时间} \]

如果虚拟机完成某个任务,用户代码加上垃圾收集共用100s,其中垃圾收集用了10s,那么吞吐量就是90%。

停顿时间越短就越适合需要与用户交互或保证请求响应质量的程序。高吞吐可以高效的利用处理器资源,尽快完成任务。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集时间的-XX:MaxGCPauseMillis参数,以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

-XX:MaxGCPauseMillis参数允许设置大于0的毫秒数,收集器尽力保证回收时间不超过用户设置的值。但不要奢求把这个参数设置的更小一点就能是系统的垃圾收集速度更快,垃圾收集的缩短是以牺牲吞吐量和新生代时间为代价换取得。

-XX:GCTimeRatio参数的值应当设置为一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如设置为19,那么允许的垃圾收集时间占总时间的5%(1/(1+19)),默认值为99,即最大允许1%( 1 /(1+99))的垃圾收集时间。

由于与吞吐量十分密切,Parallel Scavenge又被称为吞吐量优先收集器。除了上述两个参数外,它还有一个参数-XX:+UseAdaptiveSizePolicy。这是一个开关参数,参数被激活后,就不需要人工设置新生代大小(-Xmn),Eden与Survivor比例(-XX:Ergonomics),晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行状况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或者最大的吞吐量。这种调节被称为垃圾收集器的自适应策略(GC Ergonomics)。

3.5.4 Serial Old收集器

Serial OldSerial的老年代版本,同样是单线程收集器,使用标记-整理算法。这个收集器的主要意义是提供给客户端模式下的HotSpot虚拟机使用。如果在服务端,它有两种用途:

  • 在JDK5之前版本中与Parallel Scavenge收集器搭配使用
  • 另外一种是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

3.5.5 Parallel Old收集器

Parallel Old收集器是Parallel Scavenge的老年代版本,支持多线程并发收集,基于标记-整理算法实现。在JDK6时提供,在此之前Parallel Scavenge一直处于相当尴尬的状态,原因是如果新生代选了Parallel Scavenge,老年代除了Serial Old(PS MarkSweep)外别无选择,其他表现良好的收集器如CMS无法搭配器工作。由于老年代Serial Old在服务端性能上的拖累,使用Parallel Scavenge收集器也未必能在整理上获得吞吐量的最大化。同样由于单线程的老年代收集收集中无法充分利用服务器多处理器的并行处理能力,这种组合甚至不一定比Par NewCMS的组合来的优秀。

直到Parallel Old的出现,吞吐量优先的收集器才有了名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以考虑Parallel ScavengeParallel Old的组合。Parallel Old收集器的工作过程如下图3-10所示

image-20210927232226013

2021-09-27

3.5-6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间的收集器。适用于Java应用程序集中在互联网网站,基于浏览器的BS系统服务。

从名字可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程分为四个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记 (CMS concurrent mark)
  • 重新标记 (CMS remark)
  • 并发清除 (CMS concurrent sweep)

其中初始标记和重新标记仍然需要“STOP THE WORLD”停顿。

初始标记仅仅需要标记一下GC Root能直接关联的对象,速度很快;并发标记就是从GC Root的直接关联对象开始遍历整个对象图的过程,这个过程耗时常,但是不需要停顿用户线程,(用户线程)可与垃圾收集线程并发运行;而重新标记阶段则为了修正并发标记阶段,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会必初始标记阶段长一些,但是远比并发标记时间短;并发清除阶段,通常清理掉标记阶段已经死亡的对象,由于不需要移动对象,这个阶段也是可以与用户线程并发运行的

由于整个阶段中耗时比较长的并发标记并发清除阶段中,垃圾收集器可以与用户线程一起工作。所以,从总的来说,CMS收集器的回收过程是与用户线程并发运行的。具体过程如下图3-11所示

image-20211020224646141

CMS收集器是一款优秀的垃圾收集器,它的主要有点在名字上已经体现出:并发收集,低停顿,一些官方公开文档称之为并发低停顿收集器CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它远达不到完美程度,其至少有以下三个明显缺点:

  • 首先,CMS收集器对处理器的资源十分敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是却会因为占用了一部分线程(或者说处理器的计算能力)而导致程序变慢,总吞吐量降低。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心在四个以上时,并发回收垃圾线程只占用不超过25%的处理器运算资源,并且会随着处理器的核心增加而下降。但是,当处理器核心不足四个是,CMS对用户线程的影响可能就会变得很大。如果应用本来负载就很高,还要分出一半的运算能力去执行收集器线程,就会导致用户程序执行速度急剧下降。为了解决这种为题,虚拟机提供一个称为增量式并发收集器(Incremental Concurrent Mark Sweep/i-CMS)的CMS变种,所做的事情和以前单核处理器年PC机操作系统靠抢占式多任务来模拟多核运行多任务思想是一样的,是在并发标记,清理的时候让收集线程,用户线程交替运行,尽量减少垃圾收集线程独占资源的时间,这样整体的垃圾收集过程会变长,但是对用户程序的影响就会显得很小。实践证明,增量式的CMS收集器效果一般,JDK7开始,已经被声明weideprecated,JDK9后被完全弃用
  • 第二,CMS收集器无法处理浮动垃圾(Floating Garbage),有可能出现Concurrent Mode Failure失败进而导致另一次完全Stop The World的Full GC的产生。在CMS的并发标记,清除阶段用户线程还在继续运行,程序在运行自然就会伴随有新的垃圾对象的不断产生,但是这些垃圾对象是出现在标记过程结束之后的,CMS无法在本次垃圾收集中处理掉他们,只能等到下次垃圾收集清理。这一部分就称之为浮动垃圾。同样也是由于垃圾收集阶段用户线程还在继续运行,所以CMS收集器不能同其他收集器一样,等到老年代几乎完全被装满再进行收集,必须预留一部分空间供并发收集时的程序运作。JDK5默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果老年代不是增长太快,可以适当提高参数值-XX:CMSInitiatingOccu-pancyFraction来提高CMS的触发百分比。JDK6时,阈值提升至92%。但这又会面临另一种风险:要是CMS预留的内存无法满足程序分配新对象的需求,就会出现“并发失败”(Concurrent ModeFailure),这时,不匿迹不得不启动后备方案:冻结用户线程的执行,临时弃用Serial Old收集器来重新收集老年代垃圾,但这样停顿时间就长了。所以用户应该在生产环境根据实际应用情况设置改参数
  • 最后一点,CMS是基于标记-清除算法实现的,这一类收集器在收集结束后会产生大量的空间碎片。空间碎片过多时,会给对象分配产生很大麻烦,往往会出现老年代还有很多剩余空间,但是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC。为解决这种情况,CMS收集器提供了-XX:+UesCMSCompactAtFullCollection开关参数(默认开启,此参数JDK9开始废弃)用于在CMS收集器不得不进行Full GC时开始内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandosh和ZGC出现前)无法并发的。这样空间碎片解决了,但是停顿时间又变长了,因此虚拟机还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK9开始废弃)这个参数主要要求CMS收集器在执行若干次(数量由参数决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认为0,表示每次进入Full GC时都进行碎片整理)

2021-10-20

3.5.7 Garbage First收集器

Garbage First(简称G1)收集器是垃圾收器技术发展史上的里程碑成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

G1是一款主要面向服务端应用的垃圾收集器。JDK9发布后称为默认垃圾收集器。

G1出现之前的所有的垃圾收集器,包括CMS在内,垃圾收集的主要目标范围要么是整个新生代(Minor GC/Young GC),要么是整个老年代(Majar GC/Old GC),再要么是整个Java堆(Full GC)。而G1跳出了这个樊笼,他可以面向堆内存任何部分组成回收集(Collect Set一般称CSet)进行回收,衡量标准不是他属于那个分代,而是那块内存中存放的垃圾数量多,回收收益最大,这就是G1收集器的Mixed GC模式

G1开创的基于Region的堆内存布局是他能够实现这目标的关键。虽然GC也是基于分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不在坚持固定大小及固定数量的分代区域划分,而是把连续的Java堆划分成大小相等的独立内存区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间,Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采取不同的策略去处理,这样无论是新创建的对象还是已经存活一段时间的,熬过多次收集的旧对象都能得到很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1只要认为对象的大小超过了一个Region的一半即为大对象。每个Region的大小都可以用参数-XX:G1HeapRegionSize设定,取值范围为1MB-32MB,且应该为2的幂次。对于那些大小超过了一个Region大小的对象,将会被存储在N个连续的Humongous Region中,G1大多数行为都把Humongous Region视为老年代的一部分看待,如图3-12所示

image-20211024230256267

虽然G1还保留新生代和老年代的概念,但是新生代和老年代不再是固定的了,他们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可以测的停顿模型,是因为它将Region作为单次回收的最小单元,即每次回收带内存空间都是Region大小的整数倍,这样就可以有效避免在整个Java堆上进行全区域的垃圾收集了。更具体的处理思路是让G1收集器去跟踪每个Region里面的垃圾堆积的价值大小,价值即回收所获取的空间大小以及回收所需的时间的经验值,然后维护一个优先级的列表,每次根据用户设定的允许的收集停顿时间(-XX:MaxGCPauseMillis指定,默认200毫秒),优先处理垃圾回收价值大的Regio,这就是Garbage First的名字来由。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间获取尽可能高的收集效益。

从G1的大体设计中可以看出,G1收集器至少一下关键细节还需处理:

  • 譬如,将Java堆划分为多个独立的Region后,Region间的跨Region引用如何解决?解决思路我们已经知道了了,就是使用记忆集(Remember Set)最为GC Root扫描,但在G1收集器上记忆集要复杂的多,它的每个Region都维护了自己的记忆集,这些记忆集会记录别的Region指向自己的指针,并标记这些指针在那些卡页之内。G1的记忆集在存储结构的本质是一种哈希表,Key作为Region的起始地址,Value是一个集合,里面存储的是元素的卡表索引号。这种“双向”的卡表结构(卡表是“我指向了谁”,这种结构还记录了谁指向了我)比原来的卡表实现更为复杂,同时由于Region的数量比传统收集器的分代数量明显要多的多,因此G1收集器要比其他收集器有着更高额内存占用负担。根据经验,G1至少要耗费大约相当Java对容量的10%-20%的额外内存来维持收集器的工作。
  • 在并发标记阶段如何保证收集线程与用户线程互不干扰的运行?这里就要解决是用户线程改变对象引用关系时,必须保证不能打破原本的对象图结构,导致标记结构出现错误,该问题的解决方法对于CMS来说其采用增量更新算法实现,而G1收集器则通过原始快照(STAB)算法实现。此外,垃圾收集器的对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象的创建,G1为每个Region设了两个名为TAMS(Top at Mark Start)的指针,把Region的一部分空间划分出来用于并发回收过程中的新对象的分配,并发回收时新分配的对象地址都需要在这两个指针以上。G1收集器默认在这个地址以上的对象是被心事标记过的,即使它们是存活的,也不会纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果回收的速度赶不上内存分配的速度,G1收集器也要被迫的冻结用户线程的执行,导致Full GC从而产生长时间的Stop The World
  • 怎样建立起可靠的停顿预测模型?+ 用户通过-XX:MaxGCPauseMills参数指定的停顿时间只意味着垃圾回收的期望值,但G1收集器怎么做才能满足用户的期望呢?G1收集器的停顿模型是以衰减均值(Decaying Average)为理论基础来实现的。G1在收集过程中会记录每个Region的回收耗时,每个Region的记忆集的脏卡数量,等各个可测量的步骤的所花费的成本,并分析出均值,标准偏差,置信度等信息。这里强调的衰减平均值更容易收到新数据的影响,平均值代表了整体的平均状态,但是衰减平均值更准确的表达出了最近的平均状态。换句话说,Region的统计状态越新越能决定其回收价值。然后通过这些信息预测现在开始回收的话,有哪些region回收组成回收机才可以在不超过期望停顿的时间的约束下获得最高利益。

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大体可划分为一下四部

  • 初始标记(Initial Marking):仅仅只是标记一下GC Root能关联到的对象,并修改TAMS指针的值,让下一阶段的用户线程并发运行时,能正确的在可用的Region中分配新对象。这个阶段会停顿,但耗时很短,而且是借用Minor GC的时候同步完成的,所以G1收集器在这个阶段并没有额外停顿。
  • 并发标记(Concurrent Marking):从GC Root开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象图,找到需要回收的对象,这个阶段耗时很长,但是可以和用户线程并发执行。当对象扫描完成以后,还要重新处理STAB记录下的在并发时有引用改变的对象
  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用户处理并发阶段结束后仍遗留的最后那些少量的STAB记录
  • 筛选回收(Live Data Counting andEvacuation):负责更新Region的统计数据,堆各个Region的回收价值和回收成本进行排序,根据用户所期望的停顿时间来制定计划,可以选择任意个Region来构成回收机,然后把决定回收的那一部分Region的存活对象度知道空的Region中,在清理掉整个旧的Region的全部空间。这里涉及对象的移动,必须停顿用户线程,由多条收集器线程并行完成。

从上述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非单纯的追求低延迟,官方给它的设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当的起全功能收集器的重任与期望

下图显示了G1收集器的运作步骤中并发和需要停顿的阶段

image-20211104223757121

需要注意的是,用户自定义停顿时间需要符合实际,若期望值不符合实际,可能会产生垃圾堆积导致占满堆进而引发Full GC降低性能情况。通常期望停顿时间设置为100-300毫秒比较合理

G1收集器开始,最先进的垃圾收集器的设计导向都不约而同变为追求能应付应用的内存分配速率(Allocation Rate),而不是追求一次把整个Java堆清理干净。这样,应用在分配内存,垃圾收集器在收集垃圾,主要垃圾收集的速度能跟得上对象的分配速度,那么一切将变得很完美了。这种设计思路从G1开始,所以说G1具有里程碑意义。

G1收集器通常会和CMS收集器比较,毕竟他们都关注停顿时间的控制,官方将他们两个并称为The Most Concurrent Collectors

相比CMSG1优点有很多,暂且不论可以指定最大停顿时间,分Region的内存布局,按收益动态确定回收集这个创造新特性带来的收益,但从最传统的算法理论来看,G1也更有潜力。与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,但从局部(两个Region之间)上看又是基于标记-复制算法实现的,无论如何,这两种算法都意味着G1在运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序的长期运行,在大对象分配内存时不容易因无法找到连续的内存空间而提前触发下一次收集。

不过G1相对于CMS仍然不占有全方位,压倒性优势。比起CMS,G1的弱项也能列举不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都比CMS高

就内存占用来说,虽然CMS和G1都使用卡表来处理跨代引用,但是G1实现的卡表更为复杂,而且堆中的每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致卡表的记忆集(和其他内存消耗)可能会占用整个堆容量的20%乃至更多内存空间;相比之下,CMS的实现就简单的多了,只有唯一一份,而且只需处理老年代到新生代的引用,反过来不需要处理,由于新生代朝生夕灭,引用变化频繁,所以能省下很大一块内存开销。

在执行负载角度上来看,两个收集器各自实现特点导致了用户线程运行时的负载会有不同,譬如都用到了写屏障,CMS用写屏障来更新维护卡表,G1使用写屏障来进行同样的卡表更新维护外,为了实现原始的快照搜索(SATB)算法,还需要使用写屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿过长的缺点,但是在用户线程中会产生有跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接同步操作,而G1不得不将其实现为类似消息队列的结构,把写前屏障和写后屏障要做的事情都放到队列中,然后再异步处理。

CMS在收集小内存应用上大概率由于G1,而在大内存应用上G1则大多能发挥优势,平衡点通常在6-G之间。

end 2021-11-04

posted @ 2021-09-25 23:45  apeGcWell  阅读(132)  评论(0编辑  收藏  举报