深入理解Java虚拟机之垃圾回收篇

垃圾回收简介

​ Java 会对内存进行自动分配与回收管理,使上层业务更加安全,方便地使用内存实现程序逻辑。在不同的 JVM 实现及不同的回收机制中,堆内存的划分方式是不一样的。

​ 简要地介绍下垃圾回收(Garbage Collection,GC)。垃圾回收的主要目的是清除掉没有引用/不再使用的对象,自动释放内存。在了解垃圾回收算法之前,首先我们先要理解对象是怎么定义可以用被回收的。

引用计数算法

​ 那么,GC 判断对象可以回收的依据是什么呢?有一种判断对象是否存活的算法是引用计数算法,该算法的原理是:给每一个对象分配一个引用计数器,每当有一个地方引用它时,计数器值就 +1 ;当引用失效时,计数器值就 -1 ;所以当对象的计数器值为 0 时,就可以判定该对象是可以被回收。引用计数法实现起来相对比较简单,判定逻辑也不复杂。但是主流的 Java 虚拟机里面并没有选用引用计数法来管理内存,因为该算法有个很大的痛点就是难以解决对象之间的循环引用。举个例子,现在有两个对象 objA 和 objB 都声明了 instance 字段,代码如下

Object objA = new Object();
Object objB = new Object();
...
objA.instance = objB;
objB.instance = objA;

​ 除此之外,objA 和 objB 没有任何的引用,也就是说这两个对象除了彼此之外,再也不会被访问,但就是因为它们俩互相引用着对方,导致它们的引用计数器不可能为0,引用计数算法也无法通知 GC 将这俩对象进行回收。

可达性分析算法

​ 所以目前主流虚拟机采用最多的回收算法是可达性分析算法来判断对象是否可以被回收,在 Java、C# 中都有大量的实现场景,JVM 也正是为了判断对象存活,引入了GC Roots,下面简要地介绍该算法的思想:通过一系列的称为“ GC Roots ”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用的链相连时(等同于对象与 GC Roots 之间没有直接或间接的引用关系),则可证明此对象是不可用的,可以通知 GC 收集器回收。那么什么对象可以作为GC Roots呢?比如:类静态属性中引用的对象、常量引用的对象、虚拟机栈中引用的对象、本地方法栈中引用的对象等等都可以充当 GC Roots 的角色。下面通过绘图的形式,更好地理解可达性分析算法的思想,对象 object 5、object 6、object7 虽然互相有关联,但是它们到 GC Roots 是不可达的,所有被判定为可回收对象。

垃圾回收算法

前面我们了解了如何去判断对象是否存活,下面我们认识下垃圾回收算法的基本思想。

标记-清除算法

算法思想:该算法分为两个阶段,分别是标记清除阶段,从每个 GC Roots 开始,依次标记有引用关系的对象,最后将没有标记的对象清除。

​ 该算法主要两点不足之处,一个是效率问题,无论是标记还是清除,他们的效率都不是很高;另一个是空间问题,这种算法会带来大量的空间碎片,如果程序在运行过程当中,产生了一个很大的对象,需要较大的连续空间来分配该对象时,往往会出现老年代还有很大内存空间剩余,但是却无法找到足够的连续内存空间,不得已去触发另一次垃圾收集动作(FGC)。

标记-整理算法

算法思想:标记过程跟标记-清除算法一样,然后将存活的对象集中整理到内存空间的一端,形成一片连续的已使用的区域,最后再将该区域外的对象全部清除,这样就避免了连续碎片的问题。

复制算法(Mark-Copy)

算法思想:为了能够并行地标记和整理,将可用内存按容量划分成大小相等的两块,每次只激活其中一块。这样,当其中一块的内存用完了,垃圾回收时只需把存活的对象复制到另一块未被激活的空间上,最后在清除掉除了未激活空间之外,其他占用内存空间的对象全部清除。

​ 比如将Java堆内存空间分为较大的 Eden 和两块较小的 Survivor ,每次只使用 Eden 区和 Survivor 区其中的一块,当垃圾回收时,就将 Eden 和 Survivor 区中存活的对象复制到另一块未被使用的 Survivor 区,再清除掉 Eden 和用过的一块 Survivor 区空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1 ,也就是每次新生代中可用内存空间为整个新生代容量的 90%,只有 10% 的内存会被浪费。复制算法现在就作为 YGC 算法进行新生代的垃圾回收

​ 这样做的好处是每次只需要对整个空间一半的区域块进行内存回收,内存分配时也就不用考虑内存碎片、较少了内存空间的浪费等复杂情况,只要移动堆顶指针,按顺序分配内存即可。

分代收集算法

​ 这种算法并没有新的思想,只是根据对象存活周期的不同将内存划分成几块。一般是 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就采用复制算法,只需要将存活的对象复制到未被使用的区域块,效率很高;老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清除标记-整理算法进行回收。

垃圾回收器

垃圾回收器(Garbage Collector,GC)是实现垃圾回收算法并应用在 JVM 环境中的内存管理模块。

Serial 回收器/Serial Old 回收器

​ Serial 回收器是早期(JDK1.3.1 之前)虚拟机新生代回收的唯一选择,是一个主要应用于 YGC 的垃圾回收器,采用的垃圾回收算法是标记-整理算法,通过串行单线程的方式完成任务,串行就意味着每次只会使用一个 CPU 或一条回收线程去完成垃圾回收工作,并且在进行垃圾回收时,不允许其他线程与它一起工作,必须要停掉其他所有的工作线程,直至收集结束。这种情况就称为:“Stop The World” 简称 STW ,即垃圾回收的某个阶段会暂停整个应用程序的运行。Serial 回收流程图如下:

​ FGC 的执行时间较长,如果频繁引起 FGC 会严重影响应用程序的性能。此外,还有一种回收器叫 Serial Old回收器 ,它是 Serial 回收器的老年代版本,所以它也一样是单线程回收器,采用的也是标记-整理算法。

​ 即使是这样,与其他回收器的单线程比,Serial 回收器也是有着优于它们的地方,对于限定单个 CPU 的环境来说,Serial 回收器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程回收效率。不论是 Serial 还是 Serial Old 回收器,它们的主要意义是在于给 Client 模式下的虚拟机使用。

CMS回收器

​ CMS(Concurrent Mark Sweep)回收器是一种以获取最短回收停顿时间为目标,是目前比较流行的垃圾回收器。对于Java 编程语言实现互联网或者 B/S 系统的服务端,并且十分重视服务的响应速度,希望停顿时间越短越好,方便给予用户更好的使用体验,采取 CMS 回收器的策略就十分符合这种应用场景。

​ CMS 回收器是基于标记-清除算法实现的,整个垃圾回收工作步骤分为4个步骤:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

​ 对于1、3步骤,也就是初始标记重新标记阶段还是会引发 STW(Stop The World),而2、4步骤的并发标记并发清除两个阶段可以和应用程序并发执行,所以也属于比较耗时的操作,但是无须担心 CMS 回收器会影响到应用程序的正常运行。

初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快;并发标记阶段就是进行 GC Roots Tracing 的过程;重新标记阶段是为了修正并发标记期间,因用户程序继续运行而可能会导致标记产生变动的那一部分对象,进行标记记录,这一阶段的停顿时间一般会比初始标记阶段长点,但不会比并发标记阶段的时间长。

​ 在垃圾回收的4个步骤中,并发标记并发清除过程中所耗时最长,并且它们是可以跟用户的线程在同一时间工作,所以从时间上来看,CMS 回收器的内存回收过程和用户线程是一起并发执行的。CMS回收流程图大致如下:

​ 所以 CMS 回收器是一款十分优秀的收集器,有着并发收集低停顿的优点,所以也称为并发低停顿收集器,尽管如此,CMS 还是存在不足之处:

  1. CMS 回收器对CPU资源十分敏感。虽然说,在并发阶段,CMS 回收器可以跟用户线程并发执行,但还是会占用一部分的 CPU 资源,从而导致应用程序响应变慢,系统压力过高,导致系统最终的吞吐量降低。

  2. CMS 回收器无法处理浮动垃圾,可能会导致出现 “Concurrent Mode Failure” 失败而导致另一次 FGC 的产生。

  3. CMS 回收器执行完垃圾回收后,会产生大量的空间碎片。这是由于 CMS 回收器采取的标记-清除算法所带来的影响(具体可以往上看标记-清除算法部分)。为了解决这一问题,CMS 回收器可以通过配置 -XX:+UseCMSCompactAtFullCollection 开关参数(默认是开启的)。用于在 CMS 回收器顶不住要进行 FGC 的时候,开启内存碎片的合并整理过程,解决了空间碎片问题,但由于空间整理期间是无法并发的,无法并发就会引起 STW 的情况。但是好在 CMS 回收器的设计者为了减少STW次数,允许通过配置 -XX:+CMSFullGCsBeforeCompaction=n 参数,该参数 n 意味着,在执行了 n 次 FGC 之后,JVM 才能在老年代执行空间碎片整理;参数默认值为 0 ,则表示每次执行完 FGC 之后,都要进行空间碎片整理。

G1回收器

​ Hotspot 在 JDK7 中推出了新一代 G1 (Garbage-First)垃圾回收,通过 -XX:+UseG1GC 参数启用。在 JDK11 中,已经把 G1 设为默认垃圾回收器,可通过 jstat 命令查看垃圾回收情况。和 CMS 相比,G1 具备压缩功能,能避免碎片问题。并且 G1 的暂停问题更加可控,总体上性能还是很不错的。

​ 在 G1 之前,其他回收器进行垃圾收集时,收集的范围都是整个新生代或老年代,而 G1 是 将 Java 堆空间分割成了若干相同大小的独立区域,即 region ,其中包括 Eden 、Survivor 、Old 、Humongous 四种类型。其中, Humongous 是特殊的 Old 类型,专门放置大型对象。图中可以看出,新生代和老年代不再是物理隔离,它们都是一部分 Region(不再连续)的集合。

​ G1 回收器之所以能够建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的时间),在后台维护起一个优先级别列表;所以它每次只需要根据允许收集的时间,优先收集价值回收率最高的 Region(这也是 Garbage-First 名称的由来)。这种使用 Region 方式划分成若干份大小相同的内存空间,以及有优先级别根据的区域回收方式,保证了G1 回收器在有限的时间内提高了回收效率。

​ 与其他的 GC 回收器比,G1 回收器有着以下的特点:

  • 并行与并发:G1 能充分利用多核多CPU环境下的硬件优势,使用多个CPU来缩短 STW 停顿的时间;部分回收器需要停顿其他 Java 线程执行的 GC 动作,而 G1 回收器可以与 Java 程序并发执行。
  • 回收算法:G1 采用的是 Mark-Copy复制算法),有很好的空间整合能力,在 G1 执行期间不会产生大量的空间碎片,并且回收完成之后能够提供规整的可用内存,有利于程序长时间运行。
  • 可预测的停顿:能够尽可能快地在指定时间内完成垃圾回收任务,能够让使用者明确指定在一个长度在 M 毫秒的时间片段内,消耗在垃圾收集上的时间不能超过 N 毫秒(M > N)。

参考资料《深入理解Java虚拟机》、《码出高效》