Java-JVM虚拟机内存垃圾回收机制gc入门:引用类型,对象标记算法,回收算法,常见的 garbage collector

2020/02/13 Java 共 4870 字,约 14 分钟

GC的优缺点

Java对比C++最大的优势是内存自动回收,无需手动删除对象,因此需要垃圾回收器(GC)来确认哪些对象是无用的对象,从而可以回收这些对象所占用的内存空间。

但GC(垃圾回收/garbage collector)也不是完美的,缺点就是如果会在程序运行时产生暂停;一般来说垃圾回收算法越好,暂停时间越短暂。

GC的过程分为标记垃圾对象和垃圾回收2步。

引用的四种类型

先介绍引用的4种类型

强引用 StrongReference/FinalReference

new的对象是强引用。

具有强引用的对象不会被GC;
即便内存空间不足,JVM宁愿抛出OutOfMemoryError使程序异常终止,也不会随意回收具有强引用的对象。

软引用 SoftReference

只具有软引用的对象,会在内存空间不足的时候被GC,如果回收之后内存仍不足,才会抛出OOM异常;
软引用常用于描述有用但并非必需的对象,比如实现内存敏感的高速缓存。

弱引用WeakReference

被弱引用关联的对象只能存活到发生下一次垃圾回收之前,也就是说当发生GC时,无论当前内存是否足够,都会被回收掉。

虚引用 PhantomReference

仅持有虚引用的对象,在任何时候都可能被GC;
常用于跟踪对象被GC回收的活动;
必须和引用队列 (ReferenceQueue)联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

对象标记算法

在内存中存活着很多对象, GC 之前需要准确将这些对象标记出来,分为存活对象与垃圾对象。这个过程一旦少标记,那就只能等待下次 GC 标记,再回收,这样将会影响 GC 效率。

引用计数法

引用计数法通过在对象中分配一个字段,用来存储该对象引用计数。一旦该对象被其他对象引用,计数加 1。如果这个引用失效,计数减 1。当引用计数值为 0 时,代表这个对象已不再被引用,可以被回收。

优点:实现简单,过程高效,具有实时性

缺点:需要额外资源维护,而且无法解决对象循环引用问题

Java没有采用引用计数,但 Python 的垃圾回收采用的就是引用计数。Python 解决对象循环引用的方法是采用标记-清除算法。

可达性分析法

这个算法的原理也很简单,就是维护一系列的『GC ROOT』的对象作为我们的根,从这些根搜索,走过的路径官方话叫做引用链(Reference Chain),当一个对象到根节点没有任何引用,就意味着这个对象是不可用的,也就是我们俗称的垃圾

注意这里是 引用 ,而不是对象。

可以被当做 GC Roots 活跃引用包括但不限于以下引用:

  • 方法中局部变量
  • 静态变量,常量
  • JNI handles

需要注意的是,在可达性分析算法中被判定不可达的对象还未真的判『死刑』,至少要经历两次标记过程:判断对象是否有必要执行finalize()方法;若被判定为有必要执行finalize()方法,之后还会对对象再进行一次筛选,如果对象能在finalize()中重新与引用链上的任何一个对象建立关联,将被移除出“即将回收”的集合。

回收算法

目前主流 GC 算法主要分为四种:

  • 标记-清除算法
  • 复制算法
  • 标记-整理算法
  • 分代收集算法

标记-清除算法(Mark-Sweep)

这是一个最为基础也是最容易实现的算法,主要实现步骤分为两步:标记,清除。

标记:通过上述 GC Roots 标记出可达对象。 清除:清理 未标记对象 。

经过这个算法回收之后,虽然堆空间被清理出来,但是也产生很多 空间碎片 。这就会导致一个新对象根据堆剩余容量计算,看起来是可以分配,但是实际分配过程,由于没有连续内存,导致虚拟机感知到内存不足,又不得不提前再次触发 GC 。

另外这个算法还有一个不足:标记与清除效率比较低。这就竟会导致 GC 占用时间过长,影响正常程序使用。

复制算法

为了解决上述效率问题,诞生复制算法。这个算法将可用内存分为两块,每次只使用其中一块,当这一块内存使用完毕,触发 GC ,将会把存活的对象依次复制到另外一块上,然后再把已使用过的内存一次性清理。

这个算法每次只需要操作一半内存, GC 回收之后也不存在任何空间碎片,新对象内存分配时只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但是这个算法闲置一半内存空间,空间利用效率不高。

复制算法以空间换时间,实际可使用的内存空间缩小为原来的一半

JVM虚拟机内存中的新生代存在特性:大批对象死去,只有少量存活。因此新生代使用『复制算法』,只需复制少量存活对象即可。

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

标记-整理算法可以说是标记-清除算法的改进版,改进了清除导致的空间碎片问题。标记后移动对象到一起,解决碎片问题。

虽然标记-整理算法解决了标记-清除算法空间碎片问题,也完整利用整个内存空间,但是这个算法问题效率并不高。相较于标记-清除算法,标记-整理算法多增加整理这一步,所以该算法效率还低于标记-清除算法。

一般情况下,老年代会选择标记-整理算法

分代收集算法

从上面三种 GC 算法可以看到,并没有一种空间与时间效率都是比较完美的算法,所以只能做的是综合利用各种算法特点将其作用到不用的内存区域。

js的V8引擎,Python, HotSpot 都采用了分代收集算法。

目前JVM虚拟机根据对象存活周期不同划分内存区域,一般分为新生代,老年代。新对象一般情况都会优先分配在新生代,新生代对象若存活时间大于一定阈值之后,将会移到至老年代。新生代的对象都是存活时间短,老年代的对象存活时间长。

新生代每次 GC 之后都可以回收大批量对象,所以比较适合复制算法,只需要付出少量复制存活对象的成本。这里内存划分并没有按照 1:1 划分,默认将会按照 8:1:1 划分成 Eden 与两块 Survivor 空间。每次使用 Eden 与一块 Survivor 空间,这样我们只是闲置 10% 内存空间。不过我们每次回收并不能保证存活对象小于 10%,在这种情况下就需要依靠老年代的内存分配担保。当 Survivor 空间并不能保存剩余存活对象,就将这些对象通过分配担保进制移动至老年代。

老年代中对象存活率将会特别高,且没有额外空间进行分配担保,所以并不适合复制算法,所以需要使用标记-清除或标记-整理算法。

大多数情况下,新的对象都分配在Eden区,当 Eden 区没有空间进行分配时,将进行一次 Minor GC,清理 Eden 区中的无用对象。清理后,Eden 和 From Survivor 中的存活对象如果小于To Survivor 的可用空间则进入To Survivor,否则直接进入老年代;Eden 和 From Survivor 中还存活且能够进入 To Survivor 的对象年龄增加 1 岁(虚拟机为每个对象定义了一个年龄计数器,每执行一次 Minor GC 年龄加 1),当存活对象的年龄到达一定程度(默认 15 岁)后进入老年代,可以通过 -XX:MaxTenuringThreshold 来设置年龄的值。

当进行了 Minor GC 后,Eden 还不足以为新对象分配空间(那这个新对象肯定很大),新对象直接进入老年代。

占 To Survivor 空间一半以上且年龄相等的对象,大于等于该年龄的对象直接进入老年代,比如 Survivor 空间是 10M,有几个年龄为 4 的对象占用总空间已经超过 5M,则年龄大于等于 4 的对象都直接进入老年代,不需要等到 MaxTenuringThreshold 指定的岁数。

在进行 Minor GC 之前,会判断老年代最大连续可用空间是否大于新生代所有对象总空间,如果大于,说明 Minor GC 是安全的,否则会判断是否允许担保失败,如果允许,判断老年代最大连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则执行 Minor GC,否则执行 Full GC。

大对象(需要大量连续内存的对象)例如很长的数组,会直接进入老年代,如果老年代没有足够的连续大空间来存放,则会进行 Full GC。

常见的 GC collector

HotSpot JVMOracle Java使用的JVM,也就是内存管理等都由HotSpot负责,而垃圾回收算法的选择也是JVM中的一部分,因此你可以指定JVM的GC算法。
比如说在 java 8中的命令行参数可以指定如下参数:

-XX:+UseConcMarkSweepGC # 允许将CMS垃圾收集器用于旧版本。当吞吐量(-XX:+UseParallelGC)垃圾收集器无法满足应用程序延迟要求时,Oracle建议您使用CMS垃圾收集器。G1垃圾收集器(-XX:+UseG1GC)是另一种选择。
-XX:+UseG1GC #启用垃圾优先(G1)垃圾收集器的使用。它是一种服务器样式的垃圾收集器,适用于具有大量RAM的多处理器计算机。它极有可能满足GC暂停时间目标,同时保持良好的吞吐量。建议将G1收集器用于需要大堆(大小约为6 GB或更大)且GC延迟要求有限(稳定且可预测的暂停时间低于0.5秒)的应用程序。默认情况下,此选项是禁用的,并且将根据计算机的配置和JVM的类型自动选择收集器。
-XX:+UseParallelGC #启用并行清除垃圾收集器(也称为吞吐量收集器)的使用,以利用多个处理器来提高应用程序的性能。
-XX:+UseParNewGC #允许在新生代中使用并行线程进行收集。默认情况下,此选项是禁用的。设置-XX:+UseConcMarkSweepGC选项后,它将自动启用。使用-XX:+UseParNewGC不带选项-XX:+UseConcMarkSweepGC的选择是在JDK 8弃用。
-XX:+UseSerialGC #启用串行垃圾收集器的使用。对于不需要垃圾回收具有任何特殊功能的小型和简单应用程序,这通常是最佳选择。默认情况下,此选项是禁用的,并且将根据计算机的配置和JVM的类型自动选择收集器。

从 Java 9开始,默认的垃圾收集器(garbage collector (GC) )从Parallel GC换成G1,见JEP 248,原因是对大部分普通用户来说,限制GC暂停时间比最大化吞吐量更为重要。

在 JDK 9之后,CMS collector 被彻底抛弃。

经典 GC(也叫作 STW,Stop-The-World)会在没有可用内存时暂停应用程序线程,回收垃圾,并压缩存活的对象,然后让应用程序继续执行。这种停顿有可能长达几十秒,而且会随着堆的增大而延长。

很多现代 GC(例如 G1)有分代的概念,它们根据对象在垃圾回收过程中存活下来的次数对这些对象进行分代,并针对每一个分代的对象使用不同的回收策略。

而JVM的垃圾回收器在这么多年来经历了多次变革,比如说: G1 成为 Java 9 开始的默认垃圾回收器,Oracle 发布了 ZGC(受 Azul 无停顿回收器 C4 的启发),然后是 Red Hat 开发了 Shenandoah(会加入 jdk 13)。

如果你的应用程序是交互式的(比如一组 API 或一个网站),那么 GC 停顿所造成的影响就会更加明显了。GC 停顿会拖慢应用程序,在外界看来,它就像冻住了一样。在 GC 停顿期间发给服务器的请求会更晚收到响应,根据停顿时间的不同(传统的 GC 停顿有可能达到几十秒),客户端有可能会出现超时。

后记

这篇文章简单的介绍了 对象标记算法,常见的回收算法和常见的 garbage collector,但有更多深入的内容值得探讨。

如:

  • 可达性分析的GC ROOT如何构造与维护
  • G1 collector跟之前旧的CMS collector 相比有哪些优势

参考:

文档信息

Table of Contents