JVM系列之:聊一聊垃圾收集器
关于 JVM 垃圾回收内容比较多,本文将继续讲述一下 JVM 发展历程中的各个垃圾收集器,这部分内容大多来源于《深入理解Java虚拟机》一文,没有太多的扩展性内容可以补充,但是为了整个系列的完整性,还是补发一下。
Serial 收集器
Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器,是基于标记-复制算法的新生代收集器。
它有两个特点:
-
它仅仅使用单线程进行垃圾回收; -
它是独占式的垃圾回收。
它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World"),直到它收集结束。
虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续 JDK 版本的更新迭代中,不断开发新的垃圾收集器,从Serial 收集器到 Parallel 收集器, 再到 Concurrent Mark Sweep(CMS) 和 Garbage First(G1) 收集器,停顿时间在不断缩短(仍然还有停顿,目前还在探索寻找最优秀的垃圾收集器)。
但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。
使用-XX:+UseSerialGC 参数可以指定使用新生代串行收集器和老年代串行收集器。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择,当虚拟机在 Client 模式下运行时,它是默认的垃圾收集器。
该收集器工作时的日志如下:
4.755: [GC (Allocation Failure) 4.755: [DefNew: 16384K->2047K(18432K), 0.0124708 secs] 16384K->6199K(59392K), 0.0125243 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
9.240: [GC (Allocation Failure) 9.240: [DefNew: 18431K->2047K(18432K), 0.0282920 secs] 22583K->20523K(59392K), 0.0283334 secs] [Times: user=0.03 sys=0.00, real=0.03 secs]
ParNew 收集器
ParNew (并行)收集器其实就是 Serial 收集器的多线程版本,同样基于标记-复制算法,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
该收集器的工作示意图如下图所示,在收集过程中,应用程序也会暂停,但由于并行收集器使用多线程进行垃圾回收,在并发能力比较强的 CPU 上,它产生的停顿时间要短于串行收集器。而在单 CPU 或者并发能力较弱的系统中,并行收集器效果不会比串行收集器好。
它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
开启 ParNew 收集器可以使用以下参数:
-XX:+UseParNewGC:新生代使用 ParNew 收集器,老年代使用串行收集器
-XX:+UseConcMarkSweepGC:新生代使用 ParNew 收集器,老年代使用CMS
ParNew 收集器工作时的线程数量可以使用 -XX:ParallelGCThreads 参数指定,一般最好与 CPU 数量相当,避免过多的线程数,影响垃圾收集性能。在默认情况下,当 CPU 数量小于 8个时,ParallelGCThreads 的值等于 CPU 个数,当 CPU 个数大于 8时,ParallelGCThreads 的值等于 3+((5*CPU_Count)/8)。
ParNew 收集器工作时的日志输入如下所示:
4.822: [GC (Allocation Failure) 4.823: [ParNew: 16384K->2047K(18432K), 0.0254299 secs] 16384K->6751K(59392K), 0.0256276 secs] [Times: user=0.09 sys=0.02, real=0.03 secs]
9.381: [GC (Allocation Failure) 9.381: [ParNew: 18431K->2048K(18432K), 0.0199511 secs] 23135K->22457K(59392K), 0.0200059 secs] [Times: user=0.11 sys=0.02, real=0.02 secs]
ParNew 收集器可以在多线程环境下工作,后续介绍其他收集器还会涉及到“并发”和“并行”这两个概念,在垃圾收集器的上下文语境中,它们可以理解为:
-
并行(Parallel):并行描述的是多条垃圾回收线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。 -
并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。同时,因为用户线程可能会导致对象的引用链发送改变,进而影响垃圾回收线程的工作。
Parallel Scavenge 收集器
Parallel Scavenge 收集器类似于ParNew 收集器,基于标记-复制算法,也是能够并行收集的多线程收集器。在实际应用中可以组合使用:
-XX:+UseParallelGC:使用Parallel收集器+ 老年代串行
-XX:+UseParallelOldGC:使用Parallel收集器+ 老年代并行
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。
该收集器工作时的日志如下所示:
4.561: [GC (Allocation Failure) [PSYoungGen: 15360K->2540K(17920K)] 15360K->5861K(58880K), 0.0046649 secs] [Times: user=0.02 sys=0.01, real=0.00 secs]
8.778: [GC (Allocation Failure) [PSYoungGen: 17900K->2540K(17920K)] 21221K->18789K(58880K), 0.0102343 secs] [Times: user=0.07 sys=0.02, real=0.01 secs]
Serial Old收集器
Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。它主要有两大用途:一种用途是在 JDK1.5以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。
若要启用 Serial Old 收集器,可以尝试使用以下参数。
-XX:+UseSerialGC:新生代和老年代都使用串行回收器
-XX:+UseParNewGC:新生代使用 ParNew 收集器,老年代使用串行收集器
-XX:+UseParallelGC:新生代使用 ParallelGC收集器,老年代使用串行收集器
该收集器工作时的日志如下:
16.400: [Full GC (Allocation Failure) 16.400: [Tenured: 40959K->40959K(40960K), 0.0762813 secs] 59391K->59391K(59392K), [Metaspace: 3765K->3765K(1056768K)], 0.0763080 secs] [Times: user=0.08 sys=0.00, real=0.08 secs]
16.476: [Full GC (Allocation Failure) 16.476: [Tenured: 40959K->40959K(40960K), 0.0756075 secs] 59391K->59391K(59392K), [Metaspace: 3765K->3765K(1056768K)], 0.0756301 secs] [Times: user=0.07 sys=0.00, real=0.07 secs]
Parallel Old收集器
Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。
该收集器工作时的日志如下所示:
16.889: [Full GC (Ergonomics) [PSYoungGen: 15359K->15359K(17920K)] [ParOldGen: 40942K->40942K(40960K)] 56302K->56302K(58880K), [Metaspace: 3766K->3766K(1056768K)], 0.0311130 secs] [Times: user=0.29 sys=0.01, real=0.03 secs]
16.920: [Full GC (Ergonomics) [PSYoungGen: 15359K->15359K(17920K)] [ParOldGen: 40944K->40944K(40960K)] 56304K->56304K(58880K), [Metaspace: 3766K->3766K(1056768K)], 0.0304346 secs] [Times: user=0.29 sys=0.00, real=0.03 secs]
JDK8 注重吞吐量以及CPU资源,默认使用 Parallel Scavenge 收集器和 Parallel Old 收集器。
% java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_301"
Java(TM) SE Runtime Environment (build 1.8.0_301-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.301-b09, mixed mode)
UseParallelGC 即 Parallel Scavenge + Parallel Old。
CMS 收集器
CMS(Concurrent Mark Sweep 并发-标记-清除 )收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS(Concurrent Mark Sweep)收集器是我HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的 Mark Sweep 这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为五个步骤:
-
初始标记:暂停所有的其他线程,并记录下直接与root相连的对象,速度很快 ; -
并发标记:同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。 -
重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,同样需要暂停其他线程,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。 -
并发清除:开启用户线程,同时GC线程开始对为标记的区域做清扫。 -
并发重置:垃圾回收完成后,重新初始化 CMS 数据结构和数据,为下一次垃圾回收做好准备。
开启 CMS 收集器的参数:
-XX:+UseConcMarkSweepGC:新生代使用 ParNew 收集器,老年代使用CMS
CMS 默认启动的并发线程数是(ParallelGCThreads+3)/4。关于 ParallelGCThreads,上文提到过,是 GC 并行时使用的线程数量。如果新生代使用 ParNew,那么 ParallelGCThreads 就是新生代 GC 的线程数量。并发线程数量也可以通过 -XX:ConcGCThreads 或者 -XX:ParallelCMSThreads 参数手工设定。
CMS 日志输出如下所示:
12.724: [GC (CMS Initial Mark) [1 CMS-initial-mark: 37113K(40960K)] 39484K(59392K), 0.0004892 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
12.724: [CMS-concurrent-mark-start]
12.742: [CMS-concurrent-mark: 0.018/0.018 secs] [Times: user=0.06 sys=0.00, real=0.02 secs]
12.742: [CMS-concurrent-preclean-start]
12.742: [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
12.742: [GC (CMS Final Remark) [YG occupancy: 2371 K (18432 K)]12.742: [Rescan (parallel) , 0.0004109 secs]12.743: [weak refs processing, 0.0000538 secs]12.743: [class unloading, 0.0003207 secs]12.743: [scrub symbol table, 0.0003475 secs]12.744: [scrub string table, 0.0001665 secs][1 CMS-remark: 37113K(40960K)] 39484K(59392K), 0.0013732 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
12.744: [CMS-concurrent-sweep-start]
12.758: [CMS-concurrent-sweep: 0.014/0.014 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
12.758: [CMS-concurrent-reset-start]
12.758: [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
可以看到 CMS 工作过程中包含了上述提到的几个阶段,日志中还可以看到 CMS 的耗时以及堆内存信息。
如果 CMS 遇到收集失败的情况,日志会这样显示:
16.467: [Full GC (Allocation Failure) 16.468: [CMS16.487: [CMS-concurrent-mark: 0.018/0.019 secs] [Times: user=0.05 sys=0.00, real=0.02 secs]
(concurrent mode failure): 40959K->40959K(40960K), 0.0935820 secs] 59391K->59391K(59392K), [Metaspace: 3768K->3768K(1056768K)], 0.0936062 secs] [Times: user=0.13 sys=0.00, real=0.10 secs]
16.561: [Full GC (Allocation Failure) 16.561: [CMS: 40959K->40959K(40960K), 0.0829561 secs] 59391K->59391K(59392K), [Metaspace: 3768K->3768K(1056768K)], 0.0829808 secs] [Times: user=0.08 sys=0.00, real=0.08 secs]
16.644: [Full GC (Allocation Failure) 16.644: [CMS: 40959K->1011K(40960K), 0.0202456 secs] 59391K->1011K(59392K), [Metaspace: 3769K->3769K(1056768K)], 0.0202698 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
这很可能是由于程序在运行过程中老年代空间不足所导致的。
综合来看,CMS 是一款优秀的垃圾收集器,不过还是被代替了。它的主要优点:并发收集、低停顿。但是也有三个明显的缺点:
-
对CPU资源敏感; -
无法处理浮动垃圾; -
它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
CMS 收集器因为并发处理还会遇到“对象消失”的问题,在上文我们提到过 CMS是基于增量更新来做并发标记的。
G1收集器
G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
JDK 9发布之日,G1宣告取代 Parallel Scavenge 加 Parallel Old 组合, 成为服务端模式下的默认垃圾收集器, 而CMS则沦落至被声明为不推荐使用(Deprecate) 的收集器。
特点介绍
介绍前几个垃圾收集器的时候,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。
G1收集器却跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set, 一般简称CSet) 进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的 Mixed GC 模式。
具体表现为:虽然还是保留的有新生代和老年代的概念,但是新生代和老年代之前再也不是区域上的隔离了。它将整个 Java 堆划分为多个大小相等的独立区域,叫做 Region 。而新生代和老年代就是由一个个 Region 动态组成的区域,它们可以是不连续的区间。
每一个 Region 都可以根据需要,扮演新生代的 Eden 空间,Survivor 空间,或者老年代空间。除此之外它还有一类特殊的区域叫做 Humongous,专门用来存储大对象。G1认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。
注意,大对象在以下场景会引起性能问题。
-
大对象的存活周期很短。 -
满足第一条时会在年轻代回收对象 Region 。 -
频繁地分配大对象。
G1的堆内存被划分为多个大小相等的 Region ,但是 Region 的总个数在 2048 个左右,默认是 2048。对于一个 Region 来说,是逻辑连续的一段空间,其大小的取值范围是 1MB 到 32MB 之间。
结构如下:
上面的E、S和没有写字母的蓝色方块(可以理解为old),H 是以往的垃圾收集器中没有的概念,它代表 Humongous。
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字Garbage-First的由来, 更具体一点的做法就是每个 Region 里面堆积的垃圾都有一个“价值”(价值即回收所获得的空间大小以及回收所需要的时间的经验值))。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
所以回收阶段会优先处理回收价值最大的那些 Region。因此,一次回收的过程并不会回收所有的Region。
G1收集器的运作大致分为以下几个步骤:
-
初始标记(Initial Marking):这阶段仅仅只是标记 GC Roots 能直接关联到的对象并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的Region中创建新对象,这阶段需要停顿线程,但是耗时很短。而且是借用进行 Minor GC 的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
-
并发标记(Concurrent Marking):从GC Roots开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
-
最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
-
筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。
可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。
这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
G1 收集器除了并发标记外,其余阶段都需要完全暂停用户线程。可见 G1 收集器的目标是在延迟可控的情况下获得尽可能高的吞吐量。
G1收集器工作时输出日志如下:
[5.487s][info][gc,start ] GC(0) Pause Young (G1 Evacuation Pause)
[5.487s][info][gc,task ] GC(0) Using 10 workers of 10 for evacuation
[5.498s][info][gc,phases ] GC(0) Pre Evacuate Collection Set: 0.0ms
[5.498s][info][gc,phases ] GC(0) Evacuate Collection Set: 10.2ms
[5.498s][info][gc,phases ] GC(0) Post Evacuate Collection Set: 0.5ms
[5.498s][info][gc,phases ] GC(0) Other: 0.2ms
[5.498s][info][gc,heap ] GC(0) Eden regions: 24->0(12)
[5.498s][info][gc,heap ] GC(0) Survivor regions: 0->3(3)
[5.498s][info][gc,heap ] GC(0) Old regions: 0->11
[5.498s][info][gc,heap ] GC(0) Humongous regions: 5->5
[5.498s][info][gc,metaspace ] GC(0) Metaspace: 6984K->6984K(1056768K)
[5.498s][info][gc ] GC(0) Pause Young (G1 Evacuation Pause) 29M->18M(60M) 10.904ms
[5.498s][info][gc,cpu ] GC(0) User=0.05s Sys=0.04s Real=0.01s
参考文献
《深入理解Java虚拟机》