目录
学习前言
一、Serial
二、ParNew
三、Parallel Scavenge
四、Serial Old
五、Parallel Old
六、CMS收集器
七、Garbage First
1. G1工作过程对内存的分配
region分区
为用户线程分配内存
触发Young GC
晋升老年代
混合收集
巨型区域(Humongous Region)
2. G1垃圾收集活动时序
3. G1的垃圾收集担保
4. G1对比CMS
八、经典垃圾收集器小结
学习前言
经过垃圾收集器与内存分配机制(一)的介绍,我们已经了解到了垃圾回收机制的基本情况,接下来就让我们一
起来看看常见且经典的垃圾收集器!
HotSpot虚拟机的垃圾收集器 如图 2.1
上图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配 使用,
图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
一、Serial
Serial收集器采用标记复制算法对新生代进行垃圾收集,GC采用单线程,并STW,如图 2.2 所示:
Safepoint,安全点,是指用户线程所执行的代码指令流能够停下来以进行垃圾收集的位置。
GC线程会等到所有用户线程都达到最近的安全点后再开始执行。
Serial收集器是最基础的收集器,虽然简单且STW时间相对较长,但在单核或内存受限的环境下,反而是很
高效的一个垃圾收集器。
例如一个只分配了单核CPU和较小内存(1G以内)的虚拟机上运行的客户端模式的JVM,就很适合使用Serial
收集器。
二、ParNew
ParNew就是Serial的GC多线程并行版本。
如图 2.3 所示:
G1成熟之前,主流的Java服务都会采用 ParNew + CMS 组合作为垃圾收集策略。
之所以新生代用ParNew其实是由于实现框架的原因,目前只有ParNew能和CMS配合使用。
如果老年代不使用CMS的话,那就也不会使用ParNew作为新生代垃圾收集器。
ParNew在单核或低核环境下是不如Serial的,因为多线程GC并行的话,会有线程间切换的损耗。
而多核环境下,其实新生代有更好的垃圾收集器选择。
三、Parallel Scavenge
Parallel Scavenge 也是新生代收集器,与ParNew在执行时基本相同,同样是基于标记复制算法实现,同样
是并行收集。
它与ParNew的不同之处在于:
- 能够通过参数控制JVM吞吐量。
- 具备自适应调节策略,把内存管理的调优任务交给虚拟机自己完成。
- 由于底层框架不同的原因,导致不能和CMS配合使用。
(1) 控制JVM吞吐量
Parallel Scavenge与其他收集器重点关注如何减少单次GC的停顿时间不同,它更关注的是如何提高JVM吞
吐量。
所谓JVM吞吐量,Throughput,它指的是用户线程运行时间占JVM总运行时间的比例。
其公式如下:吞吐量 = 用户线程运行时间 / (用户线程运行时间 + GC时间)
它是通过以下两个参数控制吞吐量:
- -XX:MaxGCPauseMillis: 最大垃圾收集停顿时间,该值是一个目标值,JVM会尽量控制每次垃圾回收
的时间不超过这个值。要注意,并不是说这个值设置的越小,GC就越快!该值设置越小的话,
Parallel Scavenge触发GC的频率就越高,因为这样每次需要收集的垃圾就越少,从而保证时间不超过
设定值。这样的话,每次的停顿时间虽然变小,但吞吐量一定也会下降。使用该参数的理论效果:
MaxGCPauseMillis越小,单次MinorGC的时间越短,MinorGC次数增多,吞吐量降低。
- -XX:GCTimeRatio : 吞吐量指标。不要被该参数的字面迷惑了,它不是GC时间占比的意思!它是GC耗
时目标公式1/(1+n)中的n参数而已。GCTimeRatio的默认值为99,因此,GC耗时的目标占比应为1/
(1+99)= 1%。使用该参数的理论效果:GCTimeRatio越大,吞吐量越大,GC的总耗时越小。有可
能导致单次MinorGC耗时变长。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效
率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此
Parallel Scavenge适合后台计算为主的Java服务。
(2) 自适应调节内存管理
Parallel Scavenge提供了参数-XX:+UseAdaptiveSizePolicy,它是一个开关参数,当这个参数打开之后,
就不需要手工指定新生代的大小-Xmn、Eden与Survivor区的比例-XX:SurvivorRatio、晋升老年代对象年
龄-XX:PretenureSizeThreshold等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动
态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略GC
Ergonomics。
四、Serial Old
Serial Old是Serial收集器的老年代版本,采用标记整理算法,同样是单线程收集。
如图 2.4 所示:
Serial Old的主要用途:
- 供客户端模式下的老年代收集用。
- 服务端模式下,作为CMS收集失败后的Full GC备案,注意此时整堆都会采用Serail Old做垃圾收集,而不仅仅是老年代。
另外,Serial Old底层使用的是mark-sweep-compact算法实现,所以有时候又叫单线程MSC;而在
Java10之前,G1收集器失败时的逃生,Full GC用的是单线程的MSC,从Java10开始,G1的Full GC改为
了多线程并行执行的MSC。
五、Parallel Old
Parallel Old就是Parallel Scavenge的老年代版本,支持GC多线程并行收集,基于标记整理算法,同样关
注于控制JVM吞吐量。
如图 2.5 所示:
在注重JVM吞吐量,后台运算较多而与用户交互较少的业务场景中,比较适合使用Parallel Scavenge +
Parallel Old的组合。事实上,Parallel Old就是专门配合Parallel Scavenge的收集器。
例如Java8的服务端模式下的默认GC策略就是Parallel Scavenge + Parallel Old。
六、CMS收集器
目前已经梳理的GC里,Serial/Serial Old/ParNew/CMS都是以减少STW时间为主要目标的。它们的适用
场景一般都是互联网应用或基于浏览器的B/S架构的后台服务,因为这一类的服务比较重要的是用户交互体
验,所以会很关注服务的响应速度。所以此时需要GC能够尽量减少STW的时间。
而CMS,就是这种场景下HotSpot中垃圾收集器的曾经的王者。它是HotSpot虚拟机上第一款支持并发回
收的垃圾收集器。CMS的全称是Concurrent Mark Sweep,即,并发标记清除。它的运行过程相比前面几
种收集器来说要复杂一些,整个过程可以简单地划分为四个阶段,分别是:
- 初始标记,Initial mark,仅仅只标记 GC Roots 能够直接关联到的对象,STW,但速度很快;
- 并发标记,Concurrent mark,从直接关联对象开始遍历整个对象图,这个过程耗时较长但并不STW,
而是让用户线程和GC线程并发执行;
- 重新标记,Final remark,因为要并发标记,所以要基于增量更新的算法重新扫描"由黑变灰"的对象(参
考前面的并发可达性分析章节),该阶段要STW,耗时一般比初始标记的耗时长,但也远比并发标记阶
段的耗时短;
- 并发清除,Concurrent sweep,该阶段清理掉被标记为不可达的对象,与用户线程并发执行。
其中,初始标记与重新标记仍然会STW,但时间都很短(重新标记在某些极端场景下会比较耗时)。而耗时最
长的并发标记和并发清除阶段,GC线程与用户线程是并发执行的。因此,CMS从总体上来说,它的GC过
程和用户线程是并发执行的。如图 2.6 所示:
实际上CMS有7个阶段,这里省略了三个阶段:
- 在重新标记之前,其实还有 Concurrent Preclean 并发预清理阶段和 Concurrent Abortable
Preclean 并发可中止预清理阶段,它们主要是为了应对可能发生的这种情况:在比较耗时的并发标记阶
段,又发生了新生代GC,甚至可能多次,导致有对象从新生代晋升到老年代。这两个阶段是为了尽量
减少这种未被可达性分析标记过的老年代对象。也可以简单地认为它们也属于并发标记阶段。
- 在并发清除之后,还有一个 Concurrent Reset 并发重置阶段,该阶段将重置与CMS相关的数据结构,
为下个周期的GC做好准备。
CMS的缺点
CMS是HotSpot实现的第一款并发收集器,它的目标当然是尽量降低停顿时间。但它远远不够完善,至少
有以下几个明显缺点:
- 对CPU资源敏感。虽然在几个并发阶段能够与用户线程并发执行,但因为占用了部分CPU资源,总会
导致用户线程的并行数量下降,降低了系统整体的吞吐量。CMS默认启动的回收线程数是(处理器核心
数量+3)/4,如果cores数在四个以上,并发回收时垃圾收集线程只占用不超过25%的CPU资源,并且
会随着cores数量的增加而下降。但是当cores数量不足四个时,CMS对用户线程的影响就变得很大。
- 存在并发收集失败进而导致完全STW的 Full GC 发生的风险。在并发标记和并发清理阶段,并发运行
的用户程序可能会导致新的对象进入老年代,这部分对象只能等到下次GC再处理(浮动垃圾),因此
CMS不能等到老年代快满时才触发,不然在并发阶段新进入老年代的对象将无处存放,但这个阈值一
般设的比较高(默认90%以上),所以会有GC期间老年代内存不足导致发生Concurrent Mode
Failure,从而启用备用的Serial Old收集器来进行整堆的Full GC,这样的话,STW就很长了。
- CMS采用的是标记清除算法,会导致内存空间碎片化问题。CMS为了解决这个问题,提供了XX:
+UseCMS-CompactAtFullCollection开关参数和XX:CM SFullGCsBefore-Compaction参数,让
CMS在执行过若干次标记清除的GC之后,下一次GC的时候先进行碎片整理。但这样又会使停顿时间
变长。
- JVM参数调优相对比较麻烦,没有自适应调节内存管理的功能。
尽管CMS有很多不如意的缺点,甚至在G1成熟后CMS已经被Oracle抛弃(在Java9中弃用并在Java15中正
式移除)。
但在目前依然占据了大部分生产环境的Java8上,ParNew + CMS依然是大多数Java服务的首选GC策略。
(尽管不是默认GC策略)
七、Garbage First
Garbage First(简称G1)是一款以Region(区域)为最小内存回收单位的,逻辑上采用分代收集理论的垃圾收
集器。
如果说CMS是曾经的王者,那么G1就是将CMS扫入"历史的垃圾堆"的替代者。
G1早在Java6中就作为实验性质的特性出现,Java7成为正式特性,Java8中继续完善,最终在Java9成为
HotSpot的首选垃圾收集器,
正式取代CMS,成为新一代的主流收集器。
从Java9到目前最新的Java16,G1一直是服务端模式下的默认垃圾收集器。以后也许会被ZGC取代,But
Not Today!
G1是一个在垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基
于Region的内存布局形式。
G1仍然使用分代收集理论,但再也不是物理上将堆内存划分为连续分配的新生代和老年代,而是变成了
逻辑上的,物理不连续的逻辑分代。而Region布局则是后续更先进的低延迟垃圾收集器比如
Shenandoah和ZGC都沿用或继续改善的内存布局方式。Region布局最显而易见的好处,就是能够更灵
活方便的分配与回收内存;在内存分配回收的灵活性与内存规整性(避免碎片化)这两者之间,使用Region
这种布局方式更容易取得平衡。
G1提供了两种垃圾收集:Young GC 与 Mixed GC。这两种GC何时触发?如何基于region工作?下面先梳
理一下G1的工作过程。
1. G1工作过程对内存的分配
先看一下G1的工作过程,以及这个过程中对内存的分配:
如图 2.7 所示:
region分区
堆内存会被切分成为很多个固定大小区域 Region 。每个 Region内部都是地址连续的虚拟内存。
每个 Region 的大小可以通过 -XX:G1HeapRegionSize 参数指定,范围是[1M~32M],值必须是2的幂次
方。
默认会把堆内存按照 2048 份均分。每个Region被标记了E、S、O和H,这些区域在逻辑上被映射为
Eden,Survivor和老年代(包括巨型区域H,后续有梳理)。每个region并不会固定属于某个分代,而是会随
着G1垃圾收集进程而不断变化。
JVM某个时刻的Region分区对应的逻辑分代示意,如图 2.8 所示:
为用户线程分配内存
刚开始的时候,大部分Region都是空白状态。G1会为每个用户线程分配一个TLAB(Thread Local
Allocation Buffers)空间,可能对应有多个TLAB块。每个TLAB块的大小不会超过一个Region,这些TLAB
块都在eden的regions中。一个用户线程创建对象申请内存时,G1会优先从该线程的TLAB块中的空闲部分
分配,如果不够再从eden的regions中申请新的TLAB块。这种方式的好处有二:一是在对象创建时,只用
检查自己的TLAB块最后一个对象的后面的空闲空间还够不够即可,从而大大加快内存分配速度;二是使得
多个用户线程的内存空间在分配时相互独立(但仍然相互可见),使得多用户线程下的内存分配可以同时进
行,变得无锁。如图 2.9 所示:
要注意的是:
- TLAB块并不是Region内部的区域划分,它甚至不是G1独有的设计。
它是给每个用户线程分配的固定大小不定数量的内存块(每个块小于region容量),
虽然位于eden Regions之中,但只是在有用户线程运行时才会有对应的TLAB块被分配出去。
- 上图中Region1与Region2并不代表两者是地址相邻的两个Region。
G1从Eden的Regions中分配TLAB时,当一个Region的空间都被分出去了,就会另外找个空闲的
region来继续分,至于这个region在哪,不一定。。。
触发Young GC
G1建立了一个 Pause Prediction Model 停顿预测模型,根据期望停顿时间来计算每次需要收集多少
Regions。
当分配内存持续进行导致eden的regions数量到达这个值时,就会触发G1的Young GC,对所有的eden的
regions进行垃圾收集。
这里的算法采用的是并行的标记复制,即,对所有eden regions中的对象进行可达性分析和标记,将所有
的存活对象整齐地复制到空闲的Regions中。这些存活对象就晋升为Survivor对象,所属的Region则从空
闲状态变为S状态(Survivor),原先eden regions会被统统清空重新回到空闲状态。
类似Serial等经典的新生代收集器对新生代的比例划分,G1对Survivor的Regions数量也有--
XX:TargetSurvivorRatio来控制,默认也是8:1。逻辑上来讲,G1的young GC也是一个eden两个
survivor进行复制的。
同时,Young GC还负责其他的一些数据统计工作,比如维护对象年龄相关信息,即存活对象经历过
Young GC的总次数。
很显然,G1的Young GC是STW的,但基于分代收集理论我们知道对新生代的可达性分析实际能够可达的
对象很少,所以停顿时间很短暂。
晋升老年代
JVM经过多轮young GC之后,那些总能存活下来的对象就会晋升到老年代。对于G1来说,就是在young
GC时,将满足晋升条件的对象从survivor region复制到老年代region中。如果需要使用空闲region来存放
晋升的对象,那么这个空闲region就变为了老年代region。
这里有个概念,叫Promotion Local Allocation Buffers,即PLAB,类似于TLAB。但PLAB不是给用户线
程分配的,是给GC线程分配的。当对象晋升到survivor分区或者老年代分区时,复制对象所需内存是从每
个GC线程自己的PLAB空间分配的。使用PLAB的好处与TLAB相同,更有效率且多线程无锁。
混合收集
当老年代空间在整个堆空间中占比(IHOP)达到一个阈值(默认45%)时,G1会进入并发标记周期(后面有梳
理),然后会进入混合收集周期。
收集的目标是整个新生代的regions + 老年代中回收价值较高的regions。相当于新生代收集 + 部分老年代
收集。
G1每次收集,会将需要收集的region放入一个叫CSet的集合,结合下图来理解young GC与Mixed GC的
收集对象不不同:
如图 2.10 所示:
所谓回收价值,是G1对每个老年代region做出的基于回收空间/回收耗时及期望暂停时间的价值评估。
G1在进行混合回收时,并不是对所有老年代region都进行回收,而是根据这个回收价值,选取价值较高的
那些老年代进行收集。
这里就能看到为什么叫Garbage First,因为会首先收集垃圾比较多的Region。。。
混合收集中的老年代收集和新生代收集一样,采用的是也是并行的复制算法。
巨型区域(Humongous Region)
Region中还有一类特殊的Humongous Region,专门用来存储巨型对象。
G1认为只要大小超过了一个Region容量一半的对象即可判定为巨型对象。而对于那些超过了整个Region
容量的巨型对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把
Humongous Region作为老年代的一部分来进行看待。
至此,大致梳理了G1如何管理内存的问题。接下来,继续梳理G1的完整的活动时序。
2. G1垃圾收集活动时序
G1垃圾收集的完整周期比较复杂,参考下面的G1垃圾收集活动时序图:
如图 2.11 所示:
整个G1的垃圾收集活动,大致可以分为以下几个时期:
- 用户线程运行时期,此时G1只有RSet维护线程在与用户线程同时运行。RSet是Region之间用于保存
相互之间对象引用关系的记忆集,每个region都有一个RSet。每个RSet记录的是哪些Region对自己的
Region中的对象存在引用关系。其结构是一个hashtable,key是其他Region的起始地址,value是一
个集合,里面的元素是其他Region中的Card的index。Card是Region内部的堆内存最小可用粒度,
512字节,有一个Global Card Table全局卡片表用来记录所有Region的Card。对象会分配在单个或物
理连续的多个Card上。如果Region1的对象A引用了Region2的对象B,而对象A的起始地址位于
Region1的某个card,那么在Region2的RSet中就会有Region1的记录,key是Region1的起始地址,
value集合中包含Region1对应Card在全局卡片表中的Index。记忆集主要用于解决可达性分析中的跨
Region引用的问题,利用Card技术可以快速定位到对象。
- young GC时期,前面已经梳理,这里不再赘述。
- 并发标记周期。当老年代的整堆空间占比(IHOP)达到阈值(45%)时,触发并发标记周期。这个周期看起
来很像CMS。它有以下几个阶段:初始标记,收集所有GC根及其直接引用,这一阶段是与young GC
一起完成的,即图中的Young GC with Initial Mark;并发标记,标记存活对象,并发标记期间可能又
会触发young GC,类似CMS中的对应阶段;重新标记,同样类似于CMS中的对应阶段,是为了解决
并发标记导致的"由黑变灰"问题,但CMS使用的是增量更新算法,而G1用的是原始快照算法;清除阶
段,识别回收价值较高的老年代Region并加入CSet,并直接回收已经没有任何存活对象的Region使之
回到空闲Region状态。重新标记与清除阶段都是并行执行的。
- 混合收集周期,在并发标记周期结束之后,会开始混合收集周期。有两点要注意。第一是混合收集并
不会马上开始,而是会先做至少一次youngGC,因为前面并发标记周期的清除阶段可能已经清除了不
少完全没有存活对象的Region,此时不必着急回收已经进入CSet的那些回收价值较高的老年代
Region。第二,混合收集并不是一次性回收CSet中所有region,而是分批收集。每次的收集可能有新
生代region的收集,可能有老年代region的收集。具体采用的算法已经在前面叙述过,这里不再赘
述。混合收集次数可以通过不同的JVM参数配合控制,其中比较重要的有:
-XX:G1MixedGCCountTarget,指定次数目标;-XX:G1HeapWastePercent,每次混合收集后计算该
值,达到指定值后就不再启动新的混合收集。
- 关于记忆集与卡表,其实这种设计并非G1独有,所有的基于分代收集理论的收集器在解决跨代引用的
问题时,都需要使用记忆集与卡表技术。只是G1的RSet与卡表的设计相对而言更复杂。G1之前的分代
收集器只用考虑新生代和老年代,比如新生代收集时,对应老年代有个卡表,凡是有跨代引用的对
象,其卡表中的元素的值会被标记为1,成为变脏,垃圾收集时通过扫描卡表里变脏的元素就能得出包
含跨代引用的Card,将其中的对象加入GcRoots即可。G1则如前所述,因为Region的数量远超出分代
数量,因此给每个Region设计了一个RSet,同时还有一个全局卡表。
- 关于增量更新与原始快照,为什么G1用SATB?CMS用增量更新?SATB相对增量更新效率会高(当然
SATB可能造成更多的浮动垃圾),G1因为region数量远多于CMS的分代数量(CMS就一块老年代区
域),重新深度扫描增量引用的根对象的话,G1的代价会比CMS高得多,所以G1选择SATB,等到下一
轮GC再重新扫描。
3. G1的垃圾收集担保
G1在对象复制/转移失败或者没法分配足够内存(比如巨型对象没有足够的连续分区分配)时,会触发Full
GC,使用的是与Serial Old收集器相同的MSC算法实现对整堆进行收集。所以一旦触发Full GC则会STW较
长时间,执行效率很低。
Java10之前是单线程MSC,Java10中改进为多线程MSC。
4. G1对比CMS
相比CMS,G1有以下优势:
- Pause Prediction Model停顿预测模型,支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。用户可以设定整个GC过程的期望停顿时间,参数 -XX:MaxGCPauseMillis 指定一个G1收集过程目标停顿时间,默认值
200ms
,不过它不是硬性条件,只是期望值。G1是通过停顿预测模型计算出来的历史数据来预测本次收集需要选择的Region数量,从而尽量满足用户设定的目标停顿时间。 - 基于Region的内存布局,使得内存分配与回收更加灵活。
- 按回收价值动态确定回收集,使得老年代的回收更加高效。
- 与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现。无论如何,这两种算法都意味着G1运作期间产生内存空间碎片要少得多,垃圾收集完成之后能提供更多规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次GC。
从工作时序上看,CMS在对老年代的回收上,采用了清除算法可以并发执行,而G1的老年代回收采用整
理算法,会导致STW。似乎CMS的停顿应该少一点。从设计理念上说,采用整理算法的G1确实应该是更
注重吞吐量而非低延迟的。但由于G1采用了很多新的设计思路,特别是停顿预测模型、Region与回收价
值,导致实际上G1很容易做到比CMS更低的停顿,特别是内存充足的场景。
相比CMS,G1的劣势是,无论是内存消耗还是CPU负载,G1都比CMS要消耗更多的资源。
例如,由于Region分区远比新生代老年代的分区数量多,且RSet维护成本更高,导致用户线程运行期间G1
的RSet维护线程要消耗更多的资源。G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的
内存空间,这要比CMS多得多。
综上,目前在小内存(4G以下)应用上,较大概率仍然是CMS表现更好;而当内存到达6G或8G以上的时
候,G1的优势就明显起来了。但随着G1越来越成熟,尤其是Java9以后,无脑使用G1是一个没啥大问题的
选择。
事实上,Oracle对G1的定位就是Fully-Featured Garbage Collector,全功能的垃圾收集器。
八、经典垃圾收集器小结
对经典垃圾收集器做一个小结,对比
收集器 | 执行方式 | 新生代 or 老年代 | 算法 | 关注点 | 适用场景 |
Serial | 串行 | 新生代 | 标记复制 | 响应速度优先 | 单CPU环境下的Client模式 |
Serial Old | 串行 | 老年代 | 标记整理 | 响应速度优先 | 单CPU环境下的Client模式,CMS与G1的后备方案 |
ParNew | 并行 | 新生代 | 标记复制 | 响应速度优先 | 多CPU环境时在Server模式下与CMS配合 |
Paraller Scavenge | 并行 | 新生代 | 标记复制 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务,与ParallelScavenge配合 |
CMS | 并发 | 老年代 | 标记清楚 | 响应速度优先 | 集中在互联网站或B/S系统的与用户交互较多的服务端上的Java应用 |
G1 | 并发 | 标记 - 整理 + 复制 | 响应速度优先 | 面向服务端应用,用来替CMS |