阅读 341

Android工程师学习JVM(八)-HotSpot中的垃圾收集器

前言

在学习JVM这个系列文章中,已经讲解了JVM规范、Class文件格式以及如何阅读字节码、ASM字节码处理、类的生命周期及自定义类加载器、内存分配、字节码执行引擎、垃圾回收机制等。本篇介绍HotSpot中的垃圾收集器,对于理解垃圾回收机制会很有帮助

如果你对JVM、字节码、Class文件格式、ASM字节码处理、类加载及自定义类加载器、内存分配、字节码执行引擎、垃圾回收机制有兴趣的话,可以看之前的文章哈,相信会收获更多哦

Android工程师学习JVM(七)-面试常考之垃圾回收

Android工程师学习JVM(六)-字节码执行引擎

Android工程师学习JVM(五)-内存分配基础知识

Android工程师学习JVM(四)-类加载、连接、初始化、卸载

Android工程师学习JVM(三)-字节码框架ASM使用

Android工程师学习JVM(二)-教你阅读Java字节码

Android工程师学习JVM(一)-JVM概述

前面讲的垃圾回收只是理论部分,本文讲解的垃圾收集器是JVM中对理论的具体实现。这部分对于理解前文所讲的分代算法非常有用。

注意:垃圾收集器不同的厂商版本的虚拟机区别较大,本文是针对HotSpot的垃圾收集器进行分析的

1、HotSpot中的收集器

上文我们提到,商用虚拟机都是采用分代收集算法进行收集的。那么收集器也是这样的。上图为HotSpot中的收集器。其中新生代收集器有:Serial、ParNew、ParallelScavenge,老年代有CMS、Serial old、Parallel old等。而G1这个收集器比较特殊,新生代老年代都可以使用。收集器基本上都是配合着使用的,上图中的线条描述的就是各种不同的收集器组合。

新生代(Serial) + 老年代(SerialOld)

新生代(ParNew)+ 老年代(首选CMS,CMS出问题时用SerialOld)

新生代(ParallelScavenge)+ 老年代(SerialOld或者ParallelOld)

G1(新生代老年代都用G1)

2、HotSpot中垃圾收集相关概念介绍

1、枚举根节点

以上文可达性分析中从 GC Roots 节点找引用链这个操作为例,可作为 GC Roots 的节点主要在全局性的引用(类常量或类静态属性)与执行上下文(栈帧中的本地变量表)中,很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。

另外,可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项分析工作必须不可以出现分析过程中对象引用关系还在不断变化的情况,否则分析结果准确性就无法得到保证。这点是导致 GC 进行时必须停顿所有 Java 执行线程("Stop The World")的其中一个重要原因,即使是在号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

因此,目前的主流 Java 虚拟机使用的都是准确式 GC(即虚拟机可以知道内存中某个位置的数据具体是什么类型。),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。

在 HotSpot 的实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录栈和寄存器中哪些位置是引用。这样, GC 在扫描时就可以直接得知这些信息了。

2、安全点(Safepoint)

在 OopMap 的协助下,HotSpot 可以快速且准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外空间,这样 GC 的空间成本将会变得很高。

实际上,HotSpot 也的确没有为每条指令都生成 OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始 GC ,只有在到达安全点时才能暂停。

Safepoint 的选定既不能太少以致于 GC 过少,也不能过于频繁以致于过分增大运行时的负荷。

对于 Safepoint,另一个需要考虑的问题是如何在 GC 发生时让所有线程都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

其中抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC 事件。

而主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

3、安全区域(Safe Region)

使用 Safepoint 似乎已经完美地解决了如何进入 GC 的问题,但实际情况却并不一定。Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的Safepoint。但是,程序“不执行”的时候呢?

所谓的程序不执行就是没有分配 CPU 时间,典型的例子就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全的地方去中断挂起,JVM 也显然不太可能等待线程重新被分配 CPU 时间。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。

在这个区域中的任意地方开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region,那样,当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region 状态的线程了。在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。

3、垃圾收集器

3.1、串行收集器

Serial和Serial Old收集器,是一个单线程的收集器,在垃圾收集时,会Stop-the-World

优点是简单,对于单Cpu,由于没有多线程交互开销,可能更高效,是虚拟机运行在Client模式下的默认收集器

在用户桌面应用场景下,分配给虚拟机管理的内存一般不会很大,收集几十兆到一两百兆的新生代,停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择

3.2、并行收集器

ParNew(并行)收集器:使用多线程进行垃圾回收,在垃圾收集时,会Stop-The-World

在并发能力好的CPU环境里,它停顿的时间要比串行收集器短。对于单Cpu或并发能力较弱的Cpu,由于多线程的开销,可能比串行回收器更差

是Server模式下首选的新生代收集器,且能和CMS收集器配合使用

3.3、新生代Parallel Scavenge收集器

新生代Parallel Scavenge收集器,是一个应用于新生代的、使用复制算法的、并行收集器

跟ParNew很类似,但更关注吞吐量,能最高效率的使用CPU,适合运行后台应用

3.4、CMS收集器

CMS(Concurrent Mark and Sweep并发标记清除),收集过程分为:

初始标记:只标记GC Roots能直接关联到的对象;

并发标记:进行GC Roots Tracing过程,在这个过程中用户线程继续运行,可能有垃圾对象重新被引用,也可能产生新的垃圾

重新标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象

并发清除:并发回收垃圾对象

在初始标记和重新标记两个阶段还是会发生Stop-the-world的

由图可看出CMS是并发标记,并发清除,用的是标记清除算法。

最后的重置线程,指的是清空跟收集相关的数据并重置,为下一次收集做准备

优点:低停顿,并发执行

缺点:并发执行对CPU资源压力大;无法处理在处理过程中产生的垃圾,可能导致FullGC。采用的标记清除算法会导致大量内存碎片,从而在分配大对象时可能触发FullGC

3.5、G1收集器

G1(Garbage-First)收集器,是一款面向服务端应用的收集器,与其他收集器相比,具有如下特点:

G1把内存划分成多个独立的区域(Region)

G1仍采用分代思想,保留了新生代和老年代,它们不再是物理隔离的,而是一部分Region的集合,不需要Region是连续的。如上图将一块内存划分为多个Region,再把它们归属到Eden、Survivor、老年代等的范围内。灰色部分没有归属的陈称为Free Region

G1收集器处理过程分为:

1、初始标记,标记GC Roots直接关联到的对象

2、并发标记,进行GC Roots Tracing过程,这个过程中用户线程仍在运行,因此会导致一些对象重新被引用,也可能产生新的垃圾

3、最终标记,并发标记时由于用户线程运行,一些对象可能成为垃圾,也可能有一部分对象重新被引用,在最终标记过程中,是发生STW的,用户线程停止,修正标记

4、筛选回收,G1的回收过程是进行STW的,但是可以设置时长,在时长不够清除所有垃圾的情况下,G1会对区域进行筛选,回收内存中垃圾较多的区域。

4、内存分配和回收策略

对象的内存分配,其实也就是在堆上分配。对象主要分配在新生代的Eden区上。少数情况下会分配在老年代中。分配的规则并不是百分之百固定的,其细节取决于当前使用的垃圾回收器组合,还和虚拟机内存配置相关。大体策略可看下图:

Minor GC 和 Full GC 有什么不一样吗?

  • 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

5、小结

1、本文介绍了HotSpot中基于分代收集思想的各种收集器及组合

2、所有的收集器都会发生STW,只不过CMS和G1发生STW的时间相对较短

3、垃圾收集器的性能优化,主要在于减少MinorGC次数,避免发生FullGC