51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

Java 面试之 GC 相关

垃圾回收之标记算法 {#垃圾回收之标记算法}

当 Java 对象没有被其他对象引用时会被判定为垃圾,需要销毁未被引用的对象并释放所占据的内存。

判断对象是否被引用的算法有两种:引用计数算法和可达性分析算法。

引用计数算法 {#引用计数算法}

该算法通过判断目标对象的引用数量来决定该对象是否可被回收。

在这种机制下,堆中的每个对象都有一个引用计数器,当某个对象被其它对象所引用时则 +1,完成引用时则 -1

例如,在方法中定义了一个引用变量指向某对象实例,此时该对象的引用计数器会 +1,当方法执行结束时会释放其中的局部变量,即该引用变量会被自动释放,此时该对象的引用实例又会 -1

当某个对象实例的引用计数为 0 时可以被当作垃圾收集。

**此算法的优点是执行效率高,程序执行过程中受影响小。**只需过滤出引用计数为 0 的对象将其回收即可,所以可以交织在程序运行过程中执行。另外由于垃圾回收时可以做到几乎不打断程序的执行,因此该算法对需要长时间运行不被打断的程序比较适合。

但是它也有一个致命的缺点:无法检测出循环引用的情况,会导致内存泄漏。

Java内存泄漏指的是在Java程序中存在的一种问题,其中对象不再被程序使用,但由于某些原因,Java虚拟机(JVM)仍然保留对这些对象的引用,导致它们不能被垃圾回收器正确地清理。这些被保留的引用会占用内存,最终导致内存耗尽,程序性能下降,甚至导致系统崩溃。常见的内存泄漏情况包括未关闭资源、静态集合类的使用、长生命周期对象引用短生命周期对象等。解决内存泄漏问题通常需要仔细检查代码,确保对象在不再需要时能够正确地被释放。

如以下代码:

public class MyObject {
    public MyObject childNode;
}
public class ReferenceCounterProblem {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
  
        object1.childNode = object2;
        object2.childNode = object1;
    }
}

对于此种循环引用的情况,该算法的就永远检测不出,因为它们的引用计数器的值永远不可能为 0。

正是因为有此短板,主流的 Java 虚拟机都未采用此算法来判断某个对象是否可标记为垃圾,大部分用的是下面这种算法。

可达性分析算法 {#可达性分析算法}

即判断对象的引用是否可达来决定该对象是否可以被回收。

可达性算法来源于离散数学中的图论,程序把所有的引用关系看作一张图,通过一系列名为 GC Root的对象作为起始点开始向下搜索,搜索过程中所走的路径就称之为引用链(Reference Chain)。

当一个对象与 GC Root之间没有任何引用链相连,就说明该对象是不可达的,可以被标记为垃圾,等待回收。

image-20240201214457473

可作为 GC Root的对象:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表)。例如在 Java 方法中 new了一个对象并赋值给了某个局部变量,在该局部变量没有被销毁之前 new出的对象就会被作为 GC Root
  • 方法区中常量引用的对象。例如在某个类中定义了一个常量,该常量保存的是某个对象的地址,被保存的对象也会被作为 GC Root,当其它变量引用到该对象时就会产生一条上图中的引用链;
  • 方法区中的类静态属性引用的对象。与上一条如出一辙;
  • 本地方法栈中 JNI(Native方法)的引用对象。调用 native方法,构建的非 Java 语言的对象也可作为 GC Root
  • 活跃线程的引用对象。

谈谈你了解的垃圾回收算法 {#谈谈你了解的垃圾回收算法}

标记 - 清除算法(Mark and Sweep) {#标记---清除算法mark-and-sweep}

该算法分为两个阶段:

  1. 标记:从根结点出发遍历对象,对访问过的对象打上标记,表示该对象可达。
  2. 清除:线性遍历整个堆内存,回收不可达对象(没有标记的对象)并清除可达对象的标记。

如图所示,在标记阶段,标记了 B、E、F、G、J、K 这 6 个可达对象;在回收阶段所有未被标记为可达的对象都会被回收,即 A、C、D、H、I。

image-20240226192644383

缺点是容易产生碎片化内存空间。因为标记清除过程仅处理不存活的对象,不对存活的对象进行移动,所以在标记清除之后会产生大量不连续的内存空间碎片。

太多的空间碎片会导致程序运行中当需要分配较大的内存空间时无法找到连续的内存空间而不得不提前触发一次垃圾回收动作。

以上图为例,假设标记清除完毕后某个对象需要分配 3 个内存单位,但此时无法提供 3 个连续的内存单位,这就会造成 Collector 一直尝试垃圾收集直至抛出 OutOfMemoryError错误。

复制算法(Copying) {#复制算法copying}

  1. 将可用的内存按容量比例划分为 2 块或多个相同大小的块,选择其中的 1~2 块作为对象面 ,剩余的作为空闲面;
  2. 对象在对象面上创建;
  3. 当对象面内存块用完时,将**存活的对象(不是垃圾)**从对象面复制到空闲面上;
  4. 将对象面所有对象内存清除。

该算法适合对象存活率低的场景,如年轻代,研究发现年轻代中的对象每次回收之后基本只剩 10% 左右的对象存活,需要复制的对象很少。

该算法的优点是:每次都对整个对象面进行内存回收,因此内存分配时不用考虑内存碎片 等复杂情况,推倒重建只需要移动堆顶指针按顺序分配内容即可,实现简单,运行高效。

该算法的特点是:

  • 解决了碎片化的问题;
  • 顺序分配内存,简单高效;
  • 适用于对象存活率低的场景。

image-20240226195051764

标记 - 整理算法(Compacting) {#标记---整理算法compacting}

该算法分为两个阶段:

  1. 标记:从根集合进行扫描,对存活的对象进行标记。
  2. 清除:移动所有存活的对象,按照内存地址次序依次排列,最后将末端内存地址以后的内存全部回收。

《标记 - 整理算法》在《标记 - 清除算法》的基础上又进行了对象的移动,因此成本更高,但是解决了内存碎片的问题,适合老年代内存的回收。

该算法的特点如下:

  • 避免内存的不连续,即没有内存碎片;
  • 不用设置两块内存互换;
  • 适用于存活率高的场景。

image-20240226200443232

分代收集算法(Generational Collector) {#分代收集算法generational-collector}

  • 垃圾回收算法的组合拳;
  • 按照对象生命周期的不同划分对应区,不同区域采用不同的垃圾回收算法;
  • 目的:提高 JVM 的垃圾回收效率。

在 JDK7 及之前的版本中,Java 堆内存分为年轻代(Young Generation)、老年代(Old Generation)、永久代(Permanent Generation)三个模块。

image-20240226203907509

在 JDK8 及之后的版本中,只剩下年轻代(Young Generation)和老年代(Old Generation)这两个模块了。

年轻代内的对象存活率低,采用复制算法;老年代内的对象存活率高,采用标记 - 清除算法或标记 - 整理算法。

该收集算法的 GC 分为两种:

  • Minor GC:年轻代垃圾回收,采用复制算法。
  • Full GC:对老年代的回收一般伴随年轻代的回收,因此被命名为 Full GC

先讲讲年轻代的回收。

年轻代:尽可能快速低收集掉那些生命周期短的对象。

年轻代分为两个区:

  • 一个 Eden 区:Eden 表示伊甸园,人类的起源。新创建的对象其内存首先被分配在 Eden 区,但 Eden 区空间不够时也有可能会放在 Survivor 区甚至老年代中。
  • 两个 Survivor 区:分别被定义为 from 区和 to 区,且哪个是 from 区,哪个是 to 区并不固定,会随着垃圾回收的进行而相互转换。

年轻代内存一般按照 8:1:1 的比例分为 Eden 区,from 区和 to 区,绝大部分对象在 Eden 区中生成,年轻代中 98% 的对象都是朝生夕死,因此不需要按照 1:1 来分配内存空间。

在进行垃圾回收时将 Eden 区和一块 Survivor 区中存活的对象一次性复制到另一块 Survivor 区,然后清空 Eden 区和之前使用过的 Survivor 区。

当 Survivor 区空间不够用时,此时就需要使用到老年代的内存空间了。

image-20240226205219875

来看一个详细演示年轻代垃圾回收的过程的例子:

为了方便演示,暂且忽略 Eden 区和 Survivor 区的大小比例,并且默认每个对象的大小都是相同的,假设 Eden 区最多保存 4 个对象,Survivor 区最多保存 3 个对象。

  1. 程序开始运行,新的对象被创建在 Eden 区,当 Eden 区被挤满时触发一次 Minor GC。

    image-20240226210334632

  2. 假设存活的对象被复制到代号为 S0 的 Survivor 区,此时 S0 就被称为 from 区,S1 被称为 to 区,并且将存活的对象年龄 + 1。

    PixPin_2024-02-26_21-06-48

  3. 复制完毕后,清空 Eden 区内存空间。

    image-20240226211143476

  4. 假设 Eden 区又被填满,再次触发 Minor GC。

    image-20240226211449660

  5. 将 Eden 区和 S0 区中的对象复制到 S1 区,并对这些对象的年龄 + 1。此时 S1 变为 from 区,S0 变为 to 区。

    image-20240226211628815

  6. 复制完毕后,清空 Eden 区和 S0 区,完成第二次 Minor GC。

    image-20240226211856472

  7. 假设 Eden 区又又满了。

    image-20240226212032168

  8. 存活的对象将从 Eden 区和 S1 区复制到 S0 区,并且年龄再 +1。

    image-20240226212151280

  9. 复制完成后清空 Eden 区和 S1 区。

    image-20240226212238554

  10. 周而复始,对象每在 Survivor 区熬过一个周期时年龄就会 +1,当其年龄大于某个值时(默认 15 岁,也可用 -XX:MaxTenuringThreshold参数进行调整)就会被分配到老年代中,但这也不是绝对的,对于需要分配较大连续内存空间的对象,Eden 区和 Survivor 区装不下就会直接进入到老年代中。

由以上例子可知 Minor GC 采用复制算法,在内存分配时不用考虑内存碎片等复杂情况,只需移动堆顶指针按顺序分配即可,在回收的时候一次性将某个区域清空。简单、粗暴、高效。

那么对象又如何晋升到老年代呢?

  • 经历一定 Minor GC 次数后依然存活的对象;
  • Survivor 区中存放不下的对象;
  • 新生成的大对象(可由 -XX:+PretenuerSizeThreshold参数调整)。

常用的调优参数:

  • -XX:SurvivorRatio:Eden 和 Survivor 的比值,默认 8:1;
  • -XX:NewRatio:老年代和年轻代内存大小的比例;
  • -XMS-XMX:设置年轻代和老年代内存的总大小;
  • -XX:MaxTenuringThreshold:对象从年轻代晋升到老年代经过 GC 次数的最大阈值。

老年代:存放生命周期较长的对象,使用标记 - 清除算法或标记 - 整理算法。

通常来说 Major GC 和 Full GC 是等价的,但因为 hotspot 虚拟机发展这么多年,外界对各种名词的解读已经完全混乱了,所以当有人说 Major GC 时一定要问清楚指的是 Full GC 还是仅仅针对老年代的 GC。

Full GC 比 Major GC 慢得多,一般得有 10 倍以上,但执行频率低,因为老年代中的对象都是从 Survivor 区中熬过来的,不会那么容易死掉。

触发 Full GC 的条件:

  • 老年代空间不足;
  • 永久代空间不足(JDK7 及之前的版本),这也是 JDK8 及之后的版本使用元空间替代永久代的原因,因为可以降低 Full GC 触发的频率,提升运行效率;
  • CMS GC 时出现 promotion failed,concurrent mode failure 时;
    • promotion failed:在进行 Major GC 时,Survivor 区和老年代都放不下了,就会造成 promotion failed。
    • concurrent mode failure:在进行 CMS GC 时同时有对象要放入老年代中,但此时老年代空间不足就会造成 concurrent mode failure。
  • Minor GC 晋升到老年代的平均大小大于老年代的剩余空间;
    • 这是比较复杂的情况,hotspot 虚拟机为了避免新生代晋升到老年代年导致老年代空间不足的现象,在进行 Minor GC 时做了一个判断,如果之前统计 Minor GC 晋升到老年代的平均大小大于老年代的剩余空间就触发一次 Full GC。
    • 例如程序第一次触发 GC 后有 6M 的对象晋升到老年代,在下次 Minor GC 时首先检查老年代的剩余空间是否大于 6M,如果小于则执行 Full GC。
  • 调用 System.gc(),显示触发 Full GC。但是这个方法只是提醒虚拟机码农希望你在这个时候执行 Full GC,但到底执不执行还是由虚拟机自己决定。
  • 使用 RMI(远程方式) 来进行 RPC 或管理的 JDK 应用,默认每 1 小时执行一次 Full GC。

面试时一般答出来三点就差不多了,可以记住三个简单的:老年代空间不足、JDK7 及以下永久代空间不足、调用 System.gc()方法。其它的能说出来更好。

Java 垃圾收集器 {#java-垃圾收集器}

在探讨 Java 垃圾收集器之前需要说明两个概念,一个模式:

  • 两个概念:Stop-the-worldSafepoint
  • 一个模式:JVM 运行模式

Stop-the-world

  • JVM 由于要执行 GC 而停止应用程序的执行的现象;
  • 任何一种 GC 算法中都会发生;
  • 多数 GC 通过优化减少 Stop-the-world发生的时间来提高程序性能,使系统具有高吞吐,低停顿的特点。

JVM 垃圾收集好比保洁阿姨在打扫卫生,如果一边打扫一边有人扔垃圾该怎么办呢?处理起来也很简单,保洁阿姨在开始打扫前就和所有人说:"我要开始打扫了,你们都不准扔垃圾了"。

那么 JVM 垃圾回收时对应上面的情景是如何处理的呢?

在可达性分析过程中分析哪个对象没有被引用时必须在一个快照的状态点进行,在这个状态点所有的线程都会被冻结,禁止出现分析过程中对象引用关系还在不停变化,因此得出的分析结果在某个节点具有确定性,该节点就被叫做安全点(Safepoint),程序只有到达了安全点才会停顿下来。

Safepoint

  • 分析过程中对象引用关系不会发生变化的点;
  • 产生 Safepoint 的地方:方法调用、循环跳转、异常跳转等;
    • 一旦 GC 发生,就会让所有的线程都跑到安全点停下,如果发现线程不在安全点就恢复线程等其跑到安全点再说。
  • 安全点数量得适中。
    • 太少会让 GC 等待太长的时间;
    • 太多会增加程序运行的负荷。

JVM 的运行模式:

  • Server
  • Client

两种模式的区别在于 Client模式启动速度比 Server模式快,但是启动进入稳定期长期运行时 Server模式的程序运行速度要比 Client模式快,因为 Server模式启动的是重量级虚拟器,对程序采用了更多的优化,而 Client模式是轻量级的虚拟机。

使用 java -version可查看当前 JVM 的运行模式。

image-20240227195202679

垃圾收集器之间的联系

虽然经过了长时间的发展,但 Java 垃圾收集器至今仍处于不断的演进中。不同大小的设备,不同特征的应用场景都需要不同的垃圾收集器来满足特定的要求,因此垃圾收集器不存在哪个好哪个坏这一说。

垃圾收集器和具体 JVM 的实现紧密相关,不同厂商,如:IBM,Oracle,不同版本的 JVM 提供的选择也不同,这也是 HotSpot 虚拟机实现这么多收集器的原因。

常见的垃圾收集器以及它们之间的关系,适用范围如下图所示,其中连线表示这两个收集器可以搭配使用:

image-20240227200120410

为什么老年代中的 CMS 收集器与年轻代中的 Parallel Scavenge 收集器不兼容?

因为 Parallel Scavenge 收集器与 G1 收集器没有使用传统的 GC 收集器代码框架,是另外独立实现的,其余的收集器共用了部分框架代码,因此可以结合在一起相互使用。

年轻代常见垃圾收集器 {#年轻代常见垃圾收集器}

Serial 收集器(-XX:+UseSerialGC,复制算法)

  • 是 JAVA 中最基本,历史最悠久的收集器,在 JDK 1.3.1 版本之前是年轻代收集器的唯一选择;
  • 单线程收集,进行垃圾收集时,必须暂停所有工作线程;
  • 简单高效,Client 模式下默认的年轻代收集器。

用户桌面应用场景中给虚拟机管理的内存一般不会很大,收集几十兆及一两百兆的停顿时间在几十毫秒到一百毫秒之间,只要不是频繁发生,这点停顿完全可以接受。

image-20240227200753276

ParNew 收集器(-XX:+UseParNewGC,复制算法)

  • 多线程收集,其余的行为、特点和 Serial 收集器一样;
  • 单核执行效率不如 Serial 收集器,在多核下执行才有优势,默认开启的收集线程数与 CPU 数量相同;
  • 在 Server 模式下是非常重要的收集器,因为目前除 Serial 收集器外只有它可以和 CMS 收集器配合工作。

image-20240227201134016

Parallel Scavenge 收集器(-XX:+UseParallelGC,复制算法)

  • 比起关注用户线程停顿时间,更关注系统的吞吐量;
    • 停顿时间短适合需要与用户交互的程序,良好的相应速度能够提升用户体验;
    • 高吞吐量则可以高效利用 CPU 时间,尽可能快的完成运算任务,主要适合在后台运算而不需要太多交互任务的程序;
    • 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
  • 在多核下执行才有优势,是 Server 模式下默认的年轻代收集器。

如果对垃圾收集器运行原理不清楚,以至于在优化过程中遇到困难,可以使用 Parallel Scavenge 收集器配合 -XX:UseAdaptiveSizePolicy自适应调教策略参数将内存管理的调优任务交由虚拟机完成。

image-20240227202644942

老年代常见垃圾收集器 {#老年代常见垃圾收集器}

Serial Old 收集器(-XX:+UseSerialOldGC,标记 - 整理算法)

  • 单线程收集,进行垃圾收集时,必须暂停所有工作线程;
  • 简单高效,Client 模式下默认的老年代收集器。

image-20240227203622302

Parallel Old 收集器(-XX:+UseParallelOldGC,标记 - 整理算法)

  • 多线程,吞吐量优先。

image-20240227203817531

CMS 收集器(-XX:+UseConcMarkSweepGC,标记 - 清除算法)

CMS 收集器是 HotSpot 在 JDK5 推出的第一款真正意义上的并发收集器,首次实现了让垃圾收集线程与用户线程之间几乎可以同时工作,使得 Stop-the-world时间大大减少,因此它占据着老年代垃圾收集器的半壁江山,也是它划时代的意义。

  • 如果应用程序对停顿比较敏感并且在程序运行过程中能过提供更大的内存,更多的 CPU,那么使用 CMS 收集器会带来好处。

  • 如果在 JVM 中有相对较多存活时间较长的对象更适合 CMS 收集器。

CMS 收集器整个垃圾回收过程可分为以下 6 步:

  1. 初始化标记:Stop-the-world需要虚拟机停顿正在执行的任务。从垃圾回收的根对象开始,只扫描能和根对象直接关联的对象并做标记,此阶段很快就能完成;
  2. 并发标记:在初始标记的基础上继续向下并发追溯标记,应用程序线程和并发标记线程并发执行,程序不会停顿;
  3. 并发预清理:查找执行并发标记阶段从年轻代晋升到老年代的对象,通过重新扫描减少下一阶段重新标记的工作,因为下一阶段会 Stop-the-world
  4. 重新标记:暂停虚拟机,收集线程扫描 CMS 堆中的剩余对象,扫描从根对象开始向下追溯并处理对象关联,这一步会相对慢一些。
  5. 并发清理:清理垃圾对象,收集线程和应用程序线程并发执行,程序不会停顿。
  6. 并发重置:重置 CMS 收集器的数据结构,等待下一次垃圾回收。

并发标记过程与用户线程同时工作,类似一边丢垃圾一边打扫,如果垃圾的产生在标记之后,则只有等到下次再回收。

但该垃圾收集器有一个明显的缺点:由于其采用标记 - 清除算法,会造成内存空间碎片化的问题,若此时需要分配连续的较大内存空间,则只能触发一次 GC 了。

image-20240227205552651

G1 收集器 {#g1-收集器}

G1 收集器(-XX:+UseG1GC,复制+标记 - 整理算法)

既可用户年轻代垃圾收集也可用于老年代垃圾收集。

HotSpot 开发团队赋予 Garbage First 收集器的使命是未来替换掉 JDK5 中发布的 CMS 收集器。

Garbage First 收集器的特点:

  • 并行和并发。使用多个 CPU 缩短 Stop-the-world停顿时间与用户线程并发执行;
  • 分代收集。独立管理整个堆,采用不同的方式处理新创建的对象和已经存活一段时间熬过多次 GC 的旧对象以获得更好的收集效果;
  • 空间整合。基于标记 - 整理算法避免了内存碎片的问题;
  • 可预测的停顿。建立可预测的停顿时间模型,让使用者明确在一个长度为 n 毫秒的时间片段内消耗在垃圾收集上的时间不得超过 m 毫秒。

使用此收集器的 Java 内存布局与其它收集器的内存布局有很大区别,它将整个 Java 堆内存划分为多个大小相等的 Region,并且年轻代和老年代也不再物理隔离,可以是不连续的 Region 集合,在内存空间分配时不需要连续的内存空间,即不需要在 JVM 启动时决定哪些 Region属于年轻代,哪些 Region 属于老年代,随着时间推移年轻代 Region 被回收之后会变为可用状态,此时可将其分配为老年代。

Garbage First 收集器年轻代由 Eden RegionSurvivor Region组成,当 Eden Region空间满了 JVM 分配失败后将触发一次年轻代回收释放内存空间,Garbage First 年轻代收集器会移动所有的存储对象,从 Eden Region复制到 Survivor Region中,这就是 Copy to Survivor的过程。

image-20240227212905935

先前的收集器收集的范围都是整个年轻代或老年代,但Garbage First 收集器与它们有所区别:

  • Garbage First 年轻代收集器是并行 Stop-the-world收集器,与其它 HotSpot GC 一样,当年轻代 GC 发生时,整个年轻代会被回收。
  • Garbage First 老年代收集器有所不同,不需要整个老年代进行回收,只有一部分 Region 被调用。

GC 相关常见面试题 {#gc-相关常见面试题}

Object 的 finalize() 方法的作用是否与 C++ 的析构函数作用相同 {#object-的-finalize-方法的作用是否与-c-的析构函数作用相同}

  • 与 C++ 的析构函数不同,析构函数调用时机是确定的,在对象离开作用域时就会调用,但 Java 中 finalize() 方法的调用具有不确定性;
  • 当垃圾回收器宣告一个对象死亡时至少要经过两次标记时,如果对象在进行可达性分析时没有与 GC Root 之间有引用链就会被第一次标记并且判断是否执行 finalize()方法。如果对象 finalize()方法被覆盖且未被调用过,则将此对象放置在 F-Queue队列中 ,稍后由虚拟机自动建立的低优先级 finalize 线程执行触发 finalize()方法;
  • 由于 finalize 线程优先级较低,触发 finalize()方法后,不保证该方法能够运行结束,finalize()方法执行过程总随时可能会被终止;
  • finalize()方法的作用时给予对象最后一次重生的机会。

finalize()方法具体如何使用,来看下面这个例子:

public class Finalization {
    public static Finalization finalization;

    /**
     * 重写 finalize() 方法
     * 在 GC 准备释放对象所占用的内存空间之前,它将先调用 finalize() 方法
     */
    @Override
    protected void finalize() {
        System.out.println("Finalized");
        // 将要释放的对象复制给静态成员变量 finalization,模拟延长对象的生命周期功能,给予对象一次重生的机会。
        finalization = this;
    }

    public static void main(String[] args) {
        Finalization f = new Finalization();
        System.out.println("First print: " + f);
        f = null;
        // 手动触发一次垃圾回收
        System.gc();
        try {
            // 休息一段时间,让上面的垃圾回收线程执行完成
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Second print: " + f);
        System.out.println(f.finalization);
    }
    /* 输出:
    First print: cool.ldw.javabasic.jvm.gc.Finalization@74a14482
    Finalized
    Second print: null
    cool.ldw.javabasic.jvm.gc.Finalization@74a14482
     */
}

在最后的输出结果中可以看出,成员变量 finalizationf对象的值是一样的,说明 f对象还活着,给予了它一次重生的机会。

由于 finalize()方法运行的不确定性较大,且运行代码也相当高昂,因此不建议使用该方法。

Java 中的强引用、软引用、弱引用、虚引用有什么用? {#java-中的强引用软引用弱引用虚引用有什么用}

强引用(Strong Reference)

  • 最普遍的引用:Object obj = new Object()
  • 当内存空间不足时,Java 虚拟机宁可抛出 OutOfMemoryError终止程序也不会回收具有强引用的对象;
  • 当不使用某个对象时,通过将其设置为 null来弱化引用,使其被 GC 回收,或等待超出对象的生命周期范围。

软引用(Soft Reference)

  • 对象处在有用但非必须的状态;
  • 只有当内存空间不足时,GC 才会回收该引用对象的内存;
  • 可以用来实现高速缓存。

用法如下:

String str = new String("abc");  // 强引用
SoftReference<String> softRef1 = new SoftReference<>(str);  // 软引用

ReferenceQueue<String> queue = new ReferenceQueue<>();  // 引用队列
SoftReference<String> softRef2 = new SoftReference<>(str, queue);  // 软引用可配合引用队列使用

弱引用(Weak Reference)

  • 非必须的对象,比软引用更弱一些;
  • GC 时会被回收;
  • 被回收的概率也不大,因为 GC 线程优先级比较低;
  • 适用于引用偶尔被使用且不影响垃圾收集的对象。

用法如下:

String str = new String("abc");  // 强引用
WeakReference<String> weakRef1 = new WeakReference<>(str);  // 弱引用

ReferenceQueue<String> queue = new ReferenceQueue<>();  // 引用队列
WeakReference<String> weakRef2 = new WeakReference<>(str, queue);  // 弱引用可配合引用队列使用

虚引用(Phantom Reference)

  • 不会决定对象的生命周期;
  • 如果一个对象持有虚引用,就和没有任何引用一样,在任何时候都可能被垃圾收集器回收;
  • 主要用来跟踪对象被垃圾收集器回收的活动,起哨兵作用;
  • 必须和引用队列 ReferenceQueue联合使用。

用法如下:

String str = new String("abc");  // 强引用
ReferenceQueue<String> queue = new ReferenceQueue<>();  // 引用队列
PhantomReference<String> ref = new PhantomReference<>(str, queue);  // 虚引用

GC 在回收对象时,如果发现对象具有虚引用,则在回收之前会将该对象加入与之关联的引用队列中,我们可以通过判断引用队列是否加入虚引用来了解被引用的对象是否被 GC 回收,所以虚引用起到了哨兵作用。

总结

引用级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用。

PixPin_2024-02-28_21-54-49

引用相关类层次结构:

image-20240228220317193

引用队列

  • 名义上是队列,但并无实际存储结构,其存储逻辑依赖于内部节点之间的关系来表达。可以理解为引用队列是类似链表的结构,仅存储链表中的 Head节点,后面的节点由每个 Reference节点通过 next来保持连接。

    1. ReferenceQueue类中有个 head成员变量,类型为 Reference

      image-20240228221325329

    2. 进入 Reference类中,成员变量 referent用来保存新创建对象的引用,queue是一个类似链表的结构,并且使用 next指向下一个引用。

      image-20240228221558640

    3. 所以 ReferenceQueue的引用关系是通过 Reference节点应用起来的,其本身只保存了一个 head,在其 enqueue()方法中通过 Reference中的 next将它们的关系串联起来,之后再通过 poll()remove()进行相关操作。

      image-20240228222310527

  • 用来存储关联的且被 GC 的软引用,弱引用和虚引用。

    • 创建引用对象时指定了 ReferenceQueue,当引用对象指向的对象达到合适的状态时,GC 会将引用对象本身添加到队列中,方便我们处理它。

      /*
      weakRef 为引用对象,它引用的是字符串 "abc" 实例。
      当 "abc" 实例只有此弱引用时,在垃圾回收时就会被回收掉了,并且会将 weakRef 对象放入 referenceQueue 中。
       */
      String str = new String("abc");  // 强引用
      ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
      WeakReference<String> weakRef = new WeakReference<>(str, referenceQueue);  // 弱引用
      

一个详细的例子演示引用队列的作用,请看:

  1. 创建 NormalObject类。

    public class NormalObject {
        public String name;
    
        public NormalObject(String name) {
            this.name = name;
        }
    
        /**
         * 重写 finalize() 方法,被回收时打印其 name 值
         */
        @Override
        protected void finalize() {
            System.out.println("Finalizing obj " + name);
        }
    }
    
  2. 创建 NormalObjectWeakReference类。

    // 为什么要用 WeakReference 举例子?因为 WeakReference 在垃圾回收时被触发时回收掉,对于我们来说是可控。
    public class NormalObjectWeakReference extends WeakReference<NormalObject> {
        public String name;
    
        /**
         * 构造方法接收 normalObject 和 ReferenceQueue 引用队列,将 WeakReference 与引用队列关联起来
         */
        public NormalObjectWeakReference(NormalObject normalObject, ReferenceQueue<NormalObject> rq) {
            super(normalObject, rq);
            this.name = normalObject.name;
        }
    
        /**
         * 重写 finalize() 方法,被回收时打印其 name 值
         */
        @Override
        protected void finalize() {
            System.out.println("Finalizing NormalObjectWeakReference " + name);
        }
    }
    
  3. 创建测试类 ReferenceQueueTest

    public class ReferenceQueueTest {
        // 创建引用队列
        private static ReferenceQueue<NormalObject> rq = new ReferenceQueue<>();
    
        /**
         * 打印引用对象 rq 中有啥东西
         */
        private static void checkQueue() {
            Reference<NormalObject> ref;
            while ((ref = (Reference<NormalObject>) rq.poll()) != null) {
                // 打印自定义弱引用对象 NormalObjectWeakReference 中成员变量 name 的值
                System.out.println("In queue: " + ((NormalObjectWeakReference) ref).name);
                // ref.get() 方法获取弱引用对象真正引用的对象 NormalObject,并打印
                System.out.println("reference object: " + ref.get());
            }
        }
    
        public static void main(String[] args) {
            // 初始化 3 个弱引用对象
            ArrayList<WeakReference<NormalObject>> weakList = new ArrayList<>();
            for (int i = 0; i < 3; i++) {
                weakList.add(new NormalObjectWeakReference(new NormalObject("Weak " + i), rq));
                System.out.println("Created weak: " + weakList.get(i));
            }
    
            // 第一次打印引用对象中的值
            System.out.println("first time");
            checkQueue();
    
            // 触发一次垃圾回收
            System.gc();
            try {
                // 休息一段时间,让上面的垃圾回收线程执行完成
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            // 第二次打印引用对象中的值
            System.out.println("second time");
            checkQueue();
        }
        /* 输出:
        Created weak: cool.ldw.javabasic.jvm.gc.NormalObjectWeakReference@74a14482
        Created weak: cool.ldw.javabasic.jvm.gc.NormalObjectWeakReference@1540e19d
        Created weak: cool.ldw.javabasic.jvm.gc.NormalObjectWeakReference@677327b6
        first time
        Finalizing obj Weak 2
        Finalizing obj Weak 1
        Finalizing obj Weak 0
        second time
        In queue: Weak 0
        reference object: null
        In queue: Weak 2
        reference object: null
        In queue: Weak 1
        reference object: null
         */
    }
    

从输出结果中看出,第一遍历引用队列 rq没有输出结果,说明此时 rq为空,接着调用 System.gc();触发一次垃圾回收,通过打印结果能得出创建出的 3 个 NormalObject对象被回收了,但是另外 3 个 NormalObjectWeakReference并没有被回收,因为其重写的 finalize()方法中的打印方法没有执行。

最后再次遍历引用队列 rq,此时能够打印出来值了,说了 rq中已经有值,值即为 NormalObjectWeakReference对象,并且使用 ref.get()获取其真正引用的对象,即 NormalObject对象,打印出来的值为 null,说明已被 GC 回收了。

该例子演示了引用队列 ReferenceQueue的意义在于可以在外部对 ReferenceQueue进行监控,如果有对象即将被回收,相应的 Reference对象就会被放入引用队列,就可以拿到队列中的 Reference做一些事情。要是没有 ReferenceQueue则只能不断轮询 Reference对象,判断其中的 get()方法返回是否为 null来判断所引用的对象是否已被回收。

另外注意:通过 get()方法判断所引用对象是否已被回收不适用于 Phantom Reference,因为它的 get()方法返回值始终为 null。只能通过 ReferenceQueue来判断,这也是 PhantomReference类为什么只带有 ReferenceQueue的构造函数的原因。

参考资料 {#参考资料}

赞(4)
未经允许不得转载:工具盒子 » Java 面试之 GC 相关