1. 概述
垃圾收集器是垃圾回收算法(标记-清除算法、复制算法、标记-整理算法、火车算法)的具体实现,本文主要介绍HotSpot虚拟机中的垃圾收集器。
1.1 垃圾回收器的组合
-
上图中各个垃圾回收器所处区域,则表明其是属于新生代收集器还是老年代收集器:
- 新生代收集器:Serial、ParNew、Parallel Scavenge;
- 老年代收集器:Serial Old、Parallel Old、CMS;
- 整堆收集器:G1;
-
两个收集器间有连线,表明它们可以搭配使用:
Serial/Serial Old、
Serial/CMS、
ParNew/Serial Old、
ParNew/CMS、
Parallel Scavenge/Serial Old、
Parallel Scavenge/Parallel Old、
G1;
(D)、其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案(后面介绍);
1.2 关于垃圾回收器的几点说明
* 评价垃圾收集器的指标
评价特定场合一下,一款收集器的好坏,主要有两个指标:停顿时间和吞吐量。
- 停顿时间是指进行垃圾收集时,用户线程的暂停时间,也就是之前课程所说的“Stop The World”,一般来说,用户交互较为频繁的B/S应用更为重视停顿时间的长短,停顿时间越短,用户等待时间就越少,体验就越佳。
- 吞吐量是指用于执行用户线程的时间占总应用时间的比率,对于无需和用户进行交互的纯后台应用来说,停顿时间没那么重要,更看重的是吞吐量的大小,吞吐量越大,说明执行用户线程的时间更长,处理速度就越高。
* 解释执行和即时编译器
JVM有两种方式去执行编译器编译出来的.class字节码文件——解释器(Interpreter)和即时编译器(Just In Time Complier)。
- 解释器逐字逐句的翻译,每遇到一个指令,就将它编译成本机的机器语言(Native Machine Code),然后执行,下次再遇到这一条指令,还会再编译一次;
- 而即时编译器,则像一个善于将外文翻译成地道的中文的翻译家,会对指令编译出来的机器语言进行执行效率的优化,并且把这个优化后的机器语言保存下来,下次遇到再这条的指令,就不需要编译,直接执行。
JVM运行时,默认是采用“混合模式”(Mixed Mode),也就是解释器和即使编译器搭配使用的方式。当需要程序迅速启动和快速执行时,解释器可以首先发挥作用,省去耗时的优化时间;而随着时间的推移,编译器会逐渐发挥作用,把越来越多的字节码编译并优化成本地代码,提高执行效率。
* Client模式和Server模式
这是Java的两种运行模式,顾名思义,一个用在客户端,一个用在服务器端。
Client模式和Server模式不同点在于即时编译器的优化程度,
- 当采用Client模式时,JVM只启用一个叫C1的即时编译器(Client Complier),这个即时编译器会对指令进行一些简单、可靠的优化;
- 而当采用Server模式时,则会再启用一个叫C2的重量级即时编译器(Server Complier),会进行一些比较耗时的优化,甚至会做一些不可靠的激进优化。
HotSpot会根据自身版本和宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或者“-server”来强制指定虚拟机运行在client模式或者server模式。
吞吐量与收集器关注点说明
-
吞吐量(Throughput)
CPU用于运行用户代码的时间与CPU总消耗时间的比值;
即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间);
高吞吐量即减少垃圾收集时间,让用户代码获得更长的运行时间; -
垃圾收集器期望的目标(关注点)
(1)、停顿时间
停顿时间越短就适合需要与用户交互的程序;
良好的响应速度能提升用户体验;
(2)、吞吐量
高吞吐量则可以高效率地利用CPU时间,尽快完成运算的任务;
主要适合在后台计算而不需要太多交互的任务;
(3)、覆盖区(Footprint)
在达到前面两个目标的情况下,尽量减少堆的内存空间;
可以获得更好的空间局部性;
2. 垃圾回收器的具体介绍
2.1 Serial收集器
- Serial 收集器是最基本、历史最悠久的收集器。这是一个单线程的收集器,有以下两个特点:
- 只使用一个CPU或一条收集线程去完成垃圾收集;
- 进行垃圾收集时,必须暂停其他所有工作线程,也就是前面课程提到过的“Stop The World”;
- 针对新生代
- 采用复制算法
- 这个单线程的收集器,是HotSpot运行在Client模式下的默认新生代收集器,主要是由于它有以下的优势:
- 对于单个CPU的环境来说,单线程的Serial 收集器不需要进行线程切换,减少了切换时的时间开销,因此在单CPU的环境下可以获得最高的收集效率;
- 对于大多数客户端桌面应用来说,分配给虚拟机的内存一般不大,新生代的垃圾一般在几十兆到一两百兆之间,停顿时间完全可以控制在几十毫秒到一百多毫秒以内,这点是可以接受的。
因此,Serial 收集器对于运行在客户端、单CPU环境的虚拟机来说,是一个很好的选择。
2.2 ParNew收集器
ParNew 收集器其实就是Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其他都和Serial 收集器一致。
ParNew 收集器是虚拟机运行在Server模式下的首选新生代收集器,除了因为它是多线程收集器之外,还因为它是除了Serial 收集器,唯一一个能和CMS收集器配合的收集器。
ParNew 收集器在单CPU的环境中绝对不会有比Serial 收集器更好的收集效率,甚至由于线程切换的开销,它在通过超线程技术实现的两个CPU的环境中都不能百分百保证可以超越Serial 收集器。
当然,随着CPU数量的增加,ParNew 收集器的多线程优势会越发明显,它默认开启的收集线程数和CPU的数量相同,可以使用-XX:ParalletGCThreads参数来限制垃圾收集的线程数。
2.3 Parallel Scavenge 收集器
Parallel Scavenge垃圾收集器因为与吞吐量关系密切,也称为吞吐量收集器(Throughput Collector)。
和ParNew 收集器一样的地方
- 新生代收集器
- 采用复制算法
- 多线程收集
不同之处
Parallel Scavenge 收集器最大的特点是它的关注点在于获得一个可以控制的吞吐量。
它提供了两个参数用于精确控制吞吐量,分别是
- 控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills
- 直接设置吞吐量大小的-XX:GCTimeRatio.
- MaxGCPauseMills的值越小,系统就会将新生代的大小调的越小,以加快垃圾收集的速度,但是这样也会增加了垃圾收集的频率,自然吞吐量就下去了。
- GCTimeRatio是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。可以精确地控制吞吐量。
实际使用时,经常会使用这两个参数中的一个,再配合另一个参数,-XX:UseAdaptiveSizePolicy.
UseAdaptiveSizePolicy是一个开关参数,这个参数打开之后,就不需要手工去指定新生代的大小(-Xmn)、Eden和Survivor的比例(-XX:SurvivorRatio),虚拟机会根据当前系统的运行情况,动态调整这些参数,以实现你所设置的最大垃圾收集时间或者最大吞吐量的目标。这种有点人工智能、又有点傻瓜式的调节方式,叫做GC的自适应调节策略(GC Ergonomics).
2.4 Serial Old 收集器
Serial Old 收集器是Serial 收集器的老年代版本,
- 与serial的相同点:
- 同样是单线程收集器,
- 同样是为了给Client模式的虚拟机使用,
- 不同点
使用“标记-整理”算法(serial使用复制算法)
主要用于client段,但是当运行在Server模式下时,它主要有两大用途:
- 在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
- 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用(后面详解);
2.5 Parallel Old 收集器
- Parallel Old 收集器是Parallel Scavenge 收集器的老年代版本,
- 采用多线程收集
- “标记-整理”算法。
在Parallel Old出现之前,如果新生代选择了Parallel Scavenge,老年代除了Serial Old就没有别的选择,而由于受到单线程的Serial Old在服务器端表现的拖累,使用Parallel Scavenge也未必可以获得吞吐量最大化的效果。
Parallel Old 收集器的出现让“吞吐量优先”收集器终于有了合适的应用组合。
2.6 CMS 收集器
- 特点
1. 针对老年代;
2. 基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
3. 以获取最短回收停顿时间为目标;
4. 并发收集、低停顿;
5.需要更多的内存(缺点)
CMS(Concurrent Mark Sweep)收集器的定位是获取最短的"Stop The World"的时间,也就是最短停顿时间,
在具有大量用户交互使用的B/S应用上,停顿时间越短,就越能给用户带来好的体验。那么CMS是如何做到最短停顿的呢?
答案是并发。CMS实现了垃圾收集线程和用户线程的并发执行,从名字上就可以看出,CMS收集器是基于“标记-清除”算法实现的,它的运作过程有以下4个步骤:
- 初始标记(CMS initial mak)
初始标记只是标记以下GC Roots能直接关联的对象,也就是下图中和GC Roots有直接连线的object1,因此速度很快;但是需要“stop the world”
- 并发标记(CMS concurrent mark)
并发标记就是进行GC Roots Tracing,对所有与GC Roots不可达的对象进行标记,下图中,object5和object6将会在这个阶段被标记;此时应用程序也在运行; - 重新标记(CMS remark)
重新标记则是为了修正并发标记期间,因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的时间一般比初始标记的长,但远比并发标记短;采用多线程并行执行来提升效率;
但是需要“stop the world” - 并发清除(CMS concurrent sweep)
并发清除,这个阶段JVM将启动多条线程将所有标记为unreachable的对象清除掉。
其中,初始标记和重新标记是仅有的两个需要**“Stop The World”**的阶段。其他两个阶段都不需要。
整个过程,耗时最长的并发标记和并发清除都可以与用户线程同时工作,因此能够实现最短的停顿时间。
CMS绝对是一款十分优秀的收集器,并发收集、低停顿,但是CMS还远不到完美,它有以下3个明显缺陷:
-
CMS会抢占CPU资源。并发阶段虽然不会导致用户线程暂停,但却需要CPU分出精力去执行多条垃圾收集线程,从而使得用户线程的执行速度下降。
-
CMS无法处理浮动垃圾(Floating Garbage),
在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;
可能会出现“Concurrent Mode Failure”而导致另一次Full GC。并发清理的过程中,由于用户线程还在执行,因此就会继续产生对象和垃圾,这些新的垃圾没有被标记,CMS只能在下一次收集中处理它们。这也导致了CMS不能在老年代几乎完全被填满了再去进行收集,必须预留一部分空间提供给并发收集时程序运作使用。在JDK1.5默认设置下,老年代使用了68%(JDK1.6是92%)的空间后CMS的垃圾收集就会被激活,其实这是一个比较保守的设置,只要应用中老年代增长不是很快,可以适当地调高参数-XX:CMSInitialingOccupancyFraction来提高触发百分比,降低回收的频率来获得更好的性能。如果CMS在收集期间,内存无法满足程序的需要,就会出现“Concurrent Mode Failure”,这时JVM将启动Plan B,也就是临时调用单线程的Serial Old收集器来重新进行老年代的垃圾收集,这样的话,CMS原本降低停顿时间的目的不仅没完成,和直接使用Serial Old收集器相比,还增加了前面几个阶段的停顿时间。
-
CMS的“标记-清除”算法,会导致大量空间碎片的产生(为什么?)。碎片的积累会给分配大对象带来麻烦,往往会出现明明老年代还有很多空间剩余,但是却无法找到连续的空间分配对象的情况,这时候就不得不触发一次Full GC。为了解决这个问题。CMS提供了一个-XX:+UseCMSCompactAtFullCollection的开关参数(默认是开启的),用于在CMS收集器进行Full GC时对内存碎片进行合并整理,整理的过程是需要暂停用户线程的,这样碎片虽然没有了,但停顿时间又变长了。CMS的设计初衷可是降低停顿,于是又提供了一个参数-XX:CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩碎片的Full GC后,跟着来一次带压缩的Full GC(默认值为0,即每次都会)。
2.7 G1 收集器
G1(Garbage First)收集器是当代收集器技术发展的最前沿成果之一,是一款面向服务端的收集器,HotSpot团队甚至希望G1收集器在未来可以替换掉CMS收集器。
G1收集器具有以下特点:
- 并行和并发:这一点和CMS是类似的,可以充分利用CPU的资源,来缩短“Stop The World”的时间,提高收集效率。
- 结合多种垃圾收集算法,不产生空间碎片:和CMS的“标记-清除”算法不同,**G1从整体上看是采用“标记-整理”,从局部又像是“复制”,**但无论如何,它都不会像CMS一样产生大量碎片而导致分配大对象失败的情形。
- 可预测的停顿:这又是G1相对于CMS的一大优势,CMS和G1都追求最低停顿时间,但是G1可以建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒。
- 分代收集,收集范围包括新生代和老年代
能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
能够采用不同方式处理不同时期的对象;
虽然保留分代概念,但Java堆的内存布局有很大差别;
将整个堆划分为多个大小相等的独立区域(Region);
新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;
G1收集器为什么可以做到可预测的停顿时间呢?
想一下,学渣在考试时怎样拿高分?肯定不是一道道题按顺序做下去,拿起卷子,先看一遍,哪些自己会的,先拿下再说。
G1收集器之所以可以做到可预测的停顿,当然就不是像其他收集器一样“死板”,一定要把所有垃圾全部清理了才罢休,G1收集器将内存分成了很多个Region,并对每个Region的垃圾收集价值进行跟踪记录(通过回收到的对象大小和时间的经验值进行计算),每次在允许的时间内,优先对回收价值大的Region进行回收,保证了在有限的时间内获得尽可能高的回收效率,这也是为什么它被叫做Garbage First的原因。
一个对象被不同区域引用的问题
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
回收新生代也不得不同时扫描老年代, 这样的话会降低Minor GC的效率;
- 解决方法:
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
每个Region都有一个对应的Remembered Set;
每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;
然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;
就可以保证不进行全局扫描,也不会有遗漏。
G1收集器运作过程
不计算维护Remembered Set的操作,可以分为4个步骤(与CMS较为相似)。
-
初始标记(Initial Marking)
仅标记一下GC Roots能直接关联到的对象;
且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;
需要"Stop The World",但速度很快; -
并发标记(Concurrent Marking)
进行GC Roots Tracing的过程;
刚才产生的集合中标记出存活对象;
耗时较长,但应用程序也在运行;
并不能保证可以标记出所有的存活对象; -
最终标记(Final Marking)
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
上一阶段对象的变化记录在线程的Remembered Set Log;
这里把Remembered Set Log合并到Remembered Set中;
需要**“Stop The World”**,且停顿时间比初始标记稍长,但远比并发标记短;
采用多线程并行执行来提升效率; -
筛选回收(Live Data Counting and Evacuation)
首先排序各个Region的回收价值和成本;
然后根据用户期望的GC停顿时间来制定回收计划;
最后按计划回收一些价值高的Region中垃圾对象;
回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;
可以并发进行,降低停顿时间,并增加吞吐量;