Java 虚拟机 | 垃圾回收机制 | 七日打卡

2,767 阅读14分钟

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


目录


前置知识

这篇文章的内容会涉及以下前置 / 相关知识,贴心的我都帮你准备好了,请享用~


1. 垃圾回收概述

垃圾回收机制(Garbage Collection,GC) 是一种自动的内存管理机制,即:当内存中的对象不再需要时,就自动释放以让出存储空间。

垃圾回收机制是 Java 虚拟机的重要特性之一,同时也是面试重要考点之一。在实践中,由于 GC 会占用程序运行资源,欲进行更有深度的内存性能优化也需要对垃圾回收机制有一定理解。

在讨论垃圾回收机制的时候,需要讨论的以下三个问题,你可以带着这三个问题阅读后面的内容,思路会更清晰。

  • 回收的对象: 哪些对象 / 区域需要回收?
  • 回收的时机: 什么时候触发 GC?
  • 回收的过程: 如何回收?

1.1 GC 相关概念

这一节,我们先罗列一些 GC 相关知识中比较重要的概念:

概念描述
collector表示程序中负责垃圾回收的模块
mutator表示程序中除了 collector 以外的模块
增量式回收(Incremental Collection)每次 GC 只针对堆的一部分,而不是整个堆,大幅减少了停顿时间
分代回收(Generational GC)增量式回收的实现方式之一,将堆分为新生代、老生代和永生代等部分
并行回收(Parallel Collection)collector 中有多个垃圾回收线程
并发回收(Concurrent Collection)指垃圾回收工作的某个阶段,collector 线程和 mutator 可以同时执行。
这样避免了 collector 线程工作时需要暂停 mutator 线程(stop-the-world)

1.2 垃圾回收的优缺点

  • 优点: 不再需要为每个 new 操作编写对应的 delete / free 操作,程序不容易出现内存泄漏或内存溢出问题;

  • 风险: 垃圾回收处理程序本身也占用系统资源(CPU 资源 / 内存),增大程序暂停时间。

1.3 GC 算法性能指标

在介绍垃圾回收算法之前,我们先来定义评价垃圾回收方法的性能指标:

指标定义描述
吞吐量(throughput)指单位时间内的处理能力吞吐量=运行用户代码时间垃圾回收频率单次垃圾回收时间吞吐量 = \frac{运行用户代码时间} {垃圾回收频率 * 单次垃圾回收时间}
最大暂停时间(pause time)指因执行 GC 而暂停执行程序的最长时间/
堆利用率(space overhead)指有效使用的堆空间占整个堆的比例影响因素:对象头大小 + 回收算法
访问局部性指回收方法是否倾向于访问局部内存访问局部内存更容易命中 CPU 缓存行

提示: 若不理解 “访问局部性” 的概念,可联想快速排序和堆排序的性能对比,前者的访问局部性更优。


2. 垃圾回收管理的区域(回收的对象)

根据《Java虚拟机规范》的规定,Java 虚拟机所管理的内存将会包括以下区域:

运行时数据区域线程独占描述
程序计数寄存器私有存储下一条字节码指令的内存地址
Java 虚拟机栈私有存储线程栈帧(Stack Frame )

栈帧包含:局部变量表、操作数栈、动态连接、返回地址等信息
本地方法栈私有存储本地方法栈帧
Java 堆共享大多数对象的存储区域
方法区共享存储类型信息、常量、类静态变量、即使编译器编译后的代码缓存等

并不是 Java 虚拟机管理的所有区域都需要垃圾回收,线程独占的区域会随着线程结束而销毁,不需要垃圾回收。因此垃圾回收机制需要管理的区域是:

  • 堆: 垃圾对象;

  • 方法区: 废弃的常量和不再使用的类型。


3. 如何判定垃圾对象?(回收的时机)

判断对象是否为垃圾对象的方法可以分为两种:引用计数 & 可达性分析。以判断方法为划分,后文所讲的垃圾回收算法也可以划分为 引用计数式 & 追踪式 两大类。

3.1 引用计数算法(Reference Counting)

3.1.1 判定方法

在分配对象时,会额外为对象分配一段空间,用于记录指向该对象的引用个数。如果有一个新的引用指向该对象,则计数器加 1;当一个引用不再指向该对象,则计数器减 1 。当计数器的值为 0 时,则该对象为垃圾对象。

3.1.2 优点

  • 1、及时性:当对象变成垃圾后,程序可以立刻感知,马上回收;而在可达性分析算法中,直到执行 GC 才能感知;
  • 2、最大暂停时间短:GC 可与应用交替运行。

3.1.3 缺点

  • 1、计数器值更新频繁:大多数情况下,对象的引用状态会频繁更新,更新计数器值的任务会变得繁重;
  • 2、堆利用率降低:计数器至少占用 32 位空间(取决于机器位数),导致堆的利用率降低;
  • 3、实现复杂;
  • 4、(致命缺陷)无法回收循环引用对象。

易错: 引用计数法是算法简单,实现较难。

3.2 可达性分析算法(Reachability Analysis)

3.2.1 判定方法

从 GC 根节点(GC Root)为起点,根据引用关系形成引用链。当一个对象存在到 GC Root 的引用链,则为存活对象,否则为垃圾对象。在 Java 中,GC Root 主要包括:

  • 1、Java 虚拟机栈中引用的对象(即栈帧中的本地变量表);
  • 2、本地方法栈中引用的对象;
  • 3、方法区中类静态变量引用的对象;
  • 4、方法区常量池中引用的对象;
  • 5、同步锁(synchronized 关键字)持有的对象;

3.2.2 优点

  • 1、可回收循环引用对象;
  • 2、实现简单。

3.2.3 缺点

  • 1、最大停顿时间长:在 GC 期间,整个应用停顿(stop-the-world,STW);
  • 2、回收不及时:直到执行 GC 才能感知垃圾对象;

3.3 小结

判定方法优点缺点
引用计数1、及时性
2、最大暂停时间短
1、计数器值更新频繁
2、堆利用率降低
3、实现复杂
4、无法回收循环引用对象
可达性分析1、可回收循环引用对象
2、实现简单
1、最大停顿时间长
2、回收不及时

由于引用计数式 GC 存在 「无法回收循环引用对象」 的致命缺陷,工业实现上还是追踪式 GC 占据了主流,后面我主要介绍的也是追踪式 GC。


4. 垃圾回收算法(回收的过程)

从原理上,垃圾回收算法可以分为以下四类基础算法,其它的垃圾回收算法其实是对基础算法的改进或组合。

时间早期提出者算法类别
1960年Lisp 之父 John McCarthy标记 - 清理算法追踪式
1960年George E. Collins引用计数算法引用计数式
1969年Fenichel复制算法追踪式
1974年Edward Lueders标记 - 整理算法追踪式

在实践中,当代绝大多数垃圾收集器都采用了 “分代收集模型” ,该模型的经验前提是:

  • 1、绝大多数对象都是朝生夕死,无法熬过第一次垃圾回收;
  • 2、熬过了多次垃圾回收的对象,往往越难被回收。

在上述事实经验的基础上,虚拟机往往使用了 动静分离 的设计思想:将新对象和难以回收的老对象存储在不同的区域,新对象存放在新生代,难回收的对象存在老年代。并且针对不同区域的特性采用不同的垃圾回收算法。

—— 图片引用自网络

  • 1、新生代: 新生代中的对象存活率低,只要付出少量的复制成本就能完成回收过程,因此选用复制算法;

  • 2、老生代: 老生代中的对象存活率高,并且没有额外空间进行分配担保,因此选用 “标记 - 清理” 或 “标记 - 整理” 算法。

4.1 标记 - 清理算法(Mark-Sweep)

4.1.1 算法回收过程

标记 - 清理算法的回收过程主要分为两个阶段:

  • 标记(Mark)阶段: 遍历整个堆,标记出垃圾对象(也可以标记存活对象);

  • 清理(Sweep)阶段: 遍历整个堆,将垃圾对象分块链接空闲列表。

4.1.2 优点

实现简单;

4.1.3 缺点

  • 1、执行效率不稳定:Java 堆中对象越多,标记和清理的过程可能会越耗时;
  • 2、内存碎片化(fragmentation):回收过程会逐渐产生很多不连续的小内存,当小内存不足以分配对象内存时,又会触发一次垃圾回收动作(GC for Alloc)。

4.2 复制算法(Copying)

4.2.1 算法回收过程

复制算法的回收过程要点如下:

  • 1、将堆分为大小相同的两个空间:from 区和 to 区;
  • 2、对象的内存分配只使用 from 区,当 from 区占满时,将存活对象全部复制到 to 区;
  • 3、复制完成后互换 from 区和 to 区的指针。

—— 图片引用自 weread.qq.com/web/reader/… 邓凡平 著

4.2.2 优点

  • 1、快速分配对象:空闲分块是一个连续内存空间,不需要向标记-清理算法那样遍历空闲列表;
  • 2、避免内存碎片化:存活对象和新分配对象都被压缩到 tospace 的一端,避免出现很多不连续的小内存。

4.2.3 缺点

  • 1、堆利用率低:把堆做二等分只能利用其中的一半,堆利用率最高仅为 50 %。

4.2.4 改进

  • 1、将新生代分为:一块 Eden 区和两块 Survivor 区,对应的比例为 8:1:1;
  • 2、对象只在 Eden 区分配,当 Eden 区占满后,将 Eden 区和 from Survivor 区的存活对象全部赋值到 to Survivor 区;
  • 3、复制完成后互换 from Survivor 区和 to Survivor 区的指针。

改进后堆利用率提升到最高 90%。

4.3 标记 - 整理算法(Mark-Compact)

4.3.1 算法回收过程

标记 - 清除算法与标记 - 整理算法的本质差异在于是否移动对象。标记 - 整理算法的回收过程主要分为两个阶段:

  • 标记(Mark)阶段: 遍历整个堆,标记出垃圾对象(这个步骤与标记 - 清理算法相同);

  • 整理(Compact)阶段: 将所有存活对象移动(压缩)到堆的一端,然后直接清理掉边界以外的内存。

—— 图片引用自 weread.qq.com/web/reader/… 邓凡平 著

4.3.2 优点

  • 1、避免内存碎片化,堆利用率高,吞吐量更高;
  • 2、快速分配对象:空闲分块是一个连续内存空间,不需要向标记-清理算法那样遍历空闲列表;

4.3.3 缺点

  • 1、移动对象比清理对象更耗时,导致 GC 停顿时间(Stop-the-world)时间更长。

5. 并发回收

5.1 stop-the-world 现象

在标准的垃圾回收算法中,在垃圾回收线程(collector)进行标记 - 清理 / 整理 / 复制的过程中需要暂停所有的用户线程(mutator),这是为了保证能够彻底清理所有垃圾对象。

但是这种做法却会导致虚拟机的吞吐量降低(吞吐量=运行用户代码时间垃圾回收频率单次垃圾回收时间吞吐量 = \frac{运行用户代码时间} {垃圾回收频率 * 单次垃圾回收时间})。

5.2 CMS 垃圾收集器

在追求响应速度的系统上,希望垃圾收集器暂停时间尽可能小,为此发展出了允许回收线程与用户线程并发运行的垃圾收集器 —— CMS(Concurrent Mark Sweep,并发标记清除)。

CMS 垃圾收集器的主要工作过程分为 4 个步骤:

  • 1、初始标记(短暂 stop-the-world): 仅仅标记被 GC Root 直接引用的对象,由于 GC Root 相对较少,这个过程速度很块;

  • 2、并发标记(耗时): 继续遍历 GC Root 引用链上的对象,这个过程比较耗时,所以采用并发处理;

  • 3、重新标记(短暂 stop-the-world): 为了修正并发标记期间用户线程导致的引用关系变化,需要暂停用户线程重新标记;

  • 4、并发清除(耗时) 由于清除对象的过程比较耗时,所以采用并发处理。

—— 图片引用自网络

5.3 CMS 的优点

  • 1、缩短了系统 stop-the-world 时间,提高了吞吐量;

5.4 CMS 的缺点

  • 1、CPU 敏感: 采用了并发策略,系统整体上会占用更多 CPU 资源;
  • 2、浮动垃圾: 由于并发清理的过程中用户线程还在运行,CMS 无法回收这个阶段中用户线程产生的垃圾,这一部分垃圾称为 “浮动垃圾”。由于浮动垃圾的存在,垃圾收集器需要预留出一部分空间来允许浮动垃圾的产生,如果预留的空间还不足以存放浮动垃圾,就会出现 Concurrent Mode Failure,此时需要临时启动非并发清理方案来代替 CMS;
  • 3、内存碎片: 采用标记 - 清理算法,会产生内存碎片。

6. 总结

  • 1、垃圾回收算法的性能指标主要有:吞吐量、最大暂停时间、堆利用率、访问局部性。在理解垃圾回收机制的过程中,可以带着 “回收的对象” & “回收的时机” & “回收的过程” 三个问题来理解;

  • 2、垃圾回收机制管理的区域有堆和方法区;

  • 3、判断垃圾对象的算法分为引用计数算法和可达性分析算法,两者各有优缺点;

  • 4、垃圾回收算法可以分为四类基本算法:引用计数算法、标记-清理算法、标记-整理算法和复制算法。其它的垃圾回收算法都是对基础算法的改进或组合。比如主流的虚拟机垃圾回收算法采用分代回收模型:即在新生代选用复制算法(对象存活率低),而老生代选用 “标记 - 清理” 或 “标记 - 整理” 算法(对象存活率高,并且没有额外空间进行分配担保);

  • 5、在标准的垃圾回收算法中,垃圾回收过程会 stop-the-world。使用并发收集可以降低系统的暂停时间,提供吞吐量。


参考资料


创作不易,你的「三连」是丑丑最大的动力,我们下次见!