JVM中都有垃圾收集器?看完以后再也不用死记硬背了!
JVM中都有垃圾收集器?
如果说收集算法(这是我写的一篇收集算法的文章)是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范并没有规定垃圾收集器应该如何实现,因此一般来说不同厂商,不同版本的虚拟机提供的垃圾收集器实现可能会有差别,一般会给出参数来让用户根据应用的特点来组合各个年代使用的收集器,主要有以下垃圾收集器:
-
在新生代工作的垃圾回收器:Serial, ParNew, ParallelScavenge
-
在老年代工作的垃圾回收器:CMS,Serial Old, Parallel Old
-
同时在新老生代工作的垃圾回收器:G1
图片中的垃圾收集器如果存在连线,则代表它们之间可以配合使用,接下来我们来看看各个垃圾收集器的具体功能。
每个收集器你知道多少?
一、新生代收集器
1、Serial 收集器
Serial 收集器是工作在新生代的,单线程的垃圾收集器,单线程意味着它只会使用一个 CPU 或一个收集线程来完成垃圾回收,不仅如此,还记得我们上文提到的 STW 了吗,它在进行垃圾收集时,其他用户线程会暂停,直到垃圾收集结束,也就是说在 GC 期间,此时的应用不可用。
看起来单线程垃圾收集器不太实用,不过我们需要知道的任何技术的使用都不能脱离场景,在 Client 模式下,它简单有效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 单线程模式无需与其他线程交互,减少了(线程之前的切换)开销,专心做 GC 能将其单线程的优势发挥到极致,另外在用户的桌面应用场景,分配给虚拟机的内存一般不会很大,收集几十甚至一两百兆(仅是新生代的内存,桌面应用基本不会再大了),STW 时间可以控制在一百多毫秒内,只要不是频繁发生,这点停顿是可以接受的,所以对于运行在 Client 模式下的虚拟机,Serial 收集器是新生代的默认收集器。
Serial/Serial Old组合收集器运行示意图如下:
2、ParNew 收集器
ParNew 收集器是 Serial 收集器的多线程版本,除了使用多线程,其他像收集算法,STW,对象分配规则,回收策略与 Serial 收集器完成一样,在底层上,这两种收集器也共用了相当多的代码。在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。
ParNew 主要工作在 Server 模式,我们知道服务端如果接收的请求多了,响应时间就很重要了,多线程可以让垃圾回收得更快,也就是减少了 STW 时间,能提升响应时间,所以是许多运行在 Server 模式下的虚拟机的首选新生代收集器,另一个与性能无关的原因是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作,CMS 是一个划时代的垃圾收集器,是真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程(基本上)同时工作,它采用的是传统的 GC 收集器代码框架,与 Serial,ParNew 共用一套代码框架,所以能与这两者一起配合工作,而后文提到的 Parallel Scavenge 与 G1 收集器没有使用传统的 GC 收集器代码框架,而是另起炉灶独立实现的,另外一些收集器则只是共用了部分的框架代码,所以无法与 CMS 收集器一起配合工作。
ParNew/Serial Old组合收集器运行示意图如下:
3、Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一个使用复制算法,多线程,工作于新生代的垃圾收集器,看起来功能和 ParNew 收集器一样,它有啥特别之处吗
关注点不同,CMS 等垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)),也就是说 CMS 等垃圾收集器更适合用到与用户交互的程序,因为停顿时间越短,用户体验越好,而 Parallel Scavenge 收集器关注的是吞吐量,所以更适合做后台运算等不需要太多用户交互的任务。
Parallel Scavenge 收集器提供了两个参数来精确控制吞吐量,分别是控制最大垃圾收集时间的 -XX:MaxGCPauseMillis 参数及直接设置吞吐量大小的 -XX:GCTimeRatio(默认99%)
除了以上两个参数,还可以用 Parallel Scavenge 收集器提供的第三个参数 -XX:UseAdaptiveSizePolicy,开启这个参数后,就不需要手工指定新生代大小,Eden 与 Survivor 比例(SurvivorRatio)等细节,只需要设置好基本的堆大小(-Xmx 设置最大堆),以及最大垃圾收集时间与吞吐量大小,虚拟机就会根据当前系统运行情况收集监控信息,动态调整这些参数以尽可能地达到我们设定的最大垃圾收集时间或吞吐量大小这两个指标。自适应策略也是 Parallel Scavenge 与 ParNew 的重要区别!
二、新生代收集器
1、Serial Old 收集器
上文我们知道, Serial 收集器是工作于新生代的单线程收集器,采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);与之相对地,Serial Old 是工作于老年代的单线程收集器,此收集器的主要意义在于给 Client 模式下的虚拟机使用,如果在 Server 模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
Serial/Serial Old收集器运行示意图如下:
2、Parallel Old 收集器
Parallel Old 是相对于 Parallel Scavenge 收集器的老年代版本,使用多线程和标记整理法,两者组合示意图如下,这两者的组合由于都是多线程收集器,真正实现了吞吐量优先的目标。
Parallel Scavenge/Parallel Old收集器运行示意图如下:
3、CMS 收集器
CMS 收集器是以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器,希望给用户最好的体验, 如常见WEB、B/S系统的服务器上的应用,则 CMS 收集器是个很不错的选择!
我们之前说老年代主要用标记整理法,而 CMS 虽然工作于老年代,但采用的是标记清除法,主要有以下四个步骤
1)初始标记
仅标记一下GC Roots能直接关联到的对象;
速度很快;
但需要"Stop The World";
2)并发标记
进行GC Roots Tracing的过程;
刚才产生的集合中标记出存活对象;
应用程序也在运行;
并不能保证可以标记出所有的存活对象;
并发标记就需要标记出 GC roots 关联到的对象 的引用对象有哪些。比如说 A -> B (A 引用 B,假设 A 是 GC Roots 关联到的对象),那么这个阶段就是标记出 B 对象, A 对象会在初始标记中标记出来。
这个过程是可以和用户线程并发执行的。所谓的并发的实现,可以有几种方式,比如说,标记了 100 个对象,那么就停一停,让用户线程跑一会;再比如说,标记了 10ms,再停一停,之类的实现。
3)重新标记
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
采用多线程并行执行来提升效率;
重新标记是干什么的呢?就是由于并发标记这个阶段用户线程和GC 线程并发,假如这个阶段用户线程产生了新的对象,这个对象是白色的,总不能被 GC 掉吧。这个阶段就是为了让这些对象重新标记。
4)并发清除
回收所有的垃圾对象;
整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;
所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行;
CMS收集器运行示意图如下:
从图中可以的看到初始标记和重新标记两个阶段会发生 STW,造成用户线程挂起,不过初始标记仅标记 GC Roots 能关联的对象,速度很快,并发标记是进行 GC Roots Tracing 的过程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短。
整个过程中耗时最长的是并发标记和标记清理,不过这两个阶段用户线程都可工作,所以不影响应用的正常使用,所以总体上看,可以认为 CMS 收集器的内存回收过程是与用户线程一起并发执行的。
但是 CMS 收集器远达不到完美的程度,主要有以下三个缺点
-
CMS 收集器对 CPU 资源非常敏感 原因也可以理解,比如本来我本来可以有 10 个用户线程处理请求,现在却要分出 3 个作为回收线程,吞吐量下降了30%,CMS 默认启动的回收线程数是 (CPU数量+3)/ 4, 如果 CPU 数量只有一两个,那吞吐量就直接下降 50%,显然是不可接受的
-
CMS 无法处理浮动垃圾(Floating Garbage),可能出现 「Concurrent Mode Failure」而导致另一次 Full GC 的产生,由于在并发清理阶段用户线程还在运行,所以清理的同时新的垃圾也在不断出现,这部分垃圾只能在下一次 GC 时再清理掉(即浮云垃圾),同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间要确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再使用,JDK 1.5 默认当老年代使用了68%空间后就会被**,当然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction 来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 收集器来重新进行老年代的收集,而我们知道 Serial Old 收集器是单线程收集器,这样就会导致 STW 更长了。
-
CMS 采用的是标记清除法,上文我们已经提到这种方法会产生大量的内存碎片,这样会给大内存分配带来很大的麻烦,如果无法找到足够大的连续空间来分配对象,将会触发 Full GC,这会影响应用的性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长,还可以用另一个参数 -XX:CMSFullGCsBeforeCompation 用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的。
总体来看,与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间;但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间;
浮动垃圾问题怎 么解决?
如果深入思考,可能会有这样的疑问,假如你标记了一个对象,然后用户线程说,这个对象我不要了,你回收吧。这个时候怎么办?其实这个时候暂时没办法处理,只能留到下一次 GC 的时候再回收,这次 GC 不好意思,GC 不了,这个就叫做浮动垃圾。这个其实还好,GC 不了对程序不会有影响,大不了多占用了一点内存嘛,反正下次也释放。
但是还可能有这样的问题出现:
假如有一个对象 GC 线程没有标记(用户线程之前没在用),然后轮到了用户线程,用户线程说,这个对象我重新又要用了,不要把这个对象GC 掉,这个时候怎么办?假如这个时候处理不了,还是 GC 了,那么程序就直接报错了,这个是不允许的,解决办法可以如下:
三色标记法
这个算法就是把 GC 中的对象划分成三种情况:
白色:还没有搜索过的对象(白色对象会被当成垃圾对象)
灰色:正在搜索的对象
黑色:搜索完成的对象(不会当成垃圾对象,不会被GC)
假设有 A -> B -> C, A 是 GC Roots 关联的对象,那么首先会把 GC Roots 标记,也就是 A 标记成灰色(证明现在正在搜索 A 相关的),然后搜索 A 的引用,也就是 B,那么搜索了 B,把 B 变成了灰色,那么 A 就搜索完成了。(此时注意,现在是不管 C 的,因为 C 不是 A 的引用,现在只管 A 的引用是什么)。此时把 A 相关的搜索完了,那么 A 就变成了黑色,证明 A 已经 ok 了。
(Ps:浮动垃圾就是说,此时 A 又不用了,那么 A 是没办法回收的,因为 A 已经标记了)此时准备要搜索 B 了。刚好,此时,用户线程要执行了,用户线程把原来 A -> B -> C 的引用改成了 A -> C,同时 B 不再引用C。然后又到 GC 线程执行了。GC 线程发现 B 没有引用的对象了(因为用户线程已经把 B -> C 去掉了),那么 B 就相当于搜索完成了,变成黑色了。最后,C 怎么办,C还是白色的呢,白色的是不会搜索,当做垃圾处理的。
解决办法
此时的解决办法就是有一个叫做写入屏障的东西。就是说,如果A已经被标记了(已经是黑色的了),那么用户线程改动 A->C的时候,会把 C 变成灰色,这样,以后就可以搜索 C了。GC 线程和 用户线程并发的时候,用户线程把失效的对象又置为有效,这个时候怎么处理?就是用重新标记!
4、G1(Garbage First) 收集器
G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器,主要有以下几个特点
-
像 CMS 收集器一样,能与应用程序线程并发执行。
-
整理空闲空间更快。
-
需要 GC 停顿时间更好预测。
-
不会像 CMS 那样牺牲大量的吞吐性能。
-
不需要更大的 Java Heap
与 CMS 相比,它在以下两个方面表现更出色
1)运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
2)在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。
为什么G1能建立可预测的停顿模型呢?
主要原因在于 G1 对堆空间的分配与传统的垃圾收集器不一器,传统的内存分配就像我们前文所述,是连续的,分成新生代,老年代,新生代又分 Eden,S0,S1,如下图:
而 G1 各代的存储地址不是连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个Region占有一块连续的虚拟内存地址,如图示:
除了和传统的新老生代,幸存区的空间区别,Region还多了一个H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。
G1 分配成这样有啥好处呢?
传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。
G1 收集器的工作步骤如下
(1)、初始标记(Initial Marking)
仅标记一下GC Roots能直接关联到的对象;
且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;
需要"Stop The World",但速度很快;
(2)、并发标记(Concurrent Marking)
进行GC Roots Tracing的过程;
刚才产生的集合中标记出存活对象;
耗时较长,但应用程序也在运行;
并不能保证可以标记出所有的存活对象;
(3)、最终标记(Final Marking)
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
上一阶段对象的变化记录在线程的Remembered Set Log;
这里把Remembered Set Log合并到Remembered Set中;
需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
采用多线程并行执行来提升效率;
(4)、筛选回收(Live Data Counting and Evacuation)
首先排序各个Region的回收价值和成本;
然后根据用户期望的GC停顿时间来制定回收计划;
最后按计划回收一些价值高的Region中垃圾对象;
回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;
可以并发进行,降低停顿时间,并增加吞吐量;
G1收集器运行示意图如下:
可以看到整体过程与 CMS 收集器非常类似,筛选阶段会根据各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。
总结
本文简述了垃圾收集器的经典7大种类,相信大家对收集器有了更深刻的认识,在生产环境中我们要根据不同的场景来选择垃圾收集器组合,如果是运行在桌面环境处于 Client 模式的,则用 Serial + Serial Old 收集器绰绰有余,如果需要响应时间快,用户体验好的,则用 ParNew + CMS 的搭配模式,即使是号称是驾驭一切的 G1,也需要根据吞吐量等要求适当调整相应的 JVM 参数,没有最牛的技术,只有最合适的使用场景,切记!