写点什么

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

发布于: 2020 年 10 月 04 日
深入理解Java虚拟机3——垃圾回收

《深入理解 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 虚拟机的垃圾收集器对比




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。


  1. 无法处理浮动垃圾

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


  1. 内存空间碎片化

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——垃圾回收


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


作者 张小超


公众号【超超不会飞】


转载请注明出处


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



发布于: 2020 年 10 月 04 日阅读数: 87
用户头像

【超超不会飞】公众号作者 2017.11.30 加入

Java & Node.js

评论

发布
暂无评论
深入理解Java虚拟机3——垃圾回收