概述

Java 的内存是由 Java 虚拟机管理的,内存的分配和释放都不需要开发人员处理。一般情况下,开发人员不需要关注这块内容,但是如果在高并发情况下出现了 OOM、CPU 高等问题的时候,就需要优化 Java 虚拟机的垃圾回收。

接下来通过几个问题来了解一下垃圾回收。

问题一:如何判定对象为垃圾对象?
**
通过如下两种算法来判断对象是否为垃圾对象。

  • 引用计数算法
  • 可达性分析

问题二:如何回收?

  • 回收策略
    • 标记-清除算法
    • 复制算法
    • 标记-整理算法
    • 分代收集算法
  • 垃圾收集器
    • Serial
    • Parnew
    • CMS
    • G1

判断对象是否为存活算法

目前虚拟机基本都是采用可达性算法,为什么不采用引用计数算法呢?

下面就说说引用计数法是如何统计所有对象的引用计数的,再对比分析可达性算法是如何解决引用技术算法的不足。先简单说说这两个算法:

  • 引用计数算法(reference-counting) :每个对象有一个引用计数器,当对象被引用一次则计数器加1,当对象引用失效一次则计数器减1,对于计数器为0的对象意味着是垃圾对象,可以被 GC 回收。
  • 可达性算法(GC Roots Tracing):从 GC Roots 作为起点开始搜索,那么整个连通图中的对象便都是活对象,对于 GC Roots 无法到达的对象便成了垃圾回收的对象,随时可被 GC 回收。

采用引用计数算法的系统只需在每个实例对象创建之初,通过计数器来记录所有的引用次数即可。而可达性算法,则需要再次GC时,遍历整个GC根节点来判断是否回收。

下面通过一段代码来对比说明:

  1. public class GcDemo {
  2. public static void main(String[] args) {
  3. //分为6个步骤
  4. GcObject obj1 = new GcObject(); //Step 1
  5. GcObject obj2 = new GcObject(); //Step 2
  6. obj1.instance = obj2; //Step 3
  7. obj2.instance = obj1; //Step 4
  8. obj1 = null; //Step 5
  9. obj2 = null; //Step 6
  10. }
  11. }
  12. class GcObject{
  13. public Object instance = null;
  14. }

很多文章以及 Java 虚拟机相关的书籍,都会告诉你如果采用引用计数算法,上述代码中 obj1 和 obj2 指向的对象已经不可能再被访问,彼此互相引用对方导致引用计数都不为0,最终无法被 GC 回收,而可达性算法能解决这个问题。

但这些文章和书籍并没有真正从内存角度来阐述这个过程是如何统计的,很多时候大家都在相互借鉴、翻译,却也都没有明白。或者干脆装作讲明白,或者假定读者依然明白。 其实很多人并不明白为什么引用计数法不为0,引用计数到底是如何维护所有对象引用的,可达性是如何可达的?

接下来结合实例,从 Java 内存模型以及数学的图论知识角度来说明,希望能让大家彻底明白该过程。

引用计数算法(reference-counting)

如果采用的是引用计数算法:

JVM 垃圾回收 - 图1

再回到前面代码 GcDemo 的 main 方法共分为6个步骤:

  • Step1:GcObject 实例1的引用计数加1,实例1的引用计数=1;
  • Step2:GcObject 实例2的引用计数加1,实例2的引用计数=1;
  • Step3:GcObject 实例2的引用计数再加1,实例2的引用计数=2;
  • Step4:GcObject 实例1的引用计数再加1,实例1的引用计数=2;

执行到 Step 4,则 GcObject 实例1和实例2的引用计数都等于2。

接下来继续结果图:

JVM 垃圾回收 - 图2

  • Step5:栈帧中 obj1 不再指向 Java 堆,GcObject 实例1的引用计数减1,结果为1;
  • Step6:栈帧中 obj2 不再指向 Java 堆,GcObject 实例2的引用计数减1,结果为1。

到此,发现 GcObject 实例1和实例2的计数引用都不为0,那么如果采用的引用计数算法的话,那么这两个实例所占的内存将得不到释放,这便产生了内存泄露。

可达性分析(GC Roots Tracing)

这是目前主流的虚拟机都是采用 GC Roots Tracing 算法,比如 Sun 的 Hotspot 虚拟机便是采用该算法。该算法的核心算法是从 GC Roots 对象作为起始点,利用数学中图论知识,图中可达对象便是存活对象,而不可达对象则是需要回收的垃圾内存。

这里涉及两个概念,一是 GC Roots,一是可达性

那么可以作为 GC Roots 的对象(见下图):

  • 虚拟机栈的栈帧的局部变量表所引用的对象;
  • 本地方法栈的 JNI 所引用的对象;
  • 方法区的静态变量和常量所引用的对象;

关于可达性的对象,便是能与 GC Roots 构成连通图的对象,如下图:

JVM 垃圾回收 - 图3

从上图,reference1、reference2、reference3 都是 GC Roots,可以看出:

  • reference1-> 对象实例1;
  • reference2-> 对象实例2;
  • reference3-> 对象实例4;
  • reference3-> 对象实例4 -> 对象实例6;

可以得出对象实例1、2、4、6都具有 GC Roots 可达性,也就是存活对象,不能被 GC 回收的对象。

而对于对象实例3、5直接虽然连通,但并没有任何一个 GC Roots 与之相连,这便是 GC Roots 不可达的对象,这就是 GC 需要回收的垃圾对象。

到这里,相信大家应该能彻底明白引用计数算法和可达性算法的区别吧
**
再回过头来看看最前面的实例,GcObject 实例1和实例2虽然从引用计数虽然都不为0,但从可达性算法来看,都是 GC Roots 不可达的对象。

总之,对于对象之间循环引用的情况,引用计数算法,则 GC 无法回收这两个对象,而可达性算法则可以正确回收。

垃圾回收算法

标记-清除算法

标记-清除算法将垃圾回收分为两个阶段:标记阶段清除阶段

在标记阶段首先通过根节点(GC Roots),标记所有从根节点开始的对象,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。

JVM 垃圾回收 - 图4

适用场合:

  • 存活对象较多的情况下比较高效
  • 适用于年老代(即旧生代)

缺点:

  • 效率问题:标记和清除两个过程效率都不高,需要遍历对象集合;
  • 空间问题:标记清除后会产生大量不连续的内存碎片,内存空间碎片太多的话会导致以后程序在运行中想要分配较大对象的时候,无法找到一块连续的内存空间而导致不得不进行又一次的 GC 回收;

复制算法

从根集合节点进行扫描,标记出所有的存活对象,并将这些存活的对象复制到一块儿新的内存(图中下边的那一块儿内存)上去,且内存空间是连续的,之后将原来的那一块儿内存(图中上边的那一块儿内存)全部回收掉。

JVM 垃圾回收 - 图5

现在的商业虚拟机都采用这种收集算法来回收新生代。

适用场合:

  • 存活对象较少的情况下比较高效;
  • 扫描了整个空间一次(标记存活对象并复制移动);
  • 适用于年轻代(即新生代):基本上98%的对象是”朝生夕死”的,存活下来的会很少;

缺点:

  • 需要一块儿空的内存空间;
  • 需要复制移动对象;

标记-整理算法

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。

这种情况在新生代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。

JVM 垃圾回收 - 图6

标记-整理算法是一种老年代的回收算法,它在标记-清除算法的基础上做了一些优化。

首先也需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。

分代收集算法

Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集,效率较高;
  • 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就使用标记-清除算法或者标记-整理**算法**来进行回收;

垃圾回收有两种类型:Minor GC 和 Full GC

Minor GC

也叫 Young GC 对新生代进行回收,不会影响到年老代。因为新生代的 Java 对象大多死亡频繁,所以 Minor GC 非常频繁,一般在这里使用速度快、效率高的算法,使垃圾回收能尽快完成。

Full GC

也叫 Major GC,对整个堆进行回收,包括新生代和老年代。由于 Full GC 需要对整个堆进行回收,所以比 Minor GC 要慢,因此应该尽可能减少 Full GC 的次数,导致 Full GC 的原因包括:老年代被写满、永久代(Perm)被写满和 System.gc() 被显式调用等。

垃圾收集器

JVM 垃圾回收 - 图7

新生代的收集器包括:

  • Serial
  • PraNew
  • Parallel Scavenge

老年代的收集器包括:

  • Serial Old
  • Parallel Old
  • CMS

回收整个 Java 堆(新生代和老年代):

  • G1 收集器

Serial 收集器

Serial 串行收集器使用复制算法,它是新生代单线程收集器,优点是简单高效,算是最基本、发展历史最悠久的收集器。它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集完成。

v2-fcefef6fae60379387b6c28f403baa55_hd_看图王.jpg

Serial 收集器依然是虚拟机运行在 Client 模式下默认新生代收集器,对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

ParNew 收集器

ParNew 收集器使用复制算法,它是新生代并行收集器,其实就是 Serial 收集器的多线程版本。

image.png

除了使用多线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样。

Parallel Scavenge 收集器

Parallel Scavenge 收集器使用复制算法,它是新生代并行收集器,追求高吞吐量,高效利用 CPU。

该收集器的目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可用高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge 通过如下两个参数实现可控制的吞吐量:

  • -XX:MaxGCPauseMillis:垃圾收集器最大停顿时间,单位是毫秒;
  • -XX:GCTimeRatio:设置吞吐量大小,它的值是一个 0-100 之间的整数,假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集;

Serial Old 收集器

Serial Old 收集器使用标记-整理算法,他是 Serial 收集器的老年代版本,它同样是一个单线程(串行)收集器。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。

如果在Server模式下,主要两大用途:

  1. 在 JDK1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用
  2. 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure(需要 stop-the-wold 降级为 Serail Old) 时使用

concurrent mode failure:该问题是在执行 CMS GC 的过程中同时业务线程将对象放入老年代,而此时老年代空间不足,或者在做 Minor GC 的时候,新生代 Survivor 空间放不下,需要放入老年代,而老年代也放不下而产生的。

Parallel Old 收集器

Parallel Old 收集器使用标记-整理算法,它是 Parallel Scavenge 收集器的老年代版本,使用多线程。这个收集器在1.6中才开始提供。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

CMS收集器是基于**标记-清除算法**实现的,它的运作过程相对前面几种收集器来说更复杂一些,整个过程分为4个步骤:

  1. 初始标记(Stop The World):只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程;
  2. 并发标记:进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。并发标记就需要标记出 GC Roots 关联到的对象的引用对象有哪些。比如说 A -> B (A 引用 B,假设 A 是 GC Roots 关联到的对象),那么这个阶段就是标记出 B 对象,A 对象会在初始标记中标记出来;
  3. 重新标记(Stop The World):为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程;
  4. 并发清除:清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程;

其中,初始标记、重新标记这两个步骤仍然需要 “Stop The World”,由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看 CMS 收集器的内存回收和用户线程是一起并发地执行。

image.png

优点:

  • 并发收集;
  • 低停顿【注意:这里的停顿指的是停止用户线程】,Oracle 公司的一些官方文档中也称之为并发低停顿收集器(Concurrent Low Pause Collector);

缺点:

  • CMS 收集器占用大量的 CPU 资源;
  • CMS 收集器无法处理浮动垃圾(Floating Garbage,就是指在之前判断该对象不是垃圾,由于用户线程同时也是在运行过程中的,所以会导致判断不准确的,可能在判断完成之后在清除之前这个对像已经变成了垃圾对象,所以有可能本该此垃圾被回收但是没有被回收,只能等待下一次 GC 再将该对象回收,所以这种对像就是浮动垃圾);
  • 可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。如果在应用中老年代增长不是太快,可能适当调高参数 -XX:CMSInitiatingOccupancyFraction 的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能。要是 CMS 运行期间预留的内存无法满足程序需要时,虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数 -XX:CMSInitiatingOccupancyFraction 设置得太高很容易导致大量 “Concurrent Mode Failure” 失败,性能反而降低。
  • 收集结束时会有大量空间碎片产生,空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前进行一次 Full GC。CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认就是开启的),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。

G1 收集器

G1 收集器使用标记-整理算法,JDK1.7 后全新的回收器, 用于取代 CMS 收集器。

优势:

  • 并行和并发:回收期间, 可由多个线程同时工作, 有效利用多核 cpu 资源;
  • 独特的分代收集:G1 将整个堆划分成许多固定大小的区域(Region),物理上不区分新生代、老年代,逻辑上保留了新生代、来年代的概念。G1 采用内存分区(Region)的思路,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此 G1 天然就是一种压缩方案(局部压缩);
  • 空间整理: 回收过程中, 会进行适当对象移动, 减少空间碎片;
  • 可预测的停顿:G1 可选取部分区域进行回收, 可以缩小回收范围, 减少全局停顿。

G1 收集器的应用场景

G1 垃圾收集算法主要应用在多 CPU、大内存的服务中,在满足高吞吐量的同时,尽可能的满足垃圾回收时的暂停时间。

就目前而言 CMS 还是默认首选的 GC 策略、可能在以下场景下 G1 更适合:

  • 服务端多核 CPU、JVM 内存占用较大的应用(至少大于4G);
  • 应用在运行过程中会产生大量内存碎片、需要经常压缩空间;
  • 想要更可控、可预期的 GC 停顿周期,防止高并发下应用雪崩现象;

G1 的堆内存算法

G1 之前的 JVM 内存模型

**image.png

G1 收集器的内存模型

image.png

  1. G1堆内存结构

    1. 堆内存会被切分成为很多个固定大小区域(Region),每个是连续范围的虚拟内存;
    2. 堆内存中一个区域(Region)的大小可以通过 -XX:G1HeapRegionSize 参数指定,大小区间最小1M、最大32M,总之是2的幂次方。
    3. 默认把堆内存按照2048份均分;
  2. G1 堆内存分配

    1. 每个 Region 被标记了 E、S、O 和 H,这些区域在逻辑上被映射为 Eden,Survivor 和老年代;
    2. 存活的对象从一个区域转移(即复制或移动)到另一个区域。区域被设计为并行收集垃圾,可能会暂停所有应用线程;
    3. 如上图所示,区域可以分配到 Eden,survivor 和老年代。此外,还有第四种类型,被称为巨型区域(Humongous Region)。Humongous 区域是为了那些存储超过 50% 标准 region 大小的对象而设计的,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC;

G1 回收流程

在执行垃圾收集时,G1 以类似于 CMS 收集器的方式运行。

G1 收集器的阶段分以下几个步骤:

image.png

  1. 初始标记(Initial Marking):这个阶段是 STW(Stop the World)的,所有应用线程会被暂停,标记出从 GC Root 开始直接可达的对象;
  2. 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,找出存活对象,耗时较长,当并发标记完成后,开始最终标记(Final Marking)阶段;
  3. 最终标记:标记那些在并发标记阶段发生变化的对象,将被回收;
  4. 筛选回收:首先对各个 Regin 的回收价值和成本进行排序,根据用户所期待的 GC 停顿时间指定回收计划,回收一部分 Region。

G1 的 GC 模式

G1 中提供了两种模式垃圾回收模式,Young GC 和 Mixed GC,两种都是 Stop The World(STW) 的。

Young GC 年轻代收集

在分配一般对象(非巨型对象)时,当所有 eden region 使用达到最大阀值并且无法申请足够内存时,会触发一次Young GC。每次 Young GC 会回收所有 Eden 以及 Survivor 区,并且将存活对象复制到 Old 区以及另一部分的 Survivor 区。

Mixed GC

当越来越多的对象晋升到老年代 old region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个 old gc,除了回收整个 young region,还会回收一部分的 old region,这里需要注意的是一部分老年代,而不是全部老年代,可以选择哪些 old region 进行收集,从而可以对垃圾回收的耗时时间进行控制。

G1 没有 FULL GC 概念,需要 FULL GC 时,调用 Serial Old GC 进行全堆扫描(包括 eden、survivor、o、perm)。

转载

《垃圾回收机制中,引用计数法是如何维护所有对象引用的?》
《JVM的4种垃圾回收算法、垃圾回收机制与总结》
《7种JVM垃圾收集器特点,优劣势、及使用场景》

作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/gq1850 来源:殷建卫 - 架构笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。