详解Java的垃圾回收机制(GC)

 2023-01-25
原文作者:goldenJet 原文地址:https://juejin.cn/post/6855641747994148877

可以搜索微信公众号【Jet 与编程】查看更多精彩文章

原文发布于自己的博客平台【www.jetchen.cn/gc/


我们知道,程序在运行的时候,为了提高性能,大部分数据都是会加载到内存中进行运算的,有些数据是需要常驻内存中的,但是有些数据,用过之后便不会再需要了,我们称这部分数据为垃圾数据。

为了防止内存被使用完,我们需要将这些垃圾数据进行回收,即需要将这部分内存空间进行释放。不同于 C++ 需要自行释放内存的机制,Java 虚拟机(JVM)提供了一种自动回收内存的机制,这对于我们开发人员来说,再友好不过了。


简介

在做 Java 开发的过程中,我们会不断地创建很多的对象,这些对象数据会占用系统内存,如果得不到有效的管理,内存的占用会越来越多,甚至会出现内存溢出的情况,所以,我们需要进行对内存进行合理地释放,这个时候 GC 就派上大用场的。

本文所介绍的垃圾回收(GC)是由 Java 虚拟机(JVM)垃圾回收器提供的一种对内存回收的一种机制,它一般会在内存空闲或者内存占用过高的时候对那些没有任何引用的对象不定时地进行回收。

不同于 C++ 程序,C++ 是需要开发人员自己分配内存并且进行自行回收内存的,而 Java 程序,内存是托管于 JVM 的,即对象的创建和内存的回收都是由 JVM 自行完成的,开发人员是无权干涉的,只能尽量去优化。

所以由上述讨论我们很容易就会有如下的疑问,下文也会依照这几点疑问来进行深入探讨:

202301011657546701.png

JVM 内存模型

JVM 内存大致分为 线程私有区域线程共享区域 ,并且其主要由5个区域组成,见下图:

202301011657553202.png

由上图可以看出,虚拟机栈、本地方法栈和程序计数器,这三个区域是线程私有的。比如栈帧的生命周期是和线程关联的,即随线程而生,随线程而死。

虚拟机栈其实就是用来描述 Java 方法执行的,所以每个方法执行的时候都会创建一个栈帧,每个栈帧都包含:局部变量、操作数栈、动态链接、方法出口,当方法执行完成之后,对应的栈帧便会出栈。所以它的内存分配是具备确定性的,因此我们并不需要太过关注包括虚拟机栈在内的这几个线程私有区域的内存使用情况。

相反,另外两个线程共享的区域:方法区和堆内存,则是我们需要重点关注的对象。因为这两个区域主要存放对象、数组等不具有确定性的数据,例如创建对象,每个方法运行的过程中创建的对象的数量是不确定的,即占用的内存是不确定的,可能不需要创建对象,也可能会创建很多对象,所以我们需要一套合理的内存管理机制来对这两个区域进行维护,因此,垃圾回收就应运而生了,并且这两个区域也是垃圾回收器进行垃圾回收的最重要的内存区域。

我们再来对堆内存和方法区进行一下划分,因为 JVM 是采用分代回收的算法,即根据对象的生命周期进行区分并进行分代存储和回收,其主要分为年轻代、老年代、持久代,见下图:

202301011657560013.png

堆内存主要由年轻代和老年代组成,而方法区主要存储持久代的数据,详细的细节在下文讲回收算法的时候会细说。

注意:从 JDK 1.8 开始,永久代已经被移除了,取而代之的是元空间(Meta Space),它和服务器的内存相关联,本文暂不赘述。

内存中的垃圾

程序在运行过程中会创建对象,但是当方法执行完成或当这个对象使用完毕之后,它便被定义为了“垃圾”,这时候便需要依靠垃圾回收器去将这块内存区域清理出来,而对于上述“垃圾”的定义,我们需要将它量化成计算机语言,即需要设计一套算法来给垃圾回收器使用,因为毕竟进行垃圾回收的动作是垃圾回收器自动运行并判定的。

判定一个对象是否是“垃圾”,即判定一个对象的存活与否,常见的算法有两种: 引用计数法根搜索算法

引用计数算法(Reference Counting Collector)

一个对象被创建之后,系统会给这个对象初始化一个引用计数器,当这个对象被引用了,则计数器 +1,而当该引用失效后,计数器便 -1,直到计数器为 0,意味着该对象不再被使用了,则可以将其进行回收了。

这种算法其实很好用,判定比较简单,效率也很高,但是却有一个很致命的缺点,就是它无法避免循环引用,即两个对象之间循环引用的时候,各自的计数器始终不会变成 0,所以 引用计数算法 只出现在了早期的 JVM 中,现在基本不再使用了。

根搜索算法(Tracing Collector)

根搜索算法的中心思想,就是从某一些指定的根对象(GC Roots)出发,一步步遍历找到和这个根对象具有引用关系的对象,然后再从这些对象开始继续寻找,从而形成一个个的引用链(其实就和图论的思想一致),然后不在这些引用链上面的对象便被标识为引用不可达对象,也就是我们说的“垃圾”,这些对象便需要回收掉。这种算法很好地解决了上面 引用计数算法 的循环引用的问题了。

202301011657564704.png

算法的核心思想是很简单的,就是标记不可达对象,然后交由 GC 进行回收,但是有一个点是很重要的,那就是 何为根对象(GC Roots)

根对象,一般有如下几种:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表);
  • 方法区中常量引用的对象;
  • 方法区中静态属性引用的对象;
  • 本地方法栈中 JNI(Native 方法)引用的对象;
  • 活跃线程。

但其实,上述算法只是一个算法的中心思想,实际执行过程是比这个复杂的,另外,GC 判断对象是否可达其实看的还是强引用。

1、进行根搜索的时候,是需要暂停所有线程的,即执行一次 STW(Stop The World),最主要的目的是防止上述的对象图在算法运行的过程中有变化从而影响算法的准确性。
2、线程暂停的时间长短,取决于对象的多少,和堆内存的大小无关。
3、 宣告一个对象的“死亡”其实不仅仅通过上述的算法计算,而是需要经历两次的标记,本文暂不进行赘述。

回收算法

除了需要上文研究的标记“垃圾对象”的算法,我们也需要“清理垃圾”的 回收算法

常用的回收算法一般有: 标记-清除算法标记-整理算法复制算法 ,以及系统自动进行判定使用的 适应性算法

标记 - 清除算法(Tracing Collector)

标记-清除 算法是最基础的收集算法,它是由 标记清除 两个步骤组成的。

标记的过程其实就是上面的 根搜索算法 所标记的不可达对象,当所有的待回收的“垃圾对象”标记完成之后,便进行第二个步骤: 统一清除

该算法的优点是当存活对象比较多的时候,性能比较高,因为该算法只需要处理待回收的对象,而不需要处理存活的对象。

但是缺点也很明显,就是在执行完 标记-整理 之后,由于将“垃圾对象”回收掉了,所以原本连续使用的内存块便会变得不连续,这样会导致内存块上面会出现很多小单元的内存区域,这些小单元的内存区域只能够存放比较小的对象,而比较大的对象是无法直接存储的。

即原本空闲 1M 的内存区域,有可能会出现无法直接存放 0.9M 大小的对象。

202301011657569485.png

标记 - 整理算法(Compacting Collector)

上述的 标记-清除 算法会产生内存区域使用的间断,所以为了将内存区域尽可能地连续使用, 标记-整理 算法应运而生。

标记-整理 算法也是由两步组成, 标记整理

第一步的 标记 动作也是使用的 根搜索算法 ,但是在标记完成之后的动作却和 标记-清除算法 天壤之别,该算法并不会直接清除掉可回收对象 ,而是让所有的对象都向一端移动,然后将端边界以外的内存全部清理掉。

该算法所带来的最大的优势便是使得内存上面不会再有碎片问题,并且新对象的分配只需要通过简单的指针碰撞便可完成。

202301011657574166.png

复制算法(Copying Collector)

无论是 标记-清除算法 还是 垃圾-整理算法 ,都会涉及句柄的开销或是面对碎片化的内存回收,所以, 复制算法 出现了。

复制算法将内存区域均分为了两块(记为S0和S1),而每次在创建对象的时候,只使用其中的一块区域(例如S0),当S0使用完之后,便将S0上面存活的对象全部复制到S1上面去,然后将S0全部清理掉。

复制算法的优势是:① 不会产生内存碎片;② 标记和复制可以同时进行;③ 复制时也只需要移动栈顶指针即可,按顺序分配内存,简单高效;④ 每次只需要回收一块内存区域即可,而不用回收整块内存区域,所以性能会相对高效一点。

但是缺点也是很明显的:可用的内存减小了一半,存在内存浪费的情况。

所以 复制算法 一般会用于对象存活时间比较短的区域,例如 年轻代 ,而存活时间比较长的 老年代 是不适合的,因为老年代存在大量存活时间长的对象,采用复制算法的时候会要求复制的对象较多,效率也就急剧下降,所以老年代一般会使用上文提到的 标记-整理算法

202301011657579527.png

适应性算法(Adaptive Collector)

适应性算法 其实不是一种单独的回收算法,他只是一种智能选择回收算法的机制,也就是该算法会根据堆内存具体的使用情况而自动选用更适合当前情况的回收算法。

分代回收

分代回收 并不是一种垃圾回收算法,它是上述各种垃圾回收算法的一个落地应用方案。

因为上述各个算法都有各自的优势,我们在内存的使用过程中,有些对象存活时间长,有些对象存活时间短,有些对象甚至一直存活着,所以根据对象的存活周期,我们将内存区域分为三大块:年轻代、老年代 和 永久代,并且年轻代也继续细分为:Eden区、S0 和 S1。

1、各个内存区域的内存大小可以见上文中的内存模型图,当然,我们也可以给 JVM 传递参数来进行调整,这些内容本文也暂不赘述。
2、 Eden : S0 : S1 的默认比例为 8:1:1,为什么这么设计呢?其实 IBM 有专门的研究表明,年轻代中 98% 的对象都是朝生夕死的,所以只需要划分为一个较大的 Eden 区和两个较小的 Survivor 区即可,而且这样做的好处是只有 10% 的 Survivor 区会被浪费掉,这也是可以接受的。

下面简单介绍下各个内存区的 GC 过程:

  1. 对象首次创建进行内存分配的时候,首先会放置在 Eden 区,当 Eden 区放满了或者当该对象太大无法放进 Eden 区的时候,此时会对年轻代(Eden区 和 S0)进行一次 GC,将幸存下来的对象放置在 S1,然后清空掉 Eden区和 S0 区;(此时年轻代采用的是 复制算法
  2. 在上面第一步中对年轻代进行垃圾回收的时候,同时会对幸存的对象进行标记,统计每个幸存对象经历的 GC 次数;
  3. 当 S1 区满了之后,或者年轻代的对象经历过指定次数的 GC 之后,这部分对象会被放置到老年代之中;
  4. 当老年代也满了之后,便会对老年代进行一次 GC;(老年代采用的是 标记-整理算法

垃圾回收器

好了,上文介绍过了 “垃圾”的识别算法“垃圾”的回收算法 ,那么这些算法的执行者是谁呢?就是下文介绍的 垃圾回收器(GC) 了。

垃圾回收器的类型

在 Java 语言中,垃圾回收器按照执行机制来进行划分,主要分为四种类型:

  1. 串行垃圾回收器(Serial Garbage Collector);
  2. 并行垃圾回收器(Parallel Garbage Collector);
  3. 并发标记扫描垃圾回收器(CMS Garbage Collector);
  4. G1垃圾回收器(G1 Garbage Collector)。

上述四种垃圾回收器都是有各自的优缺点的,我们可以通过向 JVM 传递参数来指定其中一款垃圾回收器。

202301011657588638.png

1、串行垃圾回收器(Serial Garbage Collector)

串行垃圾回收器会暂停所有的应用程序线程,并采用单独的的线程进行 GC。

适用于单 CPU、并且对应用程序的暂停时间要求不高的情况,所以不太适合当前的生产环境。

2、并行垃圾回收器(Parallel Garbage Collector)

并行垃圾回收器是 JVM 默认的垃圾回收器,相较于串行垃圾回收器而言性能稍有提升,它也是需要暂停所有的应用程序线程的,但是区别是它会使用多线程进行 GC。

所以并行垃圾回收器适用于多 CPU 的服务器、并且能接受短暂的应用暂停的程序。

3、并发标记扫描垃圾回收器(CMS Garbage Collector)

CMS 回收器也是一种并行的垃圾回收器,它会采用多线程来进行扫描堆内存,标记需要清理的对象并将这些对象清理掉。

但是 CMS 它需要更多的 CPU 来保证程序的吞吐量,并且它保证了最短的回收停顿时间,所以,在服务器允许的情况下,为了达到更到的性能,我们应该使用 CMS 来代替默认的 并行垃圾回收器。

4、G1 垃圾回收器(G1 Garbage Collector)

G1 垃圾回收器是在 JDK1.7 中才正式引入的一款垃圾回收器,“科技在进步,所以一般越是先进的技术一般会更好用并且会替代陈旧的技术”,好了,玩笑归玩笑,但是 G1 的引入,目的就是为了取代 CMS 的。

不要被上面 G1 的示意图误导, G1 并没有将内存进行物理划分,它只是将堆内存划分为一个个的 Region,但是也是属于分代垃圾回收器,G1 仍然会区分年轻代和老年代,并且年轻代仍然会有 Eden 区和 Survivor 区。

这么做的目的是保证 G1 回收器在有限的时间内可以获得尽可能高的回收效率。

202301011657594349.png

HotSpot 虚拟机(HotSpot VM)提供的几种垃圾收集器

HotSpot VM 提供了 7 种垃圾收集器,分别为:

  1. Serial
  2. PraNew
  3. Parallel Scavenge
  4. Serial Old
  5. Parallel Old
  6. CMS
  7. G1

其中,1、2、3 种适合年轻代内存区的垃圾回收,4、5、6种适合老年代内存区的垃圾回收,并且它们之间是两两组合来进行使用的,详见下图:

2023010116580007810.png

垃圾回收的时机

垃圾回收分为两种,Full GC 和 Scavenge GC。

Full GC 发生在整个堆内存中,而 Scavenge GC 仅仅发生在年轻代的 Eden 区,所以我们应该尽可能地减少 Full GC 的次数,当然,对于 JVM 的调优,很多情况下也是在想办法对 Full GC 进行调优。

因为 GC 是可能会对应用程序造成影响的,所以触发 GC 也是有一定的条件的,例如:

  • 当应用程序空闲时,GC 有可能会被调用,因为 GC 运行线程的优先级是相对较低的,所以当线程忙的时候,它是不会运行的,当然,内存不足的情况除外;
  • 堆内存不足的时候,GC 会被调用。例如创建对象的时候,若此时内存不足,则会触发 GC 用来给这个对象分配合适的内存,当进行完一次 GC 之后内存还是不足,则会继续进行第二次 GC,若第二次 GC 之后内存还是不足,则一般会提示 “out of memory”异常;

小 Tip:
System.gc() 方法会显示触发 Full GC,但是它只是对 JVM 的一个 GC 请求,至于何时触发,还是由 JVM 自行判断的。

GC 的调用开销是比较大的,所以我们需要有针对性地进行调优,一般有如下方案:

  1. 不要显式调用 System.gc()。此函数虽然是建议 JVM 进行 GC,但很多情况下它会触发 GC,从而增加 GC 的频率;
  2. 尽量减少临时对象的使用。在方法结束后,临时对象便成为了垃圾,所以减少临时变量的使用就相当于减少了垃圾的产生,从而减少了GC的次数;
  3. 对象不用时最好显式置为 Null。一般而言,为 Null 的对象都会被作为垃圾处理,所以将不用的对象显式地设为 Null 有利于 GC 收集器对垃圾的判定;
  4. 尽量使用 StringBuilder 来代替 String 的字符串累加。因为 String 的底层是 final 类型的数组,所以 String 的增加其实是建了一个新的 String,从而产生了过多的垃圾;
  5. 允许的情况下尽量使用基本类型(如 int)来替代 Integer 对象。因为基本类型变量比相应的对象占用的内存资源会少得多;
  6. 合理使用静态对象变量。因为静态变量属于全局变量,不会被 GC 回收;

其它

JVM 的 GC,它就像能看到也能感受到的真实存在的事物,但是当我们去伸手够它的时候,此时它又是虚无缥缈般的存在,处理它的时候还需要格外地谨慎。

因为它的不确定性,所以我们不应该去假定 GC 触发的时间,也不要去使用类似 System.gc() 这样显示调用 GC 的方法,这些都是得不偿失的。

最需要注意的是我们的编程习惯和编程态度,良好的编程习惯能够帮助我们规避掉很多内存方面的问题,包括但不仅限于 内存泄露 等。

最后,由于垃圾回收器众多,在特定的情况下,我们是可以指定使用垃圾回收器的类型的,例如使用:-X:+UseG1GC 来指定使用 G1 垃圾回收器。

2023010116580082911.png