《深入理解Java虚拟机》(第二版)学习3:垃圾收集器

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

我们这里讨论的收集器主要是基于JDK 1.7 Update 14之后的 Hotspot VM 。

Serial 收集器

Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。

这个收集器是一个单线程的收集器,但它“单线程”的意义并不仅仅说明它只会使用一个 CPU 或者一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束,如下图:

《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第1张图片

优点:
简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。

Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

Serial Old 收集器

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

这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。

如果在 Server 模式下,那么它还有两大用途:

  1. 在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用
  2. 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用

《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第2张图片

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配原则、回收策略等都与 Serial 收集器完全一样。ParNew 收集器工作过程如下图:

《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第3张图片

ParNew 收集器是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。

ParNew 收集器默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(譬如32个)的环境下,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

Parallel Scavenge 收集器

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

不同点:

  • CMS 等收集器:尽可能地缩短垃圾收集时用户线程的停顿时间;
  • Parallel Scavenge 收集器:达到一个可控制的吞吐量(Throughput)。

所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花费掉1分钟,那吞吐量就是99%。
高吞吐量可以高效的利用CPU时间,尽快完成程序的运算任务,(意味着暂停时间可能长一些)主要适合那些在后台计算而不需要交互的任务。因此,常见在服务器环境中使用。例如,那些执行批处理、订单处理、工资支付、科学计算的应用程序。

因此,Parallel Scavenge 收集器也被称为“吞吐量优先的垃圾”收集器。

参数说明

  • -XX:MaxGCPauseMillis
    控制最大垃圾收集停顿时间(即 STW 时间)。允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。我们也不要把这个值设置得太小,GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的,停顿时间在下降,但吞吐量也降下来了

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

  • -XX:+UseAdaptiveSizePolicy
    设置 Parallel Scavenge 收集器具有 GC 自适应的调节策略(GC Ergonomics)
    在这种模式下,年轻代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMIllis),让虚拟机自己完成调优工作

特点

  1. 吞吐量优先
  2. 具有 GC 自适应的调节策略,虚拟机自己完成调优工作

Parallel Old 收集器

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法

在这个收集器推出之前(JDK 1.6 之前),新生代的 Parallel Scavenge 收集器一直处于一种比较尴尬的状态。
原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外别无选择(因为 Parallel Scavenge 收集器无法与 CMS 收集器配合工作)。由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”(单线程的 Serial Old 无法充分利用服务器多 CPU 的处理能力),使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果。

Parallel Old 收集器的工作流程如下图所示:

《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第4张图片

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它在一些文档中也被称为并发低停顿收集器(Concurrent Low Pause Collector)。
CMS 是 Hotspot 虚拟机中第一款真正意义上的并发(Concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字(包含“Mark Sweep”)上就可以看出, CMS 收集器是基于“标记-清除”算法实现的,它的运作过程分为7个步骤(书上作者只说了4个…):

  • 初始标记(CMS initial mark),会导致 STW
  • 并发标记(CMS concurrent mark),与用户线程同时运行
  • 预清理(CMS concurrent preclean),与用户线程同时运行
  • 可被终止的预清理(CMS concurrent abortable preclean),与用户线程同时运行
  • 重新标记(CMS remark),会导致 STW
  • 并发清除(CMS concurrent sweep),与用户线程同时运行
  • 并发重置状态等待下次CMS的触发(CMS concurrent reset),与用户线程同时运行

执行过程如下图:

《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第5张图片

初始标记

这是 CMS中 两次“Stop TheWorld”事件中的一次。这一步的作用是标记存活的对象,有两部分:

  1. 标记老年代中所有的 GC Roots 对象,如下图节点 1 ;
  2. 标记新生代中活着的对象引用到的老年代的对象(指的是新生代中还存活的引用类型对象,引用指向老年代中的对象)如下图节点 2 和 3

《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第6张图片
这里引入一个概念:跨代引用
跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。

一般来说,每个分代的垃圾回收器都希望各扫门前雪,只标记自己分代的存活对象,比如 Young GC 时,当 GC 线程遇到引用指向老年代时就会停止遍历,因为它只负责回收新生代的内存空间,不需要去访问老年代对象,而 CMS 收集器却不是这样。

Card Marking
在 Young GC 时,为了找到跨代引用,通常有这几个方法:

  1. 当对象引用路径指向老年代时继续遍历老年代对象找到跨代引用
  2. 线性地扫描老年代对象,标记跨代引用,用顺序读代替离散读
  3. 从程序开始运行,就使用一个集合记录所有跨代引用的创建,在 Young GC 时扫描这个集合里指向新生代的跨代引用

前两种方式都需要在 Young GC 时去遍历老年代对象,因为老年代存活对象多,工作量太大,JVM 使用的是第三种方式。

跨代引用如何产生的?
对于老年代到年轻代的跨代引用(a->b),产生条件有两种,
一是 GC 线程把对象 a 从新生代移动到了老年代,
二是 a 本身是老年代对象,应用线程修改了 a 的引用指向了年轻代的 b( 对于新生代到老年代的跨代引用就只有第二种情况)。

对于 GC 线程本身创建的跨代引用,可以直接由 GC 线程在创建时记录,所以问题就变成了:如何记录应用线程修改对象引用时创建的跨代引用?

在 JVM 中再次使用分治法,将老年代划分成多个 Card(和 linux 内存 page 类似),每个 Card 大约是512K。
只要 Card 内对象引用被应用线程修改,就把 Card 标记为 Dirty。然后 Young GC 时会扫描老年代中 Dirty Card 对应的内存区域,记录其中的跨代引用,这种方式被称为 Card Marking。

JVM 通过写屏障(write barrier)来实现监控程序线程对引用的修改,并且标记对应 Card,写屏障工作方式和代理模式类似,具体来说是通过在引用赋值指令执行时,添加对了 Card Table 的修改指令。

小结:JVM 使用 Card Marking 的方式,避免了 Young GC 时扫描整个老年代存活对象,付出的代价是在每次修改引用时添加额外的汇编指令实现写屏障,和额外的内存来保存 Card Table。

并发标记

从“初始标记”阶段标记的对象开始找出所有存活的对象;
因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、再或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。为了提高重新标记的效率,该阶段会把上述对象所在的 Card 标识为 Dirty,后续只需扫描这些 Dirty Card 的对象,避免扫描整个老年代。

并发标记阶段只负责将引用发生改变的 Card 标记为 Dirty 状态,不负责处理。
如下图所示,也就是节点1、2、3,最终找到了节点4和5。

并发标记的特点是和应用程序线程同时运行。并不是老年代的所有存活对象都会被标记,因为标记的同时应用程序会改变一些对象的引用等。
由于这个阶段是和用户线程并发的,可能会导致“concurrent mode failure”。如果 CMS 并发标记过程中出现了 concurrent mode failure 的话那么接下来就会做一次 mark sweep compact 的 Full GC,这个是完全Stop The World的。

《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第7张图片

预清理阶段

前一个阶段已经说明,不能标记出老年代全部的存活对象,是因为标记的同时应用程序会改变一些对象引用,这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为 Dirty 的 Card。

如下图所示,在并发清理阶段,节点3的引用指向了6,则会把节点3的 Card 标记为 Dirty;
《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第8张图片

最后将6标记为存活,如下图所示:

《深入理解Java虚拟机》(第二版)学习3:垃圾收集器_第9张图片

可终止的预处理

这个阶段尝试着去承担下一个阶段 Final Remark 阶段足够多的工作。这个阶段持续的时间依赖很多因素,由于这个阶段是重复的做相同的事情直到发生 abort 的条件(比如:重复的次数、多少量的工作、持续的时间等等)之一才会停止。

PS:此阶段最大持续时间为5秒,之所以可以持续5秒,另外一个原因也是为了期待这5秒内能够发生一次 Young GC,清理新生代的引用,使得下个阶段的重新标记阶段、扫描新生代指向老年代的引用的时间减少。

重新标记

这个阶段会导致第二次 Stop The Word,该阶段的任务是完成标记整个年老代的所有的存活对象

这个阶段,重新标记的内存范围是整个堆,包含 Young Gen 和 Old Gen。

为什么要扫描新生代呢?
因为对于老年代中的对象,如果被新生代中的对象引用,那么就会被视为存活对象,即使新生代的对象已经不可达了,也会使用这些不可达的对象当做 CMS 的“GC Root”,来扫描老年代

因此对于新生代来说,引用了老年代中对象的新生代的对象,也会被新生代视作“GC Root”

当此阶段耗时较长的时候,可以加入参数 -XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次 Young GC,回收掉新生代的无用的对象,并将对象放入 Survivor 区或晋升到老年代,这样再进行新生代扫描时,只需要扫描 Survivor 区的对象即可,一般 Survivor 区非常小,这大大减少了扫描时间。

由于之前的预处理阶段是与用户线程并发执行的,这时候可能新生代的对象对老年代的引用已经发生了很多改变,这个时候,remark 阶段要花很多时间处理这些改变,会导致很长 Stop The Word,所以通常 CMS 尽量运行 Final Remark 阶段在年轻代是足够干净的时候。

另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled

并发清除

通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过 Garbage Collector 采用清扫的方式回收那些不能用的对象了。
这个阶段主要是清除那些没有标记的对象并且回收空间
由于 CMS 并发清除阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 收集器的优点

  • 并发收集
  • 低停顿

CMS 收集器的缺点

对 CPU 资源非常敏感

CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户线程变慢,但是因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU 数量+3) / 4 ,也就是当 CPU 在4个以上时,,并发回收时垃圾收集线程占用不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。
为了应对这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep / i-CMS)的 CMS 收集器变种,就是在并发标记和并发清除的时候让GC线程和用户线程交替运行,尽量减少GC 线程独占资源的时间,这样整个垃圾收集的过程会变长,但是对用户程序的影响会减少。(效果不明显,现在已经不再提倡用户使用)

无法处理浮动垃圾

CMS 收集器无法处理浮动垃圾(Floating Garbage),可能会出现“Concurrent Mode Failure”失败而导致另一次 Full GC 的产生。

浮动垃圾我们之前在并发清除提到过,是由于在清除过程中程序还在运行着,运行着也就会不断产生新的垃圾,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。

也是因为在垃圾收集阶段用户线程还需要继续运行,那就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运行使用。
我们可以设置-XX:CMSInitiatingOccupancyFraction参数的值来设置 CMS 收集器的启动阀值,在JDK 1.6中,CMS 收集器的启动阀值已经提升到92%。

要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启动 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说,参数-XX:CMSInitiatingOccupancyFraction设置得太高很容易导致大量的“Concurrent Mode Failure”失败,性能反而降低。

GC 结束时会产生大量空间碎片

前面我们说过,CMS 收集器是基于“标记-清除”算法实现的,这意味着在收集结束时会有大量空间碎片产生。空间碎片太多的时候,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象的,只能提前触发 Full GC。

为了解决空间碎片这个问题,CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认就是开启的),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。
虚拟机设计者还提供了另一个参数 -XX:CMSFullGCBeforeCompaction,这个参数是用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为0,表示每次进入 Full GC 时都进行碎片整理)。

空间碎片问题的具体实例

在 Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion),这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。
再进一步,如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)

在进行 Minor GC 时,Survivor Space 放不下,对象只能放入老年代,而此时老年代因为碎片太多,新生代要转移到老年代的对象也比较大,找不到一块连续的内存区域存放这个对象导致的。

过早提升的原因:

  1. Survivor 空间太小,容纳不下全部的运行时短生命周期的对象。
    如果是这个原因,可以尝试将Survivor调大,否则端生命周期的对象提升过快,导致老年代很快就被占满,从而引起频繁的full gc;
  2. 对象太大,Survivor 和 Eden 没有足够大的空间来存放这些大对象。

提升失败的原因:
当提升的时候,发现老年代也没有足够的连续空间来容纳该对象。为什么是没有足够的连续空间而不是空闲空间呢?

老年代容纳不下提升的对象有两种情况:

  1. 老年代空闲空间不够用了;
  2. 老年代虽然空闲空间很多,但是碎片太多,没有连续的空闲空间存放该对象。

解决方法

  1. 如果是因为内存碎片导致的大对象提升失败,CMS 需要进行空间整理压缩;
  2. 如果是因为提升过快导致的,说明 Survivor 空闲空间不足,那么可以尝试调大 Survivor;
  3. 如果是因为老年代空间不够导致的,尝试将 CMS 触发的阈值调低。

CMS部分的内容借鉴了:
https://zhuanlan.zhihu.com/p/150696908
https://www.jianshu.com/p/86e358afdf17

G1 收集器

G1 收集器是一款面向服务端应用的垃圾收集器。G1 最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至 CMS 的众多缺陷。
G1 收集器具有以下的特点:

  • 并行与并发:G1 能充分利用多 CPU 、多核环境下的硬件优势,使用多个 CPU (CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然能通过并发的方式让 Java 程序继续执行。
  • 分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
  • 可预测的停顿:这是 G1 相对 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。

剩下的内容等我回去看看关于 G1 收集器的文献再补上,《深入理解 Java 虚拟机》(第二版)这本书关于 G1 收集器说的太浅了(可能是第二版太老了)…

PS:也可以到我的个人博客查看更多内容
个人博客地址:小关同学的博客

你可能感兴趣的