Java 中的 GC

人在日常活动中不可避免会产生垃圾,程序也是如此。在方法中 new 一个对象,每次调用该方法都会进行 new 对象操作,如果没有人去管理这些对象,没有把这些对象占有内存及时释放掉,内存很快就会满。因此,在程序运行过程中,需要及时寻找和处理「 死去」的对象。这个「 死去」的对象就是垃圾,而寻找和处理垃圾的过程就叫做 GC

GC 全称 “Garbage Collection” 即垃圾收集。不同的语言对于 GC 的处理都是不一样的,因此也产生了不同的「流派」。

需要说明的是:本文介绍的 GC 都是基于 HotSpot。

垃圾管理流派

根据内存的管理方式,划分出两个流派:手动管理和自动管理。

手动管理的代表就是:C ++ 和 C。它们需要在编码过程中,手动去释放内存。

  • 优点:能明确垃圾的产生,以及提高回收的效率。
  • 缺点:垃圾太多,在代码中手动清理内存很「痛苦」,很容易忘记释放内存,导致内存泄露。由于需要明确垃圾的产生位置,因此也需要对该语言非常熟悉,这也无形中加大了语言的上手难度。

自动管理的代表:Java (当然还有很多其他语言,篇幅有限,暂不阐述)。

  • 优点:使用者无需关心内存的使用,只需专注功能和业务的实现。内存的处理和回收有个智能的程序替我们完成。
  • 缺点:屏蔽了底层细节,出现了内存问题,使用者无法着手解决。当然解决办法也很简单,就是去了解 GC 原理。

寻找垃圾

现在我们知道 Java 是自动 GC,那现在问题来了:它是怎么去找到垃圾?怎么保证找的对象就一定是垃圾呢?

在「 找垃圾」这块,也有两种方式:引用计数和可达性分析。虽然有两种方式,但主流的 Java 虚拟机里面都没有选用引用计数。

引用计数

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

如图所示,小圆圈代表对象,箭头代表引用关系,数字代表对象被引用的次数。可以看到蓝色的对象都是处于「 活跃」状态的,而灰色的对象要么没有被引用,要么引用它的对象没有被引用,这些对象就是程序需要回收的垃圾。

GC-in-Java-01.PNG

这一切看似完美无缺,实则存在一个巨大问题:对象间的循环引用。

仔细看上面这张图,红色的对象相互循环引用,程序并没有使用到,理应当作垃圾处理。但是,在引用计数方法看来这是个「 活跃」的对象,因此并不会进行处理。

在一些文章上有谈论到解决该问题的方法,例如:循环引用的对象使用 弱引用,使用单独的算法等,详情可以自行搜索。

可达性分析

Java 就是通过可达性分析算法来进行内存管理的。

这个算法的基本思路就是通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,即通过 GC Roots 访问不到该对象时,则证明此对象是垃圾。

用比较生活化的例子来解释:家是内存,家庭成员是 GC Roots,物品是对象,家庭大扫除就是可达性分析。家庭大扫除的结果就是把家庭成员都用不到的东西归为垃圾,并把它清除掉。

如图所示:灰色的对象就是 GC Roots 访问不到的对象,这些对象就是垃圾。图中也可以看到对象循环引用的问题的,在这里也已经不复存在,都会被当做垃圾。

GC-in-Java-02.PNG

大扫除的例子,我们知道「 家庭成员」是 GC Roots,在 Java 技术体系中常见的 GC Roots 包括以下几种:

  • 线程
  • native 方法
  • 栈帧中的局部变量表
  • Class 引用的 static field
  • 所有被同步锁(synchronized关键字)持有的对象

引用

通过上面两种方法来看,似乎对象在内存中的状态只有「 被引用」和「 未被引用」两种状态,GC 把「 未被引用」 的对象收集。

在在内存空间充足时,可能并不希望「 未被引用」的对象被清除,只有当内存紧张时,才把那些对象抛弃。

在 Java 中对引用的概念进行扩充,将引用分为:

  • 强引用(Strongly Re-ference)
    • 在 Java 程序代码中普遍存在的就是强引用,比如你 new 一个对象。
  • 软引用(Soft Reference)
    • 只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
  • 弱引用(Weak Reference)
    • 当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
  • 虚引用(Phantom Reference)
    • 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。类似于 Linux 中的 退出状态

这4种引用强度依次逐渐减弱。

垃圾自我救赎

使用可达性分析找到「未被引用」的对象并不会立即将内存释放,而是给这些对象进行标记 ,当对象被标记两次后才会进行回收。

image.png

当对象第一次标记之后,随后会进行一次筛选,筛选的依据是:对象是否有必要执行 finalize() 方法?

假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为「没有必要执行」

对象被判定为「没有必要执行」,则直接进行回收。否则,会把对象放入到一个名为 F-Queue 的队列中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。流程如下:

image.png

对象的 finalize() 方法执行,GC 对 F-Queue 中的对象进行第二次标记。如果 finalize() 方法执行之后,对象重新回到了 GC Roots 的引用链上,则把它移出队列。否则,被 GC 回收。

image.png

这个时候你可能就有问题了:那我在对象的 finalize() 方法中,编写代码使得当前对象重新回到 GC Roots 引用链上,这个对象岂不是永远不会被 GC 回收?

答案是会被回收。可以看到「没有必要执行」的条件中有一条是: finalize() 方法已经被虚拟机调用过。因此对象虽然能回到引用链上,但是下一次 GC 它一定会被回收。总结就是:一个对象只能被救赎一次。

其实关于  finalize() 方法,很多人也不了解,只需要知道它会于 GC 过程产生联系即可。在 《Effective Java》第三版 Item 8 ,以及《深入理解 Java 虚拟机》第三版中,作者都不建议在代码中使用 finalize,甚至让我们忘记这个 API。

因此,使用  finalize() 方法的最佳实践就是:从不使用它。

文章参考《深入理解 Java 虚拟机》第三版、《Effective Java》第三版、《Plumbr Handbook Java Garbage Collcetion》。


Java 中的 GC
http://wszzf.top/2022/07/14/Java 中的 GC/
作者
Greek
发布于
2022年7月14日
许可协议