JVM:GC垃圾回收

在Java 中,JVM 会对内存进行自动分配与回收,其中 GC (Garbage Collection) 的主要作用就是清除不再使用的对象,自动释放内存

要搞清楚JVM垃圾回收机制,就需要搞清楚一下三件事:

  1. 哪些垃圾需要回收
  2. 垃圾回收算法
  3. 常见的垃圾回收器及其工作原理

1. 哪些内存需要被回收?

1.1 GC的目标区域

JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、方法区、堆。根据是否线程私有可以分为两类:

其中程序计数器、虚拟机栈、本地方法栈是随线程创建而生、线程销毁而亡,因此这几个区域的内存分配和回收都具备确定性,因为方法结束或者线程终止,内存自然就跟着回收了。

而方法区和堆的内存分配和回收是动态的,正是垃圾回收器需要关注的区域。

1.2 如何判定哪些对象是“垃圾”?

对于如何判断对象是否可以回收,有两种比较经典的判断策略。

  • 引用计数算法

  • 可达性分析算法

  1. 引用计数算法

在对象头维护了一个counter计数器,对象被引用一次,计数器+1;若删除该引用或引用失效,计数器-1。当计数器为0时,则认为该对象是可以被回收的。

弊端:无法解决循环引用的问题,发生循环应用的引用计数器永远不会为0,则意味着这些对象永远不会被回收,就会导致内存泄漏问题。因此主流的Java虚拟机都没有采用引用计数算法来判断对象是否存活。

循环引用

  1. 可达性分析算法

首先确定一系列的**根对象 (GC Roots),并根对象出发根据对象之间的引用关系搜索出一条引用链 (Reference Chain)**,能被引用链搜索到的对象就判定为存活对象,不在引用链的对象则可对其进行回收。

JVM中,GC Roots包括:

  • 虚拟机栈中引用的对象(方法内的局部变量、参数等)
  • 方法区中静态属性引用的对象(static声明的字段)
  • 方法区中常量引用的对象(final声明的字段)
  • 本地方法栈中引用的对象(native方法)
  • Java虚拟机内部的引用

1.3 Java中的引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”相关。在Java中,引用又分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这四种引用强度依次逐渐减小。

四种引用

  1. 强引用(Strong Reference)

在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用存在,垃圾收集器永远不会回收被引用的对象。

  1. 软引用(Soft Reference)

软引用是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

  1. 弱引用(Weak Reference)

弱引用的强度比软引用更弱一些。当 JVM 进行垃圾回收时,无论内存是否充足,都会回收只被弱引用关联的对象。

  1. 虚引用(Phantom Reference)

虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

无论引用计数算法还是可达性分析算法都是基于强引用而言的。

1.4 对象死亡(被回收)前的最后一次挣扎

即使在可达性分析算法中不可达的对象,也并非“非死不可”,这时候它们处于“缓刑”阶段,要真正宣告一个对象死亡,至少还要经历两次标记过程:

  • 第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;
  • 第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记。

第二次标记成功的对象将真的会被回收,如果对象在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

1.5 方法区的内存如何判断是否需要回收

方法区主要回收的内容有:废弃常量和无用的类。对于「废弃常量」也可以通过可达性分析来判断,对于「无用的类」则需要同时满足以下三个条件:

  • 该类的所有实例都被回收,即Java堆中不存在该类的任何实例;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类。

2. GC(垃圾回收)算法

常见的GC算法包括:

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

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

标记-清除算法是最基本的一种垃圾回收算法,其垃圾回收过程可以分为两步:

  1. 标记

上图中灰色对象是不可达对象,标记为需要被回收的对象。

  1. 清除

将标记为灰色的部分进行清除。

注意:所谓的清除操作并不是真正把整个内存的字节进行清零,只是把空闲对象的起始和结束地址记录下来并放入空闲列表中,表示这段内存是空闲的。

标记-清除算法优缺点:

  • 优点:速度快,只需要做个标记就能知道哪一块需要被回收。
  • 缺点: 会产生内存碎片。

内存碎片带来的问题:空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

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

与标记-清除算法不同,标记-整理算法是移动式的,它会让所有存活的对象都向空间一端移动,然后清除到边界以外的内存。

标记-整理算法的优缺点:

  • 优点:不会产生空间碎片,完美解决了标记-清除算法带来的空间碎片问题。
  • 缺点:标记-整理算法是在标记-清除算法的基础上增加了对象的移动,在整理阶段,由于移动了可用对象,需要去更新引用,从而导致效率较低。

2.3 复制算法

将内存空间分为大小相等的两个内存块,在垃圾回收时将正在使用内存块中存活的对象复制到另一个未被使用的内存块,然后清除正在使用内存块的所有对象。然后交换两个内存块的角色,完成垃圾回收过程。

复制算法的优缺点:

  • 优点:复制算法不需要对象移动的操作,效率较高;并且不存在内存碎片的问题。
  • 缺点:需要两倍的内存空间。

2.4 分代收集算法

分代收集算法是目前大部分JVM垃圾回收器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个区域,针对不同区域采用不同的垃圾回收算法。

一般情况下将Java堆内存划分为「新生代」和「老年代」,新生代中大部分都是“朝生夕死”、存活率低的对象,而老年代中大部分都是存活时间长、存活率高的对象,这样就可以根据各个年代的特点采用最合适的回收算法。

Java堆中不同内存块选用的垃圾回收算法:

  • 新生代:复制算法。新生代中,每次垃圾回收的时候都会有大量对象死亡,只有少量存活,因此选用复制算法,只需要付出少量存活对象复制的成本就可以完成垃圾回收,并且新生代内存较小,双倍空间成本也是可以接受的。
  • 老年代:标记-清除算法、标记-整理算法。老年代中因为对象存活率高,且没有额外空间对它进行分配担保,,就必须使用 “标记—清理” 或者 “标记—整理” 算法 来进行回收。

2.5 GC触发

GC触发分类:

  • Young GC/Minor GC:指目标只是新生代的垃圾收集。
  • Major GC:指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
  • Full GC:指目标是整个堆和方法区的垃圾收集。

创建对象时GC触发的流程:

Full GC是对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以执行时间会较长,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。

触发Full GC的条件:

a) 年老代(Tenured)被写满;

b) 持久代(Perm)被写满;

c) System.gc()被显式调用;

d) 上一次GC之后Heap的各域分配策略动态变化;

3. 常见的垃圾收集器

Jdk1.8版本HotSpot虚拟机所包含的垃圾收集器:

3.1 串行收集器(Serial, Serial Old)

Serial 翻译过来可以理解成单线程。单线程收集器有Serial 和 Serial Old 两种,它们的唯一区别就是:Serial 工作在新生代,使用“复制”算法;Serial Old 工作在老年代,使用“标志-整理”算法

串行收集器收集器是最经典、最基础,也是最好理解的。它们的特点就是单线程运行及独占式运行,因此会带来很不好的用户体验。虽然它的收集方式对程序的运行并不友好,但由于它的单线程执行特性,应用于单个CPU硬件平台的性能可以超过其他的并行或并发处理器。

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

STW 会带给用户恶劣的体验,所以从JDK 1.3开始,一直到现在最新的JDK 13,HotSpot虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至现在垃圾收集器的最前沿成果Shenandoah和ZGC等。

虽然新的收集器很多,但是串行收集器仍有其适合的场景。迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效。对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的,单线程没有线程交互开销。(这里实际上也是一个时间换空间的概念)

3.2 并行收集器(ParNew, Parallel Scavenge, Parallel Old)

并行收集器是多线程的收集器,在多核CPU下能够很好的提高收集性能。

并行收集器包含:

  • ParNew收集器:就是 Serial收集器的多线程版本,基于“复制”算法,其他方面完全一样,在JDK9之后差不多退出历史舞台,只能配合CMS在JVM中发挥作用。
  • Parallel Scavenge收集器:和 ParNew收集器类似,基于“复制”算法,但此收集器更关注可控制的吞吐量,并且能够通过-XX:+UseAdaptiveSizePolicy打开垃圾收集自适应调节策略的开关。
  • Parallel Old收集器:就是 Parallel Scavenge 收集器的老年代版本,基于“标记-整理”算法实现。

3.3 Concurrent Mark and Sweep(CMS)收集器

CMS(Concurrent Mark Sweep,并发标记清除) 收集器是以获取最短回收停顿时间为目标的收集器,它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。

特点:基于标记-清除算法实现。并发收集、低停顿。

从名字就可以知道,CMS是基于“标记-清除”算法实现的。它的工作流程如下:

整个过程可以分为四步:

  1. 初始标记:主要是标记 GC Root 开始的下级(注:仅下一级)对象,这个过程会 STW,但是跟 GC Root 直接关联的下级对象不会很多,因此这个过程其实很快。

  2. 并发标记:根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞没有 STW

  3. 重新标记:顾名思义,就是要再标记一次。为啥还要再标记一次?因为第 2 步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。这个过程会有STW,但是时间不会很长。

  4. 并发清除:GC线程和用户线程并发执行,清除已标记的垃圾,没有STW。由于最后一步并发清除时,并不阻塞其它线程,所以还有一个副作用,在清理的过程中,仍然可能会有新垃圾对象产生,只能等到下一轮 GC,才会被清理掉

提问:前面提到“标记-清除”算法会产生空间碎片,为什么CMS还要使用标记-清除算法,而不使用标记-整理算法?

答:如果换成“标记 - 整理”算法,把垃圾清理后,剩下的对象也顺便整理,会导致这些对象的内存地址发生变化,而此时其他线程还在工作,如果对象引用发生改变,将会带来巨大的问题。

3.4 G1(Garbage First)收集器

一款面向服务端应用的垃圾收集器。JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。

特点如下:

并发与并行:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间。部分收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让Java程序继续运行。

分代收集:G1能够独自管理整个Java堆,并且采用不同的方式去处理新创建的对象已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

空间整合:G1运作期间不会产生空间碎片,收集后能提供规整的可用内存。

可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。

鉴于 CMS 的一些不足之外,比如: 老年代内存碎片化,STW 时间虽然已经改善了很多,但是仍然有提升空间。G1 就横空出世了,它对于堆区的内存划思路很新颖,有点算法中分治法“分而治之”的味道。下面我们来讨论G1收集器的工作原理!

G1 将连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂

Region中还有一类特殊的Humongous区域,专门用来存储大对象G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中。Humongous,简称 H 区,是专用于存放超大对象的区域,通常 >= 1/2 Region Size,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

在G1收集器中,所有的垃圾回收,都是基于 region 的。G1根据各个Region回收所获得的空间大小以及回收所需时间等指标在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大(垃圾)的Region,从而可以有计划地避免在整个Java堆中进行全区域的垃圾收集。这也是 “Garbage First” 得名的由来。

G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次GC。

提问:一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?

这里就需要引入 Remembered Set 的概念了。

答案是不需要,每个 Region 都有一个 Remembered Set (记忆集)用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 即可防止对整个堆内存进行遍历

再提一个概念,Collection Set :简称 CSet,记录了等待回收的 Region 集合,GC 时这些 Region 中的对象会被回收(copied or moved)

G1工作流程:

如果不计算维护 Remembered Set 的操作,G1 收集器的工作过程分为以下几个步骤:

  • 初始标记:Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。
  • 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢。
  • 最终标记:Stop The World,使用多条标记线程并发执行。
  • 筛选回收:回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行。(还会更新Region的统计数据,对各个Region的回收价值和成本进行排序)

从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量

G1 的 Minor GC/Young GC

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

G1 的 Mixed GC

当越来越多的对象晋升到老年代Old Region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,是收集整个新生代以及部分老年代的垃圾收集。除了回收整个Young Region,还会回收一部分的Old Region ,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些Old Region 进行收集,从而可以对垃圾回收的耗时时间进行控制。

Mixed GC的整个子任务和YGC完全一样,只是回收的范围不一样。

注:G1 一般来说是没有FGC的概念的。因为它本身不提供FGC的功能。

如果 Mixed GC 仍然效果不理想,跟不上新对象分配内存的需求,会使用 Serial Old GC 进行 Full GC强制收集整个 Heap。