【JVM】内存管理之垃圾回收

相较于 C/C++,Java 能够对「垃圾对象」进行自动清理而不需要手动释放,是如何实现的?底层工作原理是什么?

1. 自动垃圾收集

1.1 判断对象是否为垃圾

首先,需要判断哪些对象是垃圾可回收,哪些是非垃圾还不可回收,主流的判断方法主要为: 1. 引用计数算法 2. 可达性分析算法

A. 引用计数算法

算法策略非常简单,每一个对象维护一个「引用计数器」,只要被引用,次数就 + 1,当引用次数为 0 时,就说明该对象已经沦为垃圾,可以被回收~

这个算法的优点就是非常易懂,不过存在一个循环引用的问题;如下所示:
alt text

对于对象 a、b 来说,除了彼此之外没有额外的引用,当对象 a / 对象 b 不再使用的时候,理论上对象 a/b 都沦为垃圾,但实际上因为互相的引用,导致无法顺利的被判断为垃圾;

B. 可达性分析算法

JVM 并没有使用「引用计数算法」,而是使用可达性分析算法;算法基本思想: 从一系列被称为GC Roots的根对象作为起始点,从这些节点根据引用关系开始向下搜索,只要能够根据引用关联起来的对象就是「可达对象」;反之,没有关联到的就是「不可达对象」,需要进行回收;
alt text
对于对象 a、b、c 来说,能够通过引用关系与 GC Roots 关联起来,为「可达对象」;
对于对象 e、f 来说,为「不可达对象」;

GC Roots

可以看到,关键是 GC Roots,在 Java 中 GC Roots 主要是以下对象组成:

  • 栈帧内本地方法表引用的对象:参数、局部变量、临时变量等~
  • 方法区内类变量、常量引用的对象
  • 被同步锁(synchronized 关键字)持有的对象

1.2 垃圾收集算法

主流的垃圾收集算法主要有以下几种:

  1. 标记-清除算法
  2. 标记-复制算法
  3. 标记-整理算法

不同算法的优缺点不一样,具体场景适用哪种算法要具体分析

A. 标记-清除算法

工作流程:

  1. 首先标记需要回收的对象【标记阶段】
  2. 统一回收垃圾对象【清理阶段】

这个算法的优点是实现非常简单,但是有两个比较明显的缺点:

  1. 标记阶段的效率「严重」受到需要被标记的对象数量所影响,导致回收效率不稳定
  2. 回收阶段可能会产生比较多内存碎片,内存空间的使用率降低

B. 标记-复制算法

工作流程:

  1. 把可使用的空间,均分两份 a、b 两块区域,每次只使用其中一块,当这一块内存用完,则开始进行回收
  2. 标记不需要回收的对象【标记阶段】
  3. 每次回收的时候,将 a 区域中不需要回收的对象复制到 b 区域中【复制阶段】

这个算法的优点是:不存在内存碎片的问题,每次复制的时候都可以按照顺序分配内存空间;
缺点则有以下:

  1. 因为把内存空间均分成了两份,每次只能使用到其中一份进行分配,空间浪费严重
  2. 如果每次回收的时候,存活的对象很多,每次复制的压力也很大

所以,标记-复制算法比较适用于内存空间内都是存活时间短的对象

C. 标记-整理算法

工作流程:

  1. 首先标记需要回收的对象【标记阶段】
  2. 对仍存活的对象进行「移动」,按照内存顺序对「存活对象」进行移动【整理阶段】

整理阶段因为对象的内存地址发生了变化,则需要对「存活对象」引用的更新;引用的更新势必要 STW(stop the world),对于用户程序来说就是产生了停顿,期间用户程序停止运行;

这个算法的优点是:不存在内存碎片问题,也不存在空间浪费的情况;
缺点非常明显:整理阶段操作非常的重,回收效率「严重」受到存活对象的数量影响;

D. 适用场景

上面简单介绍了一下三种垃圾收集算法的优缺点,对于 JVM 来说,具体场景适用哪种算法要具体分析

JVM 的分代收集理念

JVM 对「堆」空间划分新生代和老年代进行管理;

新生代:对象基本「朝生夕死」,在这个区域的对象存活时间短,不会长时间停留在内存

  • 常见于 Java 程序中的一些方法调用产生的临时对象,方法调用完即「死亡」

老年代:在这个区域的对象存活时间长,会长时间停留在内存

  • 常见于 Java 程序中一些全局对象,生命周期为整个 Java 程序的生命周期
新生代适用算法

新生代的对象朝生夕死,在这个区域的对象大部分都是需要被回收的对象,相较于「标记-清除算法」,更加适合适用「标记-复制算法」,因为每次只需要复制“一小部分”存活的对象;但是因为「标记-复制算法」存在空间利用率低的问题,JVM 采用了在新生代划分为两块区域:Eden 区 和 2 个 Survivor 区,HotSpot 虚拟机 Eden : Survivor 大小比例默认为 8 :1,也就是说新生代可用的内存空间为总空间的 90%;当新生代区域剩余空间不足以分配给到新对象时,就会触发一次 GC(新生代的 GC 称为 Young-GC),对当前所有对象进行标记,然后将「存活对象」全部复制到 Survivor 区域;如果剩余的 Survivor 区域不够存放这些「存活对象」,会怎么样?思考一下,我们下面解答~

老年代适用算法

老年代的对象大多存活时间久、且存在较多大对象(对于一些超过阈值的对象直接分配在老年代),在对这一块对象进行 GC 的时候,大部分的对象都是存活的,无法进行回收;

  • 「标记-清除算法」缺点是存在内存碎片
  • 「标记-复制算法」缺点是空间利用率低,明显不适合老年代的特性
  • 「标记-整理算法」缺点是需要重新对「存活对象」的引用进行重新调整,需要 STW;不会产生内存碎片

除了「标记-复制算法」明显不适合老年代的特性,「标记-清除算法」和 「标记-整理算法」相对比较适合;实际上 不同的垃圾收集器在处理老年代时采用了不同的策略:

  • 对于 Serial 和 Parallel 收集器,在进行 Full GC 的时候,默认使用「标记-整理算法」对老年代进行垃圾收集
  • 对于 CMS 收集器,CMS 追求的是停顿时间短,使用「标记-清除算法」对老年代进行垃圾收集,对于内存碎片,会定期进行 Full GC 重新整理内存。

E. JVM GC 的一些细节

问:Young-GC 的时候,如果 Survivor 区无法存放「存活对象」会怎么样?

  1. 每一个对象都会记录一个 GC 年龄(存储在对象头中),每次进行一次 Young-GC GC 年龄就 + 1,达到某个阈值之后(默认是 15,可以通过 JVM 参数进行调整),会进入到老年代,由更大的老年代进行存储
  2. 如果剩余的「存活对象」的年龄还不够阈值(15 次),就会采用「空间分配担保机制」;

空间分配担保机制

JVM 在进行 Young-GC 之前,会先检查当前老年代的最大可用连续空间是不是大于新生代所有对象的总空间,如果大于,说明即使新生代对象全部存活并且都需要进入到老年代,也不会出现堆空间溢出的情况;即这次 Young-GC 是安全的;反过来,如果空间小于,这个时候会检查是否开启了空间分配担保机制(可以通过 JVM 参数控制开启),如果开启了,则会判断老年代最大可用连续空间是是不是大于历次晋升到老年代对象的平均大小,如果小于或者没有开启担保机制,这次 Young-GC 就会升级为 Full GC 对整个堆空间进行 GC。

1.3 垃圾收集器

A. 前言

垃圾收集算法作为方法论,垃圾收集器则是算法的实践者;正如各种算法之间存在各自的优劣,每一种垃圾收集器都存在各自的优势区间和短板,并不存在完美的垃圾收集器;需要根据具体的使用场景来选择正确的收集器

垃圾收集器需要考虑以下几个点:

  1. 吞吐量:用户线程工作时间 / (用户线程工作线程 + GC 线程工作时间)
  2. 延迟:用户线程停顿时间

笔者主要是对经典的垃圾收集器有所了解:

  1. Serial 收集器
  2. ParNew 收集器
  3. Parallel Scavenge 收集器
  4. CMS 收集器
  5. G1 收集器

B. Serial 收集器

Serial 工作在新生代和老年代;

  • 新生代:采用「标记-复制」算法
  • 老年代:采用「标记-整理」算法

Serial 收集器在进行垃圾收集的时候,只有单个 GC 线程在进行工作,收集过程中,用户线程处于停止状态。

优点:实现简单、对一些内存小的 Java 应用来说运行高效,因为不存在线程上下文切换
缺点:单线程收集效率上限不高

C. ParNew 收集器

ParNew 工作在老年代:采用「标记-复制」算法;

Serial 收集器的「多线程」版本,区别在于进行垃圾收集的时候有多条 GC 线程在进行收集。

优点:多线程进行 GC,收集效率上限高
缺点:对一些内存小的 Java 应用来说,运行效率不如 Serial 收集器,存在线程上下文切换

D. Parallel Scavenge 收集器

Parallel Scavenge 工作在老年代;采用「标记-复制」算法;

吞吐量 = 用户线程工作时间 / (用户线程工作时间 + GC 线程工作时间)

Parallel Scavenge 与 ParNew 一样是采用多条 GC 线程对垃圾进行收集,并且提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。除此以外,Parallel Scavenge 收集器还有自适应调节策略,提供了 -XX:+UseAdaptiveSizePolicy 进行控制,开启之后,JVM 能够自行根据系统的运行情况对新生代的大小、Eden 与 Survivor 区的比例、晋升老年代对象大小等相关信息进行「动态调节」。

优点:能够由开发者自行控制 GC 的停顿时间和应用的整体吞吐量
缺点:相当于 ParNew 的升级版

D. CMS 收集器

CMS (Concurrent Mark Sweep)工作在老年代;采用「标记-清除」算法;

特点是:在进行垃圾回收的时候,能够与用户线程「并发」进行,提升了回收效率,缩短了 STW 的时间。

主要工作流程有以下四个阶段:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

CMS 相对比较复杂,在另外一篇文章中,详细展开聊聊这个收集器

E. G1 收集器

G1 (Garbage First) 收集器作为 JDK 9 以上版本默认的垃圾收集器,其不再沿用传统的针对新生代进行 Young GC,针对老年代进行 Major GC,对整个 Java 堆空间进行 Full GC,而是将整个堆划分成一个一个区域(Region),Region 作为最小的回收单元,** G1 允许指定在 M 时间段内尽可能的只消耗 N 时间进行垃圾回收**,每一个 Region 都维护着各自的回收价值(即单位时间内能够回收到多少垃圾),在进行垃圾回收的时候,能够按照价值大小对各个 Region 进行回收。这就是 G1 建立起来的 可预测的时间停顿模型

G1 从逻辑上还存在新生代和老年代的划分,只不过新生代/老年代在 G1 中已经是由一个一个 Region 组成的区域,在物理上不一定连续,不再是固定的了。

1.4 小结

对 Java 的自动垃圾收集原理做了「并不深入」的介绍,从如何识别一个对象为「垃圾对象」,引出「引用技术」算法和「可达性分析」算法,到如何对「垃圾对象」进行回收,引出了「标记-清除」、「标记-复制」、「标记-整理」算法,介绍了一下各自的优缺点,最后是列举了一下传统垃圾收集器,梳理其主要特点。

梳理下来,对 JVM 的垃圾回收工作机制有了更加清晰的认识,接下来就是深入细节去进行学习、梳理、总结~