跳到主要内容

04、调优实战 - 常用垃圾收集器

1. 新生代收集器

1.1 Serial 收集器

Serial 收集器在JDK 1.3之前是HotSpot虚拟机新生代收集器的唯一选择,也是一个单线程工作的垃圾收集器。

由于它没有线程交互的开销,专心做垃圾收集可以获得更高的单线程收集效率,所以Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择,也是至今Hotspot虚拟机运行在客户端模式下的默认新生代收集器。

1.2 ParNew 收集器

ParNew 收集器本质就是 Serial 收集器的多线程版本,除了同时使用多条垃圾回收线程进行收集之外,其余的行为包括 Serial收集器可用的参数、收集算法、Stop the World、对象分配规则等都与Serial收集器完全一致。

ParNew 收集器在单核心处理器的环境中不会有比Serial收集器更好的效果,甚至由于线程交互等开销,它的性能可能还会低于Serial收集器;

ParNew 收集器 是目前为止除了Serial收集器之外,唯一能与CMS收集器配合工作的新生代收集器;

JVM参数指定 ParNew 收集器:-XX:+UseParNewGC;

1.2.1 默认线程数量

现在一般我们部署应用的服务器都是多核CPU的,所以当使用 ParNew 收集器时,它默认的垃圾回收线程数量是跟CPU核数是一样的;这个参数一般不要手动去调节。

如果一定要手动调节,可以使用JVM 参数:-XX:ParallelGCThreads;

1.2.2 运行示意图

 

1.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器(也叫做 吞吐量优先收集器)也是能够并行收集的多线程收集器,它的特点是:关注点跟其他收集器不同,它的目标是达到一个可控制的吞吐量(Throughput),也就是处理器用于运行用户代码的时间与总消耗时间的比值;即:

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)

吞吐量越高,则可以最高效率的利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

1.3.1 控制吞吐量

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量

-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,收集器将尽力保证内存回收花费的时间不超过这个设定值;

-XX:GCTimeRatio:直接设置吞吐量大小,是一个大于0小于100的正数,也就是垃圾收集时间占总时间的比值;

1.3.2 运行示意图

 

2. 老年代收集器

2.1 Serial Old 收集器

Serial Old 收集器是Serial收集器的老年代版本,同样也是一个单线程收集器,使用 标记-整理算法。

这个收集器主要是也供客户端模式下的HotSpot虚拟机使用;如果在服务端模式下,有两种用途:

  • 在JDK 5 及之前的版本中与 Parallel Scavenge 收集器 搭配使用;
  • 在CMS 收集器并发收集时发生 Concurrent Mode Failure 时使用;

2.1.1 运行示意图

 

2.2 Parallel Old 收集器

Parallel Old 是Parallel Scavenge 收集器的老年代版本,同样支持多线程并发收集,是基于 标记-整理 算法实现的。

2.2.1 运行示意图

 

2.3 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种 以获取最短回收停顿时间为目标的收集器,基于 标记-清除算法 实现。

2.3.1 CMS垃圾回收流程

示例代码:

 

回收流程

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

2.3.1.1 初始标记(CMS initial mark)

CMS要进行垃圾回收时,要先执行初始标记阶段,这个阶段会让系统的工作线程全部停止,进入“Stop the World”状态:

 

这里的初始标记,是标记出 所有的GC Roots 直接引用的对象

示例代码中,静态变量 replicaManager这个GC Roots直接引用的 ReplicaManager 对象才会被标记,而 ReplicaFetcher对象不是被直接引用的就不会被标记。(因为类中的实例变量不是 GC Roots)

初始标记阶段 速度很快,耗时很短,因为只需要标记 GC Roots直接引用的对象。

2.3.1.2 并发标记(CMS concurrent mark)

并发标记阶段,不会进入 “Stop the World”状态,允许系统的工作线程继续工作;在这个阶段,可能会有新创建的对象,也有可能部分前面存活的对象失去引用变成垃圾对象。

在这个阶段中,会对老年代所有的对象进行 GC Roots追踪,也就是说 会标记出 所有的 GC Roots直接和间接引用的对象

示例代码中,ReplicaFetcher对象被 replicaFetcher变量引用了,而 replicaFetcher变量是 ReplicaManager对象的实例变量;所以会继续追踪,ReplicaManager对象被谁引用了,又追踪到 ReplicaManager对象是被 replicaManager这个静态变量引用了;而这个静态变量是 GC Roots,所以可以判定 ReplicaFetcher对象是被 GC Roots间接引用的,所以也会把它标记为存活对象,不需要回收它。

 

并发标记阶段 速度很慢,非常耗时,因为要追踪所有对象是否从根源上被 GC Roots引用了;但是又因为允许系统并发运行,所以这个阶段不会对系统运行造成太大影响。

2.3.1.3 重新标记(CMS remark)

在并发标记阶段里,一边标记存活对象和垃圾对象,一边系统也在一直工作,会产生新对象和让老对象变成垃圾对象;所以在这个运行期间会有很多存活对象和垃圾对象是没有被标记的:

 

所以这个时候,需要让系统停下来,也就是进入“Stop the World”状态,重新去标记 在并发标记阶段状态变更了的一些对象

 

重新标记阶段 速度很快,耗时很短,因为只需要标记并发阶段中被系统运行变动过的少数对象。

2.3.1.4 并发清除(CMS concurrent sweep)

并发清除阶段不会进入 “Stop the World”状态,允许系统随意运行,只需要清理掉之前标记为垃圾的对象即可。

并发清除阶段 非常耗时,因为需要进行对象的清理,但是允许系统并发运行,所以对系统程序的执行没有太大影响。

 

Q:为什么CMS要用 标记-清除 算法,而不用 标记-整理 算法?

A:这个可以从CMS的目标来思考,CMS收集器的目标是 获取最短回收停顿时间

  • 如果使用 标记-整理 算法的话,在标记阶段用户线程可以和垃圾回收线程并发执行,但是在整理阶段,用户线程不能和垃圾回收线程并发执行,这样就会导致停顿时间过长,不能达到CMS的追求目标;
  • 对于CMS使用的 标记-清除 算法,在标记和清除阶段,用户线程都可以和垃圾回收线程并发执行,也就不会使得用户线程停顿时间过长,从而达到 获取最短回收停顿时间的目标;
  • 这里贴上 R大对于这个问题的详细解答:并发垃圾收集器(CMS)为什么没有采用标记-整理算法来实现?

2.3.2 CMS 性能分析

从CMS的四个回收流程可以看出来,JVM已经尽可能的进行了性能优化了;

其中最耗时的两个阶段

并发标记阶段,对老年代所有对象进行 GC Roots的追踪,标记出直接引用和间接引用的对象;

并发清除阶段,对各种垃圾对象进行清理;

但是这两个阶段都是允许用户线程并发运行的,所以对系统的运行影响也较小了;

初始标记 和 重新标记阶段,需要 “Stop the World”,但是这两个阶段只是进行简单的标记,执行速度非常快,所以基本上对系统运行影响也不大。

2.3.3 CMS 运行示意图

 

2.3.4 Parnew + CMS 的痛点

其实Parnew + CMS 的垃圾回收器组合最大的痛点还是 “Stop the World”。无论是新生代垃圾回收,还是老年代垃圾回收,都会产生“Stop the World”,然后对系统的运行造成影响。

即使JVM已经在尽力进行优化了,但还是存在不小的问题;在之后垃圾回收器的优化,都是朝着减少“Stop the World”的目标去做的

3. G1 收集器

G1垃圾回收器是可以同时回收新生代和老年代的对象的,不再需要两个垃圾回收器配合起来运作,它自己就可以搞定所有的垃圾回收。

与其他的GC收集器相比,G1具备如下几个特点:

并行与并发:G1可以使用多个垃圾回收线程并行收集,缩短 Stop the World的时间;

G1可以通过与工作线程并发执行的方式,减少对系统的影响;

  • 分代收集:分代的概念在G1中依然保留,虽然G1不需要与其他收集器配合,但是它能够采用不同的方式去处理 新生代和老年代中的不同对象;
  • 空间整合:G1整体上是基于 标记-整理算法实现的,但是局部(两个Region之间)上是基于 复制算法实现的;所以G1在运行期间不会产生内存碎片,收集后能提供规整的可用内存;
  • 可预测的停顿时间:G1在追求低停顿的同时,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒;

3.1 G1的实现方式

3.1.1 堆内存划分

G1收集器在内存划分的实现方式上和其他收集器几乎完全不同;在使用G1收集器时,将Java堆划分成为多个大小相等的独立区域(Region);虽然还保留了新生代和老年代的概念(逻辑概念),但是新生代和老年代都不再是隔离的了,而都是一部分Region的集合,并且可以动态变化。

 

每个 Region都被标记了 E、S、O、H,说明每个Region在运行时都充当了一种角色(新生代、老年代、大对象等);

H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj);

3.1.2 避免全堆扫描

在G1收集器中,Region之间的对象引用(以及其他收集器中的新生代与老年代之间的对象引用)是使用 Remembered Set 来避免全堆扫描的。

G1中每个Region都有一个与之对应的 Remembered Set,JVM虚拟机发现程序在对 Reference 类型的数据进行更新操作时,会产生一个 Write Barrier暂时中断这个更新操作,先 检查Reference引用的对象是否在不同的Region中都被引用(在分代情况下就是检查是否老年代中的对象引用了新生代中的对象),如果是则会通过 CardTable 把相关引用信息记录到被引用对象所属的 Region中的 Remembered Set中

当进行垃圾回收时,在GC根节点的枚举范围中加入 Remembered Set 就可以保证不对全堆进行扫描也不会有遗漏的对象。

3.2 G1的可预测停顿时间模型

可预测的停顿时间模型:

  • 能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒;

  • 简单来说就是希望G1在垃圾回收的时候,可以保证在1小时内由G1垃圾回收导致的“Stop the World”时间,也就是系统停顿的时间,不能超过1分钟。

  • 这样也就相当于我们就可以直接 控制垃圾回收对系统性能的影响了

  • 可预测的停顿时间模型的实现:

  • G1追踪各个Region里面的垃圾回收价值,并且在后台维护一个 优先列表 用来维护Region回收价值;

  • 垃圾回收价值:它必须搞清楚每个Region里面有多少垃圾,如果对这个Region进行回收,需要消耗多少时间,可以回收掉多少垃圾;

  • 每次根据允许的停顿时间,优先收集回收价值最大的Region;

3.3 G1 Region的动态分配

在G1中,每个Region既可能属于新生代,也可能属于老年代。

刚开始一个Region可能谁都不属于,然后被分配给了新生代,存放了很多属于新生代的对象;然后垃圾回收的时候回收了这个Region:

 

回收之后,这个Region就空出来了,在下一次内存分配的时候,这个Region可能就会被分配给老年代了,用来存放老年代的长生命周期的对象:

 

所以其实在G1对应的内存模型中,Region会随时属于新生代也会属于老年代,所以没有所谓新生代给多少内存,老年代给多少内存这一说了。

实际上新生代和老年代各自的内存区域及大小是不停的变动的,由G1根据当前应用的对象生成情况自动控制

3.4 G1的内存分配

假设当前Java堆内存设置为 4G

3.4.1 Region 内存分配

Region 个数:G1中默认有2048个Region;

Region 大小:

  • 每个Region大小是固定相等的,Region的大小可以通过JVM参数 -XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数;
  • 如果不设定,那么G1会根据Heap大小自动决定:size = 堆大小/2048;
  • 在4G堆内存下,除以2048个Region,每个Region的大小就是2MB。

3.4.2 分代内存分配

在G1中虽然把内存划分为了很多的 Region,但是其实还是有新生代、老年代的区分。

G1中刚开始的时候,默认新生代占堆内存的比例是5%;也可以通过JVM参数 -XX:G1NewSizePercent来设置新生代初始占比(建议维持这个默认值);

即在4G堆内存下,新生代占据200MB左右的内存,对应大概是100个Region;

 

在系统运行过程中,随着新生代对象的增多,JVM会不停的给新生代增加更多的Region,但是最多新生代的占比不会超过默认比例60%;也可以通过JVM参数 -XX:G1MaxNewSizePercent来设置新生代最大占比;

一旦Region进行了垃圾回收,此时新生代的Region数量就会减少,所以这些都是动态的。

并且新生代里还是有Eden和Survivor的划分的,根据新生代的JVM参数 -XX:SurvivorRatio=8,还是可以区分出来属于新生代的Region中,哪些属于Eden,哪些属于Survivor;

比如上面的新生代初始的时候,有100个Region,那么可能80个Region就是Eden,两个Survivor各自占10个Region:

 

随着对象不停的在新生代里分配,属于新生代的Region会不断增加,Eden和Survivor对应的Region也会不断增加。

3.5 G1的垃圾回收

3.5.1 新生代垃圾回收(YGC)

既然G1的新生代也有Eden和Survivor的区分,那么触发垃圾回收的机制也都是类似的;
随着系统运行生成越来越多的对象,JVM也就会不停的给新生代加入更多的Region,直到新生代内存达到堆内存的最大比例60%。

在新生代达到了设定占比60%时,比如有1200个Region了,里面的Eden可能占据了1000个Region,每个Survivor是100个Region,而且Eden区还装满了对象:

 

这个时候就会触发新生代的GC,G1会使用 复制算法 来进行垃圾回收(新生代回收过程跟其他垃圾回收器类似);进入“Stop the World”状态,然后把Eden区对应的Region中的存活对象放入S1对应的Region中,接着回收掉Eden区对应的Region中的垃圾对象:

 

由于G1是可以设定目标GC停顿时间的,也就是G1执行GC的时候最多可以让系统停顿多长时间,可以通过JVM参数 -XX:MaxGCPauseMills参数来设定,默认值是200ms;

那么G1就会对每个Region追踪它的回收价值,保证在 指定的GC停顿时

间违范围内,尽可能多的回收掉一些对象。

3.5.2 老年代垃圾回收(MIXGC、FGC)

在G1的内存模型下,新生代和老年代各自都会占据一定的Region,所以老年代也会有自己的Region;按照默认新生代最多只能占据堆内存60%的Region来推算,老年代最多可以占据40%的Region,大概就是800个左右的Region。

G1中对象进入老年代的条件(除了大对象,跟其他收集器相同):

分配担保

年龄阈值:当经过多次Minor GC之后,年龄阈值(-XX:MaxTenuringThreshold)达到15的对象,就会被移到老年代;

动态年龄判断:如果年龄从小到大的一批对象的总大小大于了Survivor区的50%,此时大于等于这批对象最大年龄的对象,则提前进入老年代;(即年龄1+年龄2+年龄n的多个对象大小总和超过了Survivor区的50%,此时就会把年龄n以上的对象提前放入老年代)

所以经历了一段时间的新生代使用的垃圾回收后,就可能会有一些对象进入了老年代:

 

3.5.2.1 混合GC(MIXGC)

在G1中,对于新生代保留了YGC,并加上了一种全新的MIXGC用于收集老年代;那什么时候会触发MIXGC呢?

当老年代占据了堆内存一定比例的Region的时候,此时就会尝试触发一个 新生代 + 老年代 + 大对象 一起回收的混合回收阶段;这个比例由JVM参数 -XX:InitiatingHeapOccupancyPercent指定,默认值 45%。

按照之前说的,堆内存有2048个Region,如果老年代占据了其中45%的Region,也就是接近1000个Region的时候,就会触发混合回收:

 

混合回收流程:

  • 初始标记(iniail Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

3.5.2.1.1 初始标记(iniail Marking)#

G1的初始标记阶段,同样会让系统的工作线程全部停止,进入“Stop the World”状态;并且仅仅 只标记GC Roots 直接引用的对象,整个过程速度很快

 

还会修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的Region中创建新对象。

3.5.2.1.2 并发标记(Concurrent Marking)#

G1的并发标记阶段,同样不会进入 “Stop the World”状态,允许系统的工作线程继续工作;

同时进行GC Roots追踪,从GC Roots开始追踪所有的存活对象,也就是说 会标记出所有的 GC Roots直接和间接引用的对象;(跟上面的CMS阶段类似)

 

JVM会在这个阶段记录对象的修改动作(比如哪个对象被新建了,哪个对象失去了引用),这些变化将被记录在线程 Remembered Set Logs 里面。

这个阶段因为要追踪全部的存活对象,速度是比较慢的,非常耗时;但是这个阶段是跟系统程序并发运行的,所以对系统程序的影响不大。

3.5.2.1.3 最终标记(Final Marking)#

G1的最终标记阶段,同样会进入“Stop the World”状态,禁止系统程序运行;

然后会根据 并发标记阶段 记录的对象修改动作,最终标记出哪些对象存活,哪些对象失去引用需要收集

 

3.5.2.1.4 筛选回收(Live Data Counting and Evacuation)#

最后的筛选回收阶段,会计算老年代中每个Region中的存活对象数量,存活对象的占比,并对各个Region的回收价值和成本进行排序;然后停止用户线程,进入“Stop the World”状态,全力以赴地进行垃圾回收。

此时会按照Region的回收价值,选择部分Region进行回收,让垃圾回收的停顿时间控制在用户指定的范围内

比如老年代此时有1000个Region都满了,但是因为根据预定目标,本次垃圾回收只能停顿200毫秒,那么通过之前的计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,把GC导致的停顿时间控制在用户指定的范围内:
 

3.5.2.1.5 混合GC 运行示意图#

 

3.5.2.2 Full GC

在进行Mixed回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region的存活对象拷贝到别的Region里去。

如果在拷贝的过程中发现没有空闲Region可以存放存活对象了,就会触发一次 混合GC失败,然后使用Full GC 进行回收;

此时G1会停止应用程序的执行(Stop-The-World),使用 单线程(Serial Old) 的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

3.5.3 大对象Region

在G1中,提供了专门的 大对象Region(Humongous) 来存放大对象,而不是让大对象进入老年代的Region中。

大对象的判定规则是超过了一个Region大小的50%,比如按照上面算的,每个Region是2MB,只要一个大对象超过了1MB,就会被放入大对象专门的Region中;而且一个对象如果太大,可能会横跨多个Region来存放:

 

也就是这里的 Humongous;

G1中新生代和老年代的Region是不停的变化的,比如新生代现在占据了1200个Region,但是一次垃圾回收之后,回收掉了1000个Region;此时这1000个Region也就不属于新生代了,它们就可以用来存放大对象。

在新生代、老年代执行垃圾回收的时候,会顺带着大对象Region一起回收。

3.6 G1的JVM参数

参数 描述
-XX:+UseG1GC 使用G1 垃圾收集器;
-XX:MaxGCPauseMillis=n 设置最大GC停顿时间,这是一个软目标,JVM会尽最大努力去达到它;
-XX:InitiatingHeapOccupancyPercent=n 启动并发标记循环的堆占用率的百分比,当整个堆的占用达到比例时,启动一个全局并发标记循环,0代表并发标记一直运行;(默认值45%)
-XX:NewRatio=n 新生代和老年代大小的比例,默认是2;
-XX:SurvivorRatio=n eden和survivor区域空间大小的比例;(默认值8)
-XX:MaxTenuringThreshold=n 晋升的阈值,一个存活对象经历多少次GC周期之后晋升到老年代;(默认1)5
-XX:ParallelGCThreads=n 设置GC并发阶段的线程数,默认值与JVM运行平台相关;
-XX:ConcGCThreads=n 设置并发标记的线程数,默认值与JVM运行平台相关;
-XX:G1ReservePercent=n 设置保留java堆大小比例,用于防止晋升失败/Evacuation Failure;(默认值10%)
-XX:G1HeapRegionSize=n 设置Region的大小;(默认值根据堆的大小动态决定,大小范围 [1M,32M])

3.7 G1的使用场景

任何东西的使用场景都离不开它的特性,所以G1的使用场景也是由G1的特性决定的。

3.7.1 G1的优缺点

优点:G1最大的优点就是 可以建立可预测的停顿时间模型,可以让用户控制应用在垃圾回收时的停顿时间;

缺点:如果应用的内存使用情况非常吃紧,在垃圾回收时对于部分内存回收根本不够(也就是说经常需要对整个堆进行回收);这种时候由于G1本身的算法更复杂,可能性能就会比其他回收器差;

3.7.2 G1适合的使用场景

根据以上G1的优越点,就可以得出G1更适用以下场景:

堆大小较大(4G以上)的应用;(由于堆大小较大,在等到堆积了很多垃圾对象后开始回收;如果是ParNew+CMS收集器,它们没法控制停顿时间,就会停顿很长时间进行回收)

对GC停顿时间更敏感的应用;(要求更少的停顿时间,如即时通讯等)