一文搞定垃圾回收器

Posted 纵横千里,捭阖四方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文搞定垃圾回收器相关的知识,希望对你有一定的参考价值。

目录

1. 垃圾回收概述

1.1 标记阶段

1.1.1. 引用计数法

1.1.2. 可达性分析法

1.2 清除阶段

1.2.1. 标记-清除算法

1.2.2. 复制算法

1.2.3. 标记-压缩算法

1.3. 不同代的收集算法

2. 垃圾回收器

2.1. 评估 GC 的性能指标

2.2. 不同的垃圾回收器概述

2.3. Serial 回收器:串行回收

2.4. ParNew 回收器:并行回收

2.5. Parallel 回收器:吞吐量优先

2.6. CMS 回收器:低延迟

2.7. G1 回收器:区域化分代式

2.7.1 增量收集算法

2.7.2. G1 垃圾回收器的回收过程

2.8. 垃圾回收器总结

2.8.1. 7 种经典垃圾回收器总结

2.8.2. 垃圾回收器组合

2.8.3. 怎么选择垃圾回收器

3. 垃圾回收相关重要主题探讨

3.1. System.gc()的理解

3.2. 再谈引用

3.2.1 强引用(Strong Reference)—不回收

3.2.2 软引用(Soft Reference):内存不足即回收

3.2.3 弱引用(Weak Reference)发现即回收

3.2.4 虚引用(Phantom Reference):对象回收跟踪

1. 垃圾回收概述

垃圾是指在运行程序中没有任何指针指向的对象。如果不及时清理,这些垃圾对象会一直占用内存空间,从而导致内存空间逐步被占满。我们知道C和C++是需要用户自己处理空间分配的问题,如果处理不当,就带来各种异常问题。而Java从创立之初的一大特色就是就是自带垃圾回收器。垃圾回收器极大地提高了开发效率,如今已成为现代语言的标配,例如当前python、go、C#、Ruby等高级语言也自带这样的功能。

对于 Java 开发人员而言,有了自动内存管理,无需手动参与内存的分配与回收,这就从繁重的内存管理中释放出来,可以更专心地专注于业务开发。但是自动内存管理就像一个黑匣子,如果过度依赖于“自动”,就会弱化在程序出现内存溢出时定位问题和解决问题的能力,因此掌握JVM 的自动内存分配和内存回收原理就显得非常重要,只有这样才能对系统和合理的设置,并且在遇见例如 outofMemoryError 等问题时,能快速定位问题和解决问题。

要设计垃圾回收器,必然要先明确几个问题:哪些需要回收?什么时候回收?如何回收?

垃圾回收不是针对所有空间的,我们主要关心方法区和堆中的垃圾收集,因为方法区存放的是用户的类信息,而堆放的是创建的对象,内容比较多并且与用户交互也最密切。而堆又分为多个区,垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收。从次数上讲, Young 区收集最频繁, Old 区收集较少,而 Perm 区(元空间)基本不收集。

接下来的问题是怎么知道哪些内存可以回收呢?也是该如何判断某个对象是不是垃圾呢?在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以认为是可以回收的垃圾。

那具体该怎么回收呢?实践发现这不是一撮而就的,至少需要两个阶段,第一个是标记阶段,第二个阶段才是清除阶段。标记阶段主要是为了判断对象是否存活,一般有两种方式:引用计数算法和可达性分析算法。而三种垃圾常见常见的清除算法是标记一清除算法(Mark-Sweep)、复制算法(copying)、标记-压缩算法(Mark-Compact)。接下来我们就重点分析这几种方法。

1.1 标记阶段

1.1.1. 引用计数法

引用计数算法(Reference Counting)比较简单,就是对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。

对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。很显然这种方式的实现比较简单,垃圾对象便于辨识,同时判定效率高,回收没有延迟性。

但是这种方式也有明显的缺点:它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。另外每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。而这种方式最大的问题是无法处理循环引用的情况,因此 Java 的垃圾回收器中没有使用这类算法。

我们解释一下什么是循环引用,当 p 的指针断开的时候,内部的引用形成一个循环,这就是循环引用,如下图所示.

引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的 Python,它更是同时支持引用计数和垃圾收集机制。Python 如何解决循环引用?手动解除!当然这不是人手动干而是使用弱引用 weakref等方式再某个时候来解除。

1.1.2. 可达性分析法

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,也可以有效地解决在引用计数算法中循环引用的问题,因此在 Java、C#里都有应用。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection),所谓 "GCRoots”根集合就是一组必须活跃的引用。

可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)。如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

 那根对象都有哪些呢?在 Java 语言中,GC Roots 包括以下几类元素:

  • 虚拟机栈中引用的对象,比如:各个线程被调用的方法中使用到的参数、局部变量等。

  • 本地方法栈内 JNI(通常说的本地方法)引用的对象

  • 方法区中类静态属性引用的对象,比如:Java 类的引用类型静态变量

  • 方法区中常量引用的对象,比如:字符串常量池(String Table)里的引用

  • 所有被同步锁 synchronized 持有的对象。

  • Java 虚拟机内部的引用。基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。

  • 反映 java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合,比如:分代收集和局部回收(PartialGC)。

如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GCRoots 集合中去考虑,才能保证可达性分析的准确性。

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。这点也是导致 GC 进行时必须“stop The World”的一个重要原因。即使是号称几乎不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

1.2 清除阶段

JVM 中比较常见的三种垃圾收集算法是标记一清除算法(Mark-Sweep)、复制算法(copying)、标记-压缩算法(Mark-Compact)。

1.2.1. 标记-清除算法

当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。

标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,其原理是当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。

  • 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收

这种方式有很明显的不足,标记清除算法的效率不算高,而且在进行 GC 的时候,需要停止整个应用程序,用户体验较差,另外这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表。

1.2.2. 复制算法

为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于 1963 年发明了复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。其核心思想是将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

 这种方式的优点是:没有标记和清除过程,实现简单,运行高效。另外一个是复制过去以后保证空间的连续性,不会出现“碎片”问题。当然此算法的缺点也是很明显的,就是需要两倍的内存空间。这种方式主要应用在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

1.2.3. 标记-压缩算法

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生,该方法的执行过程是:

  1. 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象。

  2. 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。

  3. 之后,清理边界外所有的空间。

 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。

二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

这种方式的优点是消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。另外就是,消除了复制算法当中,内存减半的高额代价。

这种方式的缺点是:从效率上来说,标记-整理算法要低于复制算法;移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址;移动过程中,需要全程暂停用户应用程序,即:STW。

Mark-SweepMark-CompactCopying
速率中等最慢最快
空间开销少(但会堆积碎片)少(不堆积碎片)通常需要活对象的 2 倍空间(不堆积碎片)
移动对象

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段

难道就没有一种最优算法吗?遗憾的是当前并没有,即使目前最新的G1也不行。没有最好的算法,只有最合适的算法,并根据实际场景来优化,将负面影响降低到最小,这就是下面要说的分代收集问题。

1.3. 不同代的收集算法

前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点,分代收集算法应运而生。

分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的 GC 都采用分代收集算法执行垃圾回收的。在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。

年轻代(Young Gen)

年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 hotspot 中的两个 survivor 的设计得到缓解。

老年代(Tenured Gen)

老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。

  • Mark 阶段的开销与存活对象的数量成正比。

  • Sweep 阶段的开销与所管理区域的大小成正相关。

  • Compact 阶段的开销与存活对象的数据成正比。

以 HotSpot 中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。而对于碎片问题,CMS 采用基于 Mark-Compact 算法的 Serial Old 回收器作为补偿措施:当内存回收不佳(碎片导致的 Concurrent Mode Failure 时),将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理。

2. 垃圾回收器

前面分析了不同垃圾回收算法的优缺点,本章就来看一下常见的回收器有哪些,怎么实际构建出一个垃圾回收器的。在此之前,我们先看一下评价垃圾回收器好坏的几个指标。

2.1. 评估 GC 的性能指标

评价垃圾回收器主要通过一下几个指标:

  • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)

  • 垃圾收集开销:垃圾收集所用时间与总运行时间的比例。

  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。

  • 收集频率:相对于应用程序的执行,收集操作发生的频率。

  • 内存占用:Java 堆区所占的内存大小。

  • 快速周期:一个对象从诞生到被回收所经历的时间。

“吞吐量、暂停时间、内存占用”这三者共同构成一个“不可能三角”,一款优秀的收集器通常最多同时满足其中的两项。这三项里,因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。暂停时间越来越不能被容忍,想象一下淘宝开始双十一秒杀的时候,突然JVM要开始几秒的垃圾清理,那会是什么画面。简单来说,主要抓住两点:吞吐量、暂停时间。

吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间)。比如:虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。

吞吐量优先,意味着在单位时间内,STW 的时间最短:0.3 + 0.3 = 0.6

 “暂停时间”是指一个时间段内应用程序线程暂停,让 GC 线程执行的状态。例如,GC 期间 100 毫秒的暂停时间意味着在这 100 毫秒期间内没有应用程序线程是活动的。

暂停时间优先,意味着尽可能让单次 STW 的时间最短:0.1 + 0.1 + 0.1 + 0.1 = 0.4

 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。

低暂停时间(低延迟)较好因为从最终用户的角度来看不管是 GC 还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的 200 毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。

不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收。相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。

在设计GC 算法时,我们必须确定我们的目标:一个 GC 算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。

现在一般的追求的是,在最大吞吐量优先的情况下,降低停顿时间,所以我们会经常让运维给增加机器。

2.2. 不同的垃圾回收器概述

垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。这当然也是面试的热点。不同的算法适用不同的场景,不同的场景也需要不同的算法,因此Java的垃圾回收器不是一个,而是一组回收器的组合。

垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的 JVM 来实现。由于 JDK 的版本处于高速迭代过程中,因此 Java 发展至今已经衍生了众多的 GC 版本。从不同角度分析垃圾收集器,可以将 GC 分为不同的类型。

按线程数分 ,可以分为串行垃圾回收器和并行垃圾回收器。

串行回收指的是在同一时间段内只允许有一个 CPU 用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的 Client 模式下的 JVM 中。

而在并发能力比较强的 CPU 上,并行回收器产生的停顿时间要短于串行回收器。和串行回收相反,并行收集可以运用多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-World”机制。

按照工作模式分 ,可以分为并发式垃圾回收器和独占式垃圾回收器。并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

按碎片处理方式分 ,可分为压缩式垃圾回收器和非压缩式垃圾回收器。压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。非压缩式的垃圾回收器不进行这步操作。

按工作的内存区间分 ,又可分为年轻代垃圾回收器和老年代垃圾回收器。

这些回收器中,最为重要的是:

  • 串行回收器:Serial、Serial Old

  • 并行回收器:ParNew、Parallel Scavenge、Parallel old

  • 并发回收器:CMS、G1

这七种垃圾回收器的关系是:

虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的万能的收集器,所以我们选择的只是对具体应用最合适的收集器。我们可以通过-XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)。

而在实际应用中,我们需要考虑不同的代适合什么的收集器,当前主要关系是:

  • 新生代收集器:Serial、ParNew、Parallel Scavenge;

  • 老年代收集器:Serial Old、Parallel Old、CMS;

  • 整堆收集器:G1;

而且这几种回收器也不是随意组合的,常见的组合如下:

  1. 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中 Serial Old 作为 CMS 出现"Concurrent Mode Failure"失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时将 Serial+CMS、ParNew+Serial Old 这两个组合声明为废弃(JEP173),并在 JDK9 中完全取消了这些组合的支持(JEP214),即:移除。

  4. (绿色虚线)JDK14 中:弃用 Parallel Scavenge 和 Serialold GC 组合(JEP366)。

  5. (绿色虚框)JDK14 中:删除 CMS 垃圾回收器(JEP363)。

2.3. Serial 回收器:串行回收

Serial 收集器是最基本、历史最悠久的垃圾收集器了,是JDK1.3 之前回收新生代唯一的选择。Serial 收集器作为 HotSpot 中 client 模式下的默认新生代垃圾收集器。Serial 收集器采用复制算法、串行回收和"stop-the-World"机制的方式执行内存回收。

除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。

  • Serial old 是运行在 Client 模式下默认的老年代的垃圾回收器

  • Serial 0ld 在 Server 模式下主要有两个用途:① 与新生代的 Parallel scavenge 配合使用 ② 作为老年代 CMS 收集器的后备垃圾收集方案。

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。

优势:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在 Client 模式下的虚拟机是个不错的选择。在用户的桌面应用场景中,可用内存一般不大(几十 MB 至一两百 MB),可以在较短时间内完成垃圾收集(几十 ms 至一百多 ms),只要不频繁发生,使用串行回收器是可以接受的。

在 HotSpot 虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用 Serial GC,且老年代用 Serial Old GC。当然这种垃圾收集器只是为了解,因为只能在单核 cpu 才可以用,而且目前大部分应用都需要交互,也不能接受这种垃圾收集器。

2.4. ParNew 回收器:并行回收

如果说 Serial GC 是年轻代中的单线程垃圾收集器,那么 ParNew 收集器则是 Serial 收集器的多线程版本。Par 是 Parallel 的缩写,New:只能处理的是新生代。

ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew 收集器在年轻代中同样也是采用复制算法、"Stop-the-World"机制。ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器。

这种收集器可以有两种工作方式,对于新生代,回收次数频繁,使用并行方式高效。对于老年代,回收次数少,使用串行方式节省资源。由于 ParNew 收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比 serial 收集器更高效?ParNew 收集器运行在多 CPU 的环境下,由于可以充分利用多 CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。但是在单个 CPU 的环境下,ParNew 收集器不比 Serial 收集器更高效。虽然 Serial 收集器是基于串行回收,但是由于 CPU 不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。

因为除 Serial 外,目前只有 ParNew GC 能与 CMS 收集器配合工作。在程序中,开发人员可以通过选项"-XX:+UseParNewGC"手动指定使用 ParNew 收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。-XX:ParallelGCThreads限制线程数量,默认开启和 CPU 数据相同的线程数。

2.5. Parallel 回收器:吞吐量优先

HotSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和"Stop the World"机制。

那么 Parallel 收集器的出现是否多此一举?和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。

高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。

Parallel 收集器在 JDK1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-the-World"机制。

在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。在 Java8 中,默认是此垃圾收集器。

2.6. CMS 回收器:低延迟

在 JDK1.5 时期,Hotspot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。

CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

CMS 的垃圾收集算法采用标记-清除算法,并且也会"Stop-the-World"。不幸的是,CMS 作为老年代的收集器,却无法与 JDK1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。在 G1 出现之前,CMS 使用还是非常广泛的,一直到今天,仍然有很多系统使用 CMS GC。

CMS 整个过程比之前的收集器要复杂,整个过程分为 4 个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GCRoots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。

  • 并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。

  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,“Stop-the-World”因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

尽管 CMS 收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。另外,由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS 收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么 CMS 在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

有人会觉得既然 Mark Sweep 会造成内存碎片,那么为什么不把算法换成 Mark Compact?

答案其实很简单,因为当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact 更适合“Stop the World” 这种场景下使用

CMS 的优点:并发收集,低延迟。CMS 的弊端:会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发 FullGC。CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。CMS 收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure"失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS 将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行 GC 时释放这些之前未被回收的内存空间。

2.7. G1 回收器:区域化分代式

上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态,此时应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为此,对实时垃圾收集算法的研究逐渐产生了增量收集(Incremental Collecting)的方式,这也是G1垃圾收集器的基本原理。

2.7.1 增量收集算法

这种方式的基本思想是:如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成。总的来说 ,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

这种方式的优点是由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。缺点也很明显,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

一般来说,在相同条件下,堆空间越大,一次 GC时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收,这样的好处是可以控制一次回收多少个小区间,这也是G1垃圾回收器的基本原理。

既然我们已经有了前面几个强大的 GC,为什么还要发布 Garbage First(G1)?原因就在于应用程序所应对的业务越来越庞大、越来越复杂,而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。G1(Garbage-First)垃圾回收器是在 Java7 update4 之后引入的一个新的垃圾回收器。与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。官方给 G1 设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,担当起“全功能收集器”的重任与期望。

为什么名字叫 Garbage First(G1)呢?因为 G1 是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的 Region 来表示 Eden、幸存者 0 区,幸存者 1 区,老年代等。

G1 GC 有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给 G1 一个名字:垃圾优先(Garbage First)。G1是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐量的性能特征。所所以该垃圾收集器是JDK9 以后的默认垃圾回收器,取代了 CMS 回收器以及 Parallel+Parallel Old 组合,被 Oracle 官方称为“全功能的垃圾收集器”。

与其他 GC 收集器相比,G1 使用了全新的分区算法,其特点有:

1.更好的并发和并行

  • 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力,此时用户线程 STW。

  • 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。

2.改进的分代收集

  • 从分代上看,G1 依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。但从堆的结构上看,它不要求整个 Eden 区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。

  • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。

  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

3.空间整合更好

  • CMS:“标记-清除”算法、内存碎片、若干次 GC后进行一次碎片整理

  • G1 将内存划分为一个个的 region。内存的回收是以 region 作为基本单位的。Region 之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。尤其是当 Java 堆非常大的时候,G1 的优势更加明显。

4.可预测的停顿时间模型

(即:软实时 soft real-time),这是 G1 相对于 CMS 的另一大优势,G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。G1收集器之所以能建立可预测的停顿时间模型,还是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX: MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些 Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

5.分区 Region:化整为零

使用 G1 收集器时,它将整个 Java 堆划分成约 2048 个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32MB 之间,且为 2 的 N 次幂,即 1MB,2MB,4MB,8MB,16MB,32MB。可以通过-XX:G1HeapRegionSize设定。所有的 Region 大小相同,且在 JVM 生命周期内不会被改变

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。通过 Region 的动态分配方式实现逻辑上的连续。

 一个 region 有可能属于 Eden,Survivor 或者 Old/Tenured 内存区域。但是一个 region 只可能属于一个角色。图中的 E 表示该 region 属于 Eden 内存区域,S 表示属于 survivor 内存区域,O 表示属于 Old 内存区域。图中空白的表示未使用的内存空间。

G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的 H 块。主要用于存储大对象,如果超过 1.5 个 region,就放到 H。

设置 H 的原因:对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的 H 区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。G1 的大多数行为都把 H 区作为老年代的一部分来看待。

G1 收集器的适用场景:面向服务端应用,针对具有大内存、多处理器的机器。最主要的应用是需要低 GC 延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约 6GB 或更大时,可预测的暂停时间可以低于 0.5 秒;(G1 通过每次只清理一部分而不是全部的 Region 的增量式清理来保证每次 GC 停顿时间不会过长)。

用来替换掉 JDK1.5 中的 CMS 收集器;在下面的情况时,使用 G1 可能比 CMS 好:

  • 超过 50%的 Java 堆被活动数据占用;

  • 对象分配频率或年代提升频率变化很大;

  • GC 停顿时间过长(长于 0.5 至 1 秒);

HotSpot 垃圾收集器里,除了 G1 以外,其他的垃圾收集器使用内置的 JVM 线程执行 GC 的多线程操作,而 G1 GC 可以采用应用线程承担后台运行的 GC 工作,即当 JVM 的 GC 线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

本小节最后再思考一个问题:对象被不同区域引用了怎么办

一个 Region 不可能是孤立的,一个 Region 中的对象可能被其他任意 Region 中对象引用,判断对象存活时,是否需要扫描整个 Java 堆才能保证准确?很明显这样效率很低。

事实上,无论 G1 还有其他分代收集器,JVM 都是使用 Remembered Set 来避免全局扫描。每个 Region 都有一个对应的 Remembered Set。每次 Reference 类型数据写操作时,都会产生一个 Write Barrier 暂时中断操作,然后检查将要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象)。如果不同,通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 Remembered Set 中。当进行垃圾收集时,在 GC 根节点的枚举范围加入 Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

2.7.2. G1 垃圾回收器的回收过程

运作过程大致可划分为以下四个步骤:

  1. 初始标记(Initial Marking):这个与其他垃圾回收器类似,仅仅只是标记一下GC Roots能直接关联到的对象,并且修改特定指针的值来保证下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

  2. 并发标记(Concurrent Marking):该过程与CMS类似,从GC Root开始对堆中对象进行可达性分析,递归扫描整个Region空间里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理快照记录下的在并发时有引用变动的对象。

  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的快照记录。

  4. 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

 回收可选的过程:Full GC,G1 的初衷就是要避免 Full GC 的出现。但是如果上述方式不能正常工作,G1 会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。要避免 Full GC 的发生,一旦发生需要进行调整。什么时候会发生 Full GC 呢?比如堆内存太小,当 G1 在复制存活对象的时候没有空的内存分段可用,则会回退到 Full GC,这种情况可以通过增大内存解决。

导致 G1 Full GC 的原因可能有两个:回收的时候没有足够的 to-space 来存放晋升的对象;并发处理过程完成之前空间耗尽。

回收阶段其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到 G1 只是回一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器(即 ZGC)中。另外,还考虑到 G1 不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

2.8. 垃圾回收器总结

2.8.1. 7 种经典垃圾回收器总结

截止 JDK1.8,一共有 7 款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

垃圾收集器分类作用位置使用算法特点适用场景
Serial串行运行作用于新生代复制算法响应速度优先适用于单 CPU 环境下的 client 模式
ParNew并行运行作用于新生代复制算法响应速度优先多 CPU 环境 Server 模式下与 CMS 配合使用
Parallel并行运行以上是关于一文搞定垃圾回收器的主要内容,如果未能解决你的问题,请参考以下文章

一文搞定垃圾回收的三色标记法

一文搞定垃圾回收的三色标记法

《深入理解JAVA虚拟机》垃圾回收时为什么会停顿

heap堆算法的使用分析

JVM中的垃圾回收器及垃圾收集算法描述

垃圾回收器为什么必须要停顿下?

(c)2006-2024 SYSTEM All Rights Reserved IT常识