返回文章列表
JVM虚拟机
JVMGC垃圾回收GC Roots

06自动垃圾回收

在 C/C++ 这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要由程序员手动释放,否则就会出现内存泄漏。我们称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收。内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累最终会导致内存溢出。

C/C++ 内存管理对比

Java 为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection,简称 GC)机制。通过垃圾回收器对不再使用的对象完成自动回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如 C#、Python、Go 都拥有自己的垃圾回收器。

Java 内存管理与自动垃圾回收

学习垃圾回收机制有以下几个重要目的:

  • 解决系统僵死问题:大厂系统出现的许多僵死问题都与频繁的垃圾回收有关,合理的 GC 调优可以避免这类问题。
  • 性能优化:对垃圾回收器进行合理设置可以有效提升程序的执行性能。
  • 应对高频面试题:常见的垃圾回收器、垃圾回收算法、四种引用、项目中使用的垃圾回收器等都是常考知识点。

Java 虚拟机在运行 Java 程序过程中管理的内存区域,称之为运行时数据区,包括方法区、堆、Java 虚拟机栈、本地方法栈、程序计数器。

运行时数据区总览

线程不共享的部分(Java 虚拟机栈、本地方法栈、程序计数器)都是伴随线程的创建而创建、线程的销毁而销毁。方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。所以需要使用垃圾回收器回收的只有方法区和堆区。

1.方法区的回收

方法区中能回收的内容主要就是不再使用的类。判定一个类可以被卸载,需要同时满足下面三个条件:

  1. 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
  2. 加载该类的类加载器已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用。

方法区的回收条件

如果需要手动触发垃圾回收,可以调用 System.gc() 方法。注意事项:调用该方法并不一定会立即回收垃圾,仅仅是向 Java 虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收由 Java 虚拟机自行判断。

// 手动触发垃圾回收(不保证立即执行)
System.gc();

开发中此类场景一般很少出现,主要在如 OSGi、JSP 的热部署等应用场景中。每个 jsp 文件对应一个唯一的类加载器,当一个 jsp 文件修改了,就直接卸载这个 jsp 类加载器,重新创建类加载器,重新加载 jsp 文件。

2.堆回收-引用计数法和可达性分析法

2.1判断对象是否可以回收

Java 中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。比如下面代码的内存结构图:栈上的局部变量引用了堆上的实例对象。

堆上对象的引用关系

只有无法通过引用获取到对象时,该对象才能被回收。常见的判断方法有两种:引用计数法和可达性分析法。

2.2引用计数法

引用计数法会为每个对象维护一个引用计数器,当对象被引用时加 1,取消引用时减 1。

引用计数法

引用计数法的优点是实现简单,C++ 中的智能指针就采用了引用计数法。但是它也存在两个主要缺点:

  1. 每次引用和取消引用都需要维护计数器,对系统性能会有一定影响。
  2. 存在循环引用问题:当 A 引用 B、B 同时引用 A 时会出现对象无法回收的问题。

循环引用问题

图中 A 和 B 实例对象在栈上已经没有变量引用了,但由于计数器还是 1 无法回收,出现了内存泄漏。

2.3可达性分析算法

Java 使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。如果从某个对象到 GC Root 是可达的,对象就不可被回收。

可达性分析算法

图中 A 到 B 再到 C 和 D 形成了一个引用链,只要在引用链上,对象就不会被回收。

2.4GC Root 对象

哪些对象被称之为 GC Root 对象呢?主要有以下几类:

  • 线程 Thread 对象,引用线程栈帧中的方法参数、局部变量等。
  • 系统类加载器加载的 java.lang.Class 对象,引用类中的静态变量。
  • 监视器对象,用来保存同步锁 synchronized 关键字持有的对象。
  • 本地方法调用时使用的全局对象。

Class 对象作为 GC Root

2.5使用 MAT 查看 GC Root

通过 arthas 和 eclipse Memory Analyzer (MAT) 工具可以查看 GC Root。MAT 是 eclipse 推出的 Java 堆内存检测工具,操作步骤如下:

  1. 使用 arthas 的 heapdump 命令将堆内存快照保存到本地磁盘中。
  2. 使用 MAT 工具打开堆内存快照文件。
  3. 选择 GC Roots 功能查看所有的 GC Root。
# arthas 中导出堆内存快照
heapdump 目录/test2.hprof

MAT 中的 GC Root 主要包含:系统类加载器加载的 java.lang.Class 对象、本地方法调用时使用的全局对象、线程 Thread 对象、监视器对象。

MAT 查看 GC Root

3.堆回收-常见的对象引用

可达性算法中描述的对象引用,一般指的是强引用,即 GC Root 对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用之外,Java 中还设计了几种其他引用方式:软引用、弱引用、虚引用、终结器引用。

3.1软引用

软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用,软引用常用于缓存中。

软引用

软引用的执行过程如下:

  1. 将对象使用软引用包装起来:new SoftReference<对象类型>(对象)。
  2. 内存不足时,虚拟机尝试进行垃圾回收。
  3. 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。
  4. 如果依然内存不足,抛出 OutOfMemoryError 异常。
public class SoftReferenceDemo2 {
    public static void main(String[] args) throws IOException {
        byte[] bytes = new byte[1024 * 1024 * 100];
        SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);
        bytes = null;
        System.out.println(softReference.get());
 
        byte[] bytes2 = new byte[1024 * 1024 * 100];
        System.out.println(softReference.get());
    }
}

软引用对象本身怎么回收呢? 如果软引用对象里边包含的数据已经被回收了,那么软引用对象本身其实也可以被回收。SoftReference 提供了一套队列机制:

  1. 软引用创建时,通过构造器传入引用队列。
  2. 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列。
  3. 通过代码遍历引用队列,将 SoftReference 的强引用删除。
public class SoftReferenceDemo3 {
    public static void main(String[] args) throws IOException {
        ArrayList<SoftReference> softReferences = new ArrayList<>();
        ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>();
        for (int i = 0; i < 10; i++) {
            byte[] bytes = new byte[1024 * 1024 * 100];
            SoftReference studentRef = new SoftReference<byte[]>(bytes, queues);
            softReferences.add(studentRef);
        }
 
        SoftReference<byte[]> ref = null;
        int count = 0;
        while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {
            count++;
        }
        System.out.println(count);
    }
}

软引用的典型场景是缓存,使用软引用实现学生信息的缓存,能支持内存不足时清理缓存。

3.2弱引用、虚引用、终结器引用

  • 弱引用:整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用,弱引用主要在 ThreadLocal 中使用。弱引用对象本身也可以使用引用队列进行回收。

弱引用

  • 虚引用:也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java 中使用 PhantomReference 实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。

  • 终结器引用:在对象需要被回收时,终结器引用会关联对象并放置在 Finalizer 类中的引用队列中,稍后由一条 FinalizerThread 线程从队列中获取对象,然后执行对象的 finalize 方法,在对象第二次被回收时,该对象才真正被回收。在这个过程中可以在 finalize 方法中再将自身对象使用强引用关联上,但是不建议这样做。

4.垃圾回收算法

4.1核心思想与评价标准

垃圾回收要做的有两件事:

  1. 找到内存中存活的对象。
  2. 释放不再存活对象的内存,使得程序能再次利用这部分空间。

Java 垃圾回收过程会通过单独的 GC 线程来完成,但是不管使用哪一种 GC 算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为 Stop The World(简称 STW),如果 STW 时间过长则会影响用户的使用。

STW 与用户代码执行交替

判断 GC 算法是否优秀,可以从三个方面来考虑:

1. 吞吐量

吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值:

吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC 时间)

吞吐量数值越高,垃圾回收的效率就越高。比如虚拟机总共运行了 100 分钟,其中 GC 花掉 1 分钟,那么吞吐量就是 99%。

2.最大暂停时间

最大暂停时间指的是所有在垃圾回收过程中的 STW 时间最大值。最大暂停时间越短,用户使用系统时受到的影响就越短。

3.堆使用效率

不同垃圾回收算法对堆内存的使用方式不同。标记清除算法可以使用完整的堆内存;而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。

上述三种评价标准不可兼得。一般来说,堆内存越大,最大暂停时间就越长;想要减少最大暂停时间,就会降低吞吐量。不同垃圾回收算法适用于不同的场景。

三种评价标准对比

4.2标记-清除算法

标记-清除算法的核心思想分为两个阶段:

  1. 标记阶段:将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。
  2. 清除阶段:从内存中删除没有被标记也就是非存活对象。

标记-清除算法

优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。

缺点:

  1. 碎片化问题:由于内存是连续的,对象被删除之后,内存中会出现很多细小的可用内存单元。如果需要一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。

碎片化问题

  1. 分配速度慢:由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。

4.3复制算法

复制算法的核心思想是:

  1. 准备两块空间 From 空间和 To 空间,每次在对象分配阶段,只能使用其中一块空间(From 空间)。
  2. 在垃圾回收 GC 阶段,将 From 中存活对象复制到 To 空间。
  3. 将两块空间的 From 和 To 名称互换。

复制算法

优点:

  • 吞吐量高:复制算法只需要遍历一次存活对象复制到 To 空间即可,比标记-整理算法少了一次遍历过程,性能较好(但不如标记-清除算法,因为标记清除算法不需要进行对象的移动)。
  • 不会发生碎片化:复制算法在复制之后就会将对象按顺序放入 To 空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。

缺点:内存使用效率低,每次只能让一半的内存空间来为创建对象使用。

4.4标记-整理算法

标记-整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。核心思想分为两个阶段:

  1. 标记阶段:将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。
  2. 整理阶段:将存活对象移动到堆的一端,清理掉存活对象的内存空间。

标记-整理算法

优点:

  • 内存使用效率高:整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。
  • 不会发生碎片化:在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间。

缺点:整理阶段的效率不高。整理算法有很多种,比如 Lisp2 整理算法需要对整个堆中的对象搜索 3 次,整体性能不佳。可以通过 Two-Finger、表格算法、ImmixGC 等高效的整理算法优化此阶段的性能。

4.5分代垃圾回收算法

现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。分代垃圾回收将整个内存区域划分为年轻代和老年代:

分代垃圾回收内存划分

  • Eden 区(伊甸园):新创建的对象首先放入此区。
  • Survivor 区(幸存区):分为 S0 和 S1,用于存放 Minor GC 后存活的对象。
  • Old 区(老年代):存放存活时间比较长的对象。

分代回收流程:

  1. 分代回收时,创建出来的对象首先会被放入 Eden 伊甸园区。
  2. 随着 Eden 区对象越来越多,如果 Eden 区满,新创建的对象无法放入,就会触发年轻代的 GC,称为 Minor GC 或 Young GC。Minor GC 会把 Eden 中和 From 需要回收的对象回收,把没有回收的对象放入 To 区。

Minor GC 过程

  1. 接下来,S0 会变成 To 区,S1 变成 From 区。当 Eden 区满时再往里放入对象,依然会发生 Minor GC。此时会回收 Eden 区和 S1(From)中的对象,并把 Eden 和 From 区中剩余的对象放入 S0。每次 Minor GC 中都会为对象记录他的年龄,初始值为 0,每次 GC 完加 1。

  2. 如果 Minor GC 后对象的年龄达到阈值(最大 15,默认值和垃圾回收器有关),对象就会被晋升至老年代。当老年代中空间不足,无法放入新的对象时,先尝试 Minor GC,如果还是不足,就会触发 Full GC,Full GC 会对整个堆进行垃圾回收。如果 Full GC 依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出 OutOfMemoryError 异常。

晋升老年代与 Full GC

4.6为什么分代 GC 算法要把堆分成年轻代和老年代?

堆内存中对象的特性:

  • 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
  • 老年代中会存放长期存活的对象,比如 Spring 的大部分 bean 对象,在程序启动之后就不会被回收了。
  • 在虚拟机的默认设置中,新生代大小要远小于老年代的大小。

新生代与老年代对象特性

分代 GC 算法将堆分成年轻代和老年代的主要原因有:

  1. 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
  2. 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
  3. 分代的设计中允许只回收新生代(Minor GC),如果能满足对象分配的要求就不需要对整个堆进行回收(Full GC),STW 时间就会减少。

5.垃圾回收器

垃圾回收器是垃圾回收算法的具体实现。由于垃圾回收器分为年轻代和老年代,除了 G1 之外其他垃圾回收器必须成对组合进行使用。

垃圾回收器组合关系

图中可以看到,Serial + Serial Old、ParNew + CMS、Parallel Scavenge + Parallel Old 都是成对组合。G1 是独立的,可以同时处理年轻代和老年代。从 JDK9 之后 ParNew 被废弃,JDK14 中 CMS 也被废弃。

5.1 Serial / Serial Old 垃圾回收器

Serial 是一种单线程串行回收年轻代的垃圾回收器,使用复制算法。

Serial 垃圾回收器

  • 优点:单 CPU 处理器下吞吐量非常出色。
  • 缺点:多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待。
  • 适用场景:Java 编写的客户端程序或者硬件配置有限的场景。

Serial Old 是 Serial 垃圾回收器的老年代版本,采用单线程串行回收,使用标记-整理算法。使用 -XX:+UseSerialGC 让新生代、老年代都使用串行回收器。

5.2 ParNew 垃圾回收器

ParNew 垃圾回收器本质上是对 Serial 在多 CPU 下的优化,使用多线程进行垃圾回收,使用复制算法。使用 -XX:+UseParNewGC 让新生代使用 ParNew 回收器,老年代使用串行回收器。

  • 优点:多 CPU 处理器下停顿时间较短。
  • 缺点:吞吐量和停顿时间不如 G1,所以在 JDK9 之后不建议使用。
  • 适用场景:JDK8 及之前的版本中,与 CMS 老年代垃圾回收器搭配使用。

5.3 CMS(Concurrent Mark Sweep)垃圾回收器

CMS 垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。使用标记-清除算法。参数:-XX:+UseConcMarkSweepGC。

  • 优点:系统由于垃圾回收出现的停顿时间较短,用户体验好。
  • 缺点:内存碎片问题、退化问题、浮动垃圾问题。
  • 适用场景:大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等。

CMS 垃圾回收器

CMS 执行步骤:

  1. 初始标记:用极短的时间标记出 GC Roots 能直接关联到的对象(STW)。
  2. 并发标记:标记所有的对象,用户线程不需要暂停。
  3. 重新标记:由于并发标记阶段有些对象发生了变化,存在错标、漏标等情况,需要重新标记(STW)。
  4. 并发清理:清理死亡的对象,用户线程不需要暂停。

CMS 执行步骤

CMS 存在的问题:

  1. CMS 使用了标记-清除算法,垃圾收集结束之后会出现大量的内存碎片,CMS 会在 Full GC 时进行碎片的整理,这样会导致用户线程暂停。可以使用 -XX:CMSFullGCsBeforeCompaction=N 参数(默认 0)调整 N 次 Full GC 之后再整理。
  2. 无法处理在并发清理过程中产生的"浮动垃圾",不能做到完全的垃圾回收。
  3. 如果老年代内存不足无法分配对象,CMS 就会退化成 Serial Old 单线程回收老年代。

并发线程数:

在 CMS 中并发阶段运行时的线程数可以通过 -XX:ConcGCThreads 参数设置,默认值为 0,由系统计算得出。计算公式为:

ConcGCThreads = (ParallelGCThreads + 3) / 4

ParallelGCThreads 是 STW 停顿之后的并行线程数,由处理器核数决定:

  • 当 CPU 核数小于 8 时,ParallelGCThreads = CPU 核数
  • 否则 ParallelGCThreads = 8 + (CPU 核数 - 8) * 5 / 8

例如逻辑处理器有 12 个时:ParallelGCThreads = 8 + (12 - 8) * 5 / 8 = 10,ConcGCThreads = (10 + 3) / 4 = 3。并发标记和并发清理阶段会使用 3 个线程并行处理,重新标记阶段会使用 10 个线程处理。由于 CPU 的核心数有限,并发阶段会影响用户线程执行的性能。

5.4 Parallel Scavenge / Parallel Old 垃圾回收器

Parallel Scavenge 是 JDK8 默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点,使用复制算法。

Parallel Scavenge 垃圾回收器

  • 优点:吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数。
  • 缺点:不能保证单次的停顿时间。
  • 适用场景:后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出。

常用参数:

Parallel Scavenge 允许手动设置最大暂停时间和吞吐量。Oracle 官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。

  • -XX:MaxGCPauseMillis=n:设置每次垃圾回收时的最大停顿毫秒数。
  • -XX:GCTimeRatio=n:设置吞吐量为 n(用户线程执行时间 = n/(n+1))。
  • -XX:+UseAdaptiveSizePolicy:让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小。

Parallel Scavenge 参数

Parallel Old 是为 Parallel Scavenge 收集器设计的老年代版本,利用多线程并发收集,使用标记-整理算法。使用 -XX:+UseParallelGC 或 -XX:+UseParallelOldGC 可以使用 Parallel Scavenge + Parallel Old 这种组合。

5.5垃圾回收器版本推荐

垃圾回收器的组合关系虽然很多,但是针对几个特定的版本,比较好的组合选择如下:

  • JDK8 及之前:ParNew + CMS(关注暂停时间)、Parallel Scavenge + Parallel Old(关注吞吐量)、G1(JDK8 之前不建议,较大堆并且关注暂停时间)
  • JDK9 之后:G1(默认)

从 JDK9 之后,由于 G1 日趋成熟,JDK 默认的垃圾回收器已经修改为 G1,强烈建议在生产环境上使用 G1。G1 的实现原理将在下一篇博客中介绍,更多前沿技术 ZGC、Shenandoah 将在低延迟收集器篇中介绍。