深入理解Java虚拟机3——垃圾回收

778

《深入理解Java虚拟机》第3章读书笔记

本文介绍了如何判断对象是否存活,三种垃圾回收算法,分析比较了几种垃圾收集器的特点。本文并非原创,是《深入理解Java虚拟机》第3章的整理、总结和补充。

对象已死?

垃圾收集器在对堆进行回收前,要先判断哪些对象“存活”,哪些已经“死去”。

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不可能再被使用的。

主流的Java虚拟机里面没有选用引用计数算法来管理内存。

优点:实现简单,效率高。

缺点:很难解决对象之间相互循环引用的问题。

循环引用问题,如下代码所示,

/**
 * 源代码出自《深入理解Java虚拟机》P62-63
 * 循环引用
 **/
public class ReferenceCountingGC {

    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    /**
     * 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];

    /**
     * 运行参数
     * -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M
     */
    public static void main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        // 假设在这行发生GC,objA和objB是否能被回收?
        System.gc();
    }
}

JVM参数设置了新生代为10MB,运行结果显示,在第一次触发GC时,“5120K->576K(9216K)”回收了约4MB内存。意味着虚拟机并没有因为两个对象相互引用就不回收它们,这也侧面说明了虚拟机并不是通过引用计数算法来判断对象是否存活

可达性分析算法

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

如图,object5、object6、object7 为可回收对象

主流的Java虚拟机使用可达性分析算法

在Java语言中,GC Roots包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中Native方法引用的对象

四种引用

  1. 强引用:代码中普遍存在,垃圾收集器不会回收强引用的对象。
  2. 软引用:有用但非必需,在系统将要发生内存溢出异常之前,会把这些对象列入回收范围。
  3. 弱引用:非必需,无论当前内存是否足够,下次垃圾回收都会回收掉这些对象。
  4. 虚引用:最弱的引用关系,是否有虚引用不对其生存时间构成影响。

垃圾收集算法

标记-清除算法

最基础的收集算法——“标记-清除”(Mark-Sweep)算法。一般用于老年代。

算法分为“标记”和“清除”两个阶段:

  1. 标记出需要回收的对象
  2. 清除被标记的对象

缺点:效率低,空间碎片化。

复制算法

为了解决效率问题,出现了“复制”算法(Copying)。它将内存分为两块,每次只使用其中一块,当这一块内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。

缺点:内存缩小为原来的一半。

JVM虚拟机在新生代使用这种收集算法,并不是按照 1:1 的比例来划分内存空间。而是根据新生代中的对象98%是“朝生夕死”这一特点,将内存分为一块较大的 Eden(80%) 空间和两块较小的 Survivor(10%) 空间。每次只使用 Eden 和其中一块 Survivor,当回收时,将 Eden 和 Survivor 中还存活的对象,一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

HotSpot默认Eden和Survivor的大小比例为8:1,这样就只有10%的内存被“浪费”。当出现超过10%的对象存活时,就会使用老年代做分配担保,把Survivor空间放不下的对象,直接放入老年代。

标记-整理算法

在Mark-Sweep算法的基础上做了改良,用于解决空间碎片化问题。标记-整理(Mark-Compact)算法在标记后不是简单做清除,而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。一般用于老年代。

安全点和安全区域

安全点

在做可达性分析时,需要保持分析期间整个系统不会发生变化,这就导致GC进行时必须停顿所有Java执行线程(Stop The World),即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也必须要停顿。

程序执行时并非在所有地方都能停下来开始GC,只有在到达**安全点(Safepoint)**时才能暂停。Safepoint 的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的,例如方法调用,循环跳转,异常跳转等。

如何在GC发生时让线程都跑到最近的安全点再停顿下来?

  • 抢先试中断:先把所有线程中断,发现不在安全点的线程恢复线程,让它跑到安全点。
  • 主动式中断:设置一个不可读的内存位置作为中断标志,标志与安全点重合,当线程执行到这个标志时自己中断挂起。

安全区域

安全区域(Safe Region)是指在一段代码片段中,引用关系不会发生变化。在这个区域的任何地方开始GC都是安全的。典型的安全区域比如线程处于Sleep状态或者Blocked状态。

在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region。当要发起GC时,就不用管标识为Safe Region状态的线程了。当线程要离开Safe Region时,要检查是否处于GC状态,如果是,就要继续等待,直到收到可以安全离开Safe Region的信号为止。

垃圾收集器

并行与并发

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

吞吐量

吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。

假设虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

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倍以上。

HotSpot虚拟机的垃圾收集器对比

收集器 串行、并行、并发 新生代、老生代 算法 目标 使用场景
Serial 单线程,串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
Serial Old 单线程,串行 老年代 标记-整理 响应速度优先 单CPU环境下的Client模式、CMS的后
ParNew 多线程,并行 新生代 复制算法 响应速度优先 多CPU环境时在Server模式下与CMS配合
Parallel Scanvenge 多线程,并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 多线程,并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或B/S系统服务端上的Java应用
G1 并发 both 复制算法+标记-整理 响应速度优先 面向服务端应用,将来替换CMS

ParNew 收集器

Serial收集器的多线程版本,Service模式下的首选新生代收集器,除了Serial收集器外,目前只有它能与CMS收集器配合工作。

ParNew 收集器是使用 -XX:+UseConcMarkSweepGC 选项后的默认新生代收集器,也可以使用 -XX:+UseParNewGC 选项来强制指定它。

运行示意图如下:

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。基于Mark-Sweep算法。运行过程分为四个部分:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

其中,初始标记和重新标记仍然需要 Stop The World。初始标记只是标记一下 GC Roots 能直接关联到的对象,速度很快。并发标记就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间比初始标记稍长一些,但远比并发标记的时间短。

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

运行示意图如下:

CMS 收集器有如下3个缺点:

  1. 对CPU资源非常敏感

    因为并发阶段需要占用一个用户线程,如果CPU小于4个,则会导致用户程序的执行速度下降大于25%,如果只有2个CPU,用户程序执行速度则会下降50%,这是让人无法接收的。一般来说使用CMS收集器的服务器配置至少需要4个CPU。

  2. 无法处理浮动垃圾

    在并发清理过程中产生的垃圾称为“浮动垃圾”。这些垃圾只能等待下次垃圾回收。因此,CMS 收集器不能像其他收集器那样等到老年代几乎被完全填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

  3. 内存空间碎片化

    CMS 收集器是基于Mark-Sweep算法,这个算法会产生内存空间碎片。CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认为开启),用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程。内存整理的过程是无法并发执行的,空间碎片问题没有了,但停顿时间不得不变长。

使用多个收集器配置,JVM会怎么处理?

如过同时使用了四个组合配置,这是时候就会报错

但是比如 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC 这两个配置同时存在就不会报错。

翻看源码可知

有些配置项是可以并存的。

其实,在使用 UseConcMarkSweepGC 配置的时候,虚拟机默认开启了 UseParNewGC

所以在配置JVM时,我们尽量显式配置。比如要启用 ParNew + CMS 组合可以配置为

-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC

理解GC日志

《深入理解Java虚拟机》阅读笔记系列

深入理解Java虚拟机1——内存区域

深入理解Java虚拟机2——对象探秘

深入理解Java虚拟机3——垃圾回收

本文首发于我的个人博客 chaohang.top

作者 张小超

公众号【超超不会飞】

转载请注明出处

欢迎关注我的微信公众号 【超超不会飞】,获取第一时间的更新。