最全的 JVM 面试知识点(二):垃圾收集

1,201 阅读10分钟

上一篇介绍了 Java 虚拟机内存的运行时数据区。本章将会介绍 Java 中的垃圾收集算法与常用的垃圾收集器。

在涉及 Java 相关的面试中,面试官经常会让讲讲 Java 中的垃圾收集相关的理解和常见的分类。可见,光就应付面试而言,JVM 的垃圾收集也对每一位 Java 开发者很重要。除此之外,对于我们了解和解决 Java 应用的性能时,也很有帮助。

本文的主要内容:

  • Java 中的引用
    • 强引用
    • 软引用
    • 弱引用
    • 虚引用
  • 对象回收
    • 引用计数法
    • 可达性分析算法
  • 垃圾收集算法
    • 标记-清除算法
    • 标记-整理算法
    • 复制算法
    • 分代收集算法
  • 垃圾收集器
  • 小结

Java 中的引用

判定对象是否存活都与引用有关。在Java语言中,将引用又分为强引用、软引用、弱引用和虚引用 4 种,这四种引用强度依次逐渐减弱。下面我们一次介绍这四种引用。

强引用

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

软引用

描述一些还有用但并非必须的对象。在 Java 中用 java.lang.ref.SoftReference 类来表示。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。SoftReference 类有两个构造函数:

    public SoftReference(T referent) {
        super(referent);
        this.timestamp = clock;
    }
    
    public SoftReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
        this.timestamp = clock;
    }

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。下面是 SoftReference 一个使用示例:

```java
    public static void main(String[] args) throws InterruptedException {
    SoftReference<String> sr = new SoftReference<>("hello");
    System.out.println(sr.get());
}
```

弱引用

描述非必须的对象,Java 中常通过使用弱引用来避免内存泄漏。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

    public WeakReference(T referent) {
        super(referent);
    }

    public WeakReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }

    public static void main(String[] args) throws InterruptedException {
    WeakReference<String> wr = new WeakReference<>("hello");

    System.out.println(wr.get());
    System.gc();                //通知JVM的gc进行垃圾回收
    Thread.sleep(1000);
    System.out.println(wr.get());
}

同样,通过引用队列这个参数,我们便把创建的弱引用对象注册到了一个引用队列上,这样当它被垃圾回收器清除时,就会把它送入这个引用队列中,我们便可以对这些被清除的弱引用对象进行统一管理。

软引用和弱引用的区别在于,若一个对象是弱引用可达,无论当前内存是否充足它都会被回收,而软引用可达的对象在内存不充足时才会被回收,因此软引用要比弱引用强一些。

虚引用

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

    public static void main(String[] args) {
        ReferenceQueue<String> queue = new ReferenceQueue<>();
        PhantomReference<String> pr = new PhantomReference<>("hello", queue);
        System.out.println(pr.get());
    }

要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

对象回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断那些对象已经死亡(即不能再被任何途径使用的对象)。

引用计数法

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

引用计数法可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为 0。

可达性分析算法

这个算法的基本思想就是通过一系列的称为 GC Roots 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

在 Java 中哪些对象可以成为 GC Root?

  • 虚拟机栈(栈帧中的本地变量表)中的引用对象
  • 方法区中的类静态属性引用的对象
  • 方法区中的常量引用对象
  • 本地方法栈中JNI(即Native方法)的引用对象

垃圾收集算法

常用的垃圾收集算法有:标记-清除、标记-整理、复制和分代收集算法。下面依次介绍这几种垃圾收集算法。

标记-清除算法

首先标记出需要回收的对象,在标记完成后统一回收掉所有的被标记对象。

缺点:效率问题和空间问题(标记清除后会产生大量的不连续内存碎片,内存碎片过多可能会导致程序需要分配较大对象时找不到足够大的连续内存空间而不得不提前触发另一次垃圾回收动作)

标记-整理算法

标记-整理 算法采用 标记-清除 算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

复制算法

将内存划分为大小相等的两块,每次只使用其中的一块。当这块内存用完了,就将还存活的对象复制到另一块内存上,然后把已使用过的内存空间一次清理掉。

复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。每次只对其中一块进行GC,不用考虑内存碎片的问题,并且实现简单,运行高效。缺点是内存缩小了一半。

分代收集算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

年轻代(Young Generation)的回收算法

所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

新生代内存按照 8:1:1 的比例分为一个 Eden 区和两个 survivor(survivor0,survivor1) 区。一个E den 区,两个 Survivor 区(一般而言)。大部分对象在 Eden 区中生成。回收时先将 Eden 区存活对象复制到一个 survivor0 区,然后清空 Eden 区,当这个 survivor0 区也存放满了时,则将 Eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 Eden 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。

当 survivor1 区不足以存放 Eden 和 survivor0 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。

新生代发生的 GC 也叫做 Minor GC,Minor GC 发生频率比较高(不一定等 Eden 区满了才触发)。

年老代(Old Generation)的回收算法

在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

内存比新生代也大很多(大概比例是 1:2),当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率标记高。

持久代(Permanent Generation)的回收算法

用于存放静态文件,如 Java 类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区,方法区存储内容是否需要回收的判断不一样。方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面 3 个条件:

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

垃圾收集器

不同的垃圾回收器,适用于不同的场景。常用的垃圾回收器:

  • 串行(Serial)回收器是单线程的一个回收器,简单、易实现、效率高。
  • 并行(ParNew)回收器是Serial的多线程版,可以充分的利用CPU资源,减少回收的时间。
  • 吞吐量优先(Parallel Scavenge)回收器,侧重于吞吐量的控制。
  • 并发标记清除(CMS,Concurrent Mark Sweep)回收器是一种以获取最短回收停顿时间为目标的回收器,该回收器是基于 标记-清除 算法实现的。

小结

本文讲了 JVM 垃圾收集中涉及的四种对象引用的类型:强引用、软引用、弱引用和虚引用,对象死亡的判断算法:引用计数法和可达性分析。最后介绍了几种常见的垃圾收集算法。关于具体的垃圾回收器将在下一篇文章具体介绍。

订阅最新文章,欢迎关注我的公众号

微信公众号

参考
  1. 扒一扒JVM的垃圾回收机制,下次面试你准备好了吗
  2. Java 如何有效地避免OOM:善于利用软引用和弱引用