A7. JVM 垃圾回收收集器(GC 收集器)

2023-04-24,,

概述

  如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范中对垃圾收集器应该如何实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾处理器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。下图为 HotSpot 虚拟机的垃圾收集器:

  

  上图展示了7种用于不同分代的收集器,其中新生代的收集器有:Serial 、ParNew、Parallel Scavenge, 老年代的收集器有:Serial Old 、Parallel Old、CMS,新生代和老年代都可用的收集器: G1。如果两个收集器之间有连线,就说明它们可以搭配使用。

  虽然我们是在对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的收集器出现,更没有万能的收集器,所以我们选择的只有对具体应用最合适的收集器。

Serial 收集器

  Serial 收集器是最基本的、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。这个收集器是一个单线程的收集器,但它的 “单线程” 的意义并不仅仅说明它只会使用一个 CPU 或者一条线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop The World” 这个名字也许听起来很酷,但这项工作实际上是虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。不妨想一下:要是你的计算机每运行一个小时就会暂停响应 5 分钟,你会有什么样的心情?下图示意了 Serial / Serial Old 收集器的运行过程。

  

  对于 “Stop The World” 带给用户的不良体验,虚拟机的设计者们表示完全理解,但也表示非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完吗?” 这确实是一个合情合理的矛盾,虽然收集器这项工作听起来和打扫房间属于一个性质的,但实际上肯定还要比打扫房间复杂得多啊!

  从 JDK 1.3 开始,到 JDK 1.7,HotSpot 虚拟机开发团队为消除或者减少工作线程因内存回收而导致停顿的努力一直在进行着,从 Serial 收集器到 Parallel 收集器,再到 CMS 收集器,乃至 GC 收集器的最前沿结果——G1收集器,我们看到了一个个越来越优秀,也越来越复杂的收集器出现,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除。寻找更优秀的GC 收集器的工作仍在继续。

  看到这里, Serial 收集器似乎是一个用处不大的收集器。实际上到目前为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。它有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器优于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会太大,停顿时间完全可以控制在毫秒级,只要不是频繁发生,这点停顿是可以接受的。所以, Serial 收集器对于运行 Client 模式下的虚拟机来说是一个好的选择。

ParNew 收集器】 

  ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如下:

  

  ParNew 收集器除了多线程收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。在 JDK 1.5 时期, HotSpot 推出了一款在强交互应用几乎可认为有划时代意义的垃圾收集器——CMS 收集器(Concurrent Mark Sweep),这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发(Concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作,用前面那个例子的话来说,就是做到了在你妈妈打扫房间的时候你还能一边往地上扔纸屑。

  不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 1.5 使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。ParNew 收集器也是使用 -XX:+UseConcMarkSweepGC 选项后的默认新生代收集器,也可以使用 -XX:+UseParNewGC 选项来强制指定它。

  ParNew 收集器在单 CPU 的环境中绝对不会比 Serial 收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境下都不能百分之百地保证可以超越 Serial 收集器。当然,随着可以使用的 CPU 的数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(例如 32个,现在 CPU 动辄就 4 核加超线程,服务器超过 32 个逻辑 CPU 的情况越来越多了) 的环境下,可以使用 -XX: ParallelGCThreads 参数来限制垃圾收集的线程数。

Parallel Scavenge 收集器

  Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,看上去和 ParNew 都一样,那它有什么特别之处呢?

  Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同, CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉了 1分钟,那吞吐量就是 99%。

  停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而搞吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要使用在后天运算而不需要太多交互的任务。

  Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMills 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。

  MaxGCPauseMills 参数允许的值是一个大于 0 的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不过大家不要认为如果把这个参数设置地稍小一点就可以使系统的垃圾收集速度变得更快,GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的;系统把新生代调小一点,收集 300M 新生代肯定比收集 500M 快吧,这也直接导致垃圾收集发生地更频繁一些,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次,每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。

  GCTimeRatio 参数的值应当是一个大于 0 且小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐率的倒数。如果把此参数设置为 19,那就是允许最大 GC 时间就占总时间的 5%(即 1/(1+19)),默认值为 99,就是允许最大 1%(1/(1+99))的垃圾收集时间。

  由于与吞吐量关系密切,Parallel Scavenge 收集器也经常被称为 “吞吐量优先” 的收集器。除上述两个参数之外,Parallel Scavenge 收集器还有一个参数 -XX:UseAdaptiveSizePolicy 值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式被称为 GC 自适应的调节策略(GC Ergonomics)。如果对于收集器运作原理不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如 -Xmx 设置最大堆内存,-Xms 设置初始堆内存),然后使用 MaxGCPauseMills(更关注最大停顿时间)或者 GCTimeRatio (更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是 Parallel Scavenge 与 ParNew 收集器的一个重要区别。

Serial Old 收集器

  Serial Old 收集器是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用 “标记-整理” 算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要还有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途就是作为 CMS 收集器的后备预案,在并发手机发生 Concurrent Mode Failure 时使用。

Parallel Old 收集器

  Parallele Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记-整理”算法。这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old 收集器外别无选择(Parallele Scavenge 收集器无法与 CMS 收集器配合使用)。由于老年代 Serial Old 收集器在 Server 模式下应用性能上的 “拖累”, 使用 Parallel Scavenge 收集器也未必能在整体性能应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合 “给力”。

  直到 Parallel Old 收集器出现后,“吞吐量优先” 收集器终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

CMS 收集器

G1 收集器】 

A7. JVM 垃圾回收收集器(GC 收集器)的相关教程结束。

《A7. JVM 垃圾回收收集器(GC 收集器).doc》

下载本文的Word格式文档,以方便收藏与打印。