Skip to content

JVM 垃圾回收(上)

本文主要介绍JVM中垃圾回收中的一些算法与概念。

1. 概述

垃圾回收,是指清除不再使用的对象,释放内存空间,主要回收区域在堆空间。

垃圾回收可以分为两个阶段:

  • 标记阶段:判断和标记对象是否死亡(对象是否不再使用),该阶段涉及标记算法;
  • 清除阶段:清除死亡对象,释放内存空间,该阶段涉及清除算法;

垃圾回收是一种思想,具体的落地实现就是各种垃圾回收器(Garbage Collector),在下文主要介绍各种垃圾回收器。

2. 标记算法

2.1 引用计数器算法

引用计数器算法的原理是对每个对象保存一个整数型的引用计数器,当该对象被引用时,引用计数器加一,当引用失效时,引用计数器减一,当引用计数器为零时,说明该对象不再被应用,该对象死亡,可以被回收。

但是,引用计数器算法最大的问题是不能解决循环引用,所以Java没有采用该算法。

image-20250412132224780

2.2 可达性分析算法

可达性分析算法,是以所有的“GC Roots”对象为出发点,如果无法通过“GC Roots”引用追踪到的对象,则认为该对象死亡,可以标记为垃圾对象。

image-20250412132410161

哪些对象是“GC Roots”?

  • 虚拟机栈中引用的对象(如参数、局部变量)
  • 本地方法栈中引用的对象
  • 方法区中静态属性引用的对象(如静态变量)
  • 方法区中常量引用的对象(如字符串常量池的引用)
  • 所有被同步锁(synchronized)持有的对象
  • 虚拟机内部的引用对象(类加载器、基本数据对应的Class对象、异常对象)
  • 描述虚拟机内部情况的对象(JMXBean,本地缓存代码等)

2.3 三色标记算法

三色标记算法基于可达性分析算法,为什么又提出了三色标记算法呢?

标记对象是否为垃圾对象,需要我们的程序处于一致性状态,就是在标记阶段,对象引用关系不能发生变化,这就需要用户线程暂停运行,GC线程开始工作,这就是STW(Stop The World)时间。但是,如果堆中存储的对象过多,这就造成STW时间过长,程序卡顿,用户体验下降。所以三色标记算法是一种并发的可达性分析算法,可以减少STW时间。

三色标记算法由于是并发的,所以要求记住对象的标记状态:未标记、标记中、已标记。所以基本思想是将对象分为三类,以三种颜色标识:

  • 白色:未标记,在初始阶段,所有的对象都是未被标记的,在整个标记阶段结束后,状态仍然是白色的对象会被视为垃圾对象;
  • 灰色:标记中,即这个对象的直接引用对象中,至少还有一个没被标记;
  • 黑色:已标记,该对象和其直接引用的所有对象都被标记过,对直接引用对象的下一级引用对象不做要求;

算法流程如下:

  1. 初始状态下,所有对象都为白色;初始化一个队列,将GC Roots对象放入队列;

    image-20250412135321085

  2. 从队列中取出一个对象,该对象的直接引用对象变为灰色,自己变为黑色(如果该对象没有直接引用,则直接变为黑色),并将灰色对象放入队列;

    image-20250412135557209

  3. 重复第二步,直至队列为空;这就是标记阶段,完成后仍然是白色状态的对象视为垃圾对象。

    image-20250412135713683

由于三色标记算法是并发的,所以存在两个问题:

  • 对象误标:就是将垃圾对象标记为非垃圾对象,例如,如果已经标记了对象H和I之后(如下图左边),其他用户线程执行了H.ref=null,此时对象I应该变为垃圾对象,但由于对象I已经被标记过了,不会重复标记,所以对象I仍然是存活对象,如下图右边。

    image-20250412143113952

    对于对象误标,可以不做处理,因为在下一次垃圾回收时,会将误标的垃圾对象清除。

  • 对象漏标:就是将非垃圾对象标记为垃圾对象,例如,如果在标记对象C的时候,其他用户线程执行了代码C.ref=null;I.ref=E;,由于对象I是已被标记状态,不会重复标记,所以最终对象E没有得到标记,将其视为垃圾对象,但对象E应该是存活对象。

    image-20250412143603999

    如果将非垃圾对象回收清除,那么将对程序造成不可预估的问题。所以,针对漏标存在两种解决方案。

再谈对象漏标的解决方案之前,有两点需要了解:

  • 写屏障(Write Barrier):是一种在对堆内存中的对象字段进行写入操作之前或之后执行的一小段代码。
  • 重新标记阶段:当三色标记算法标记完成后,会再对一小部分对象重新标记,这个阶段称为重新标记阶段。

对象漏标的解决方案:

  • 增量更新:当黑色对象引用了白色对象(如上图中的I.ref=E),我们可以把黑色对象记录下来,在重新标记阶段,以该黑色对象为根,重新扫描标记,通过这种方式,被黑色对象引用的白色对象最终就会变成黑色,从而变为存活状态。垃圾回收器CMS就是使用这种方法的。
  • 原始快照:当灰色对象断开了自己的直接引用对象时(如上图中的C.ref=null),我们可以把直接引用对象(白色对象)记录下来,在在重新标记阶段再以白色对象为根,对它的引用进行扫描,从而避免了漏标的问题。但是原始快照方式有可能会产生误标问题。垃圾回收器G1就是使用这种方法的。

3. 清除算法

在标记阶段完成后,就可以使用清除算法清除垃圾对象了。

3.1 标记-清除算法

在标记完成后,对对内存从头到尾进行遍历,如果发现某个对象没有标记为可达对象,则将其进行回收。

image-20250412151215204

标记-清除算法的优点:

  • 实现简单;
  • 不会移动对象,也就不会修改对象的引用地址。

标记-清除算法的缺点:

  • 效率不高,需要两次遍历堆内存,一次用于标记,一次用于清除,效率相对较低;

  • 会造成内存碎片,即空闲的内存空间不连续。

3.2 复制算法

将内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存块中的存活对象复制到未被使用的内存块中,之后交换两个内存块的角色,完成垃圾回收。

image-20250412151645322

复制算法的优点:

  • 复制阶段就是标记阶段,效率高。当需要进行垃圾回收时,复制算法会从根集合(例如,栈中的引用、静态变量等)开始遍历对象图。它不是像标记算法那样给每个存活的对象打上标记,而是直接将遍历到的存活对象复制到另一个空闲的内存区域中。
  • 不会产生内存碎片。

复制算法的缺点:

  • 浪费内存空间,因为将内存空间分为了两块,每次只使用一块内存空间;
  • 如果在对象存活率较高时(垃圾较少)时,效率较低;

3.3 标记-整理算法

在标记完成后,将所有的存活对象按顺序整理到内存的一端。

image-20250412152156633

标记-整理算法的优点:

  • 不产生内存碎片

标记-整理算法的缺点:

  • 效率相对较低:除了标记和清除之外,还需要进行整理操作,移动对象会带来额外的开销,尤其是在存活对象较多的情况下,效率会更低。
  • 需要暂停用户线程: 在整理过程中,需要暂停所有用户线程,以保证对象引用的正确性,这可能会导致较长的停顿时间。

4. Java中的引用

在JDK 1.2 之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,这4种引用强度依次减弱。除强引用外,其他3种引用均可以在java.lang.ref包中找到,如下:

image-20250412162649452

4.1 强引用(Strong Reference)

这是最常见也是最普通的引用类型。当我们使用 new 关键字创建一个对象并将其赋值给一个变量时,这个变量就持有了该对象的强引用。例如:

java
Object obj = new Object();

只要对象有任何强引用指向它,那么垃圾回收器就永远不会回收这个对象。即使 JVM 的内存空间不足,就算抛出 OutOfMemoryError 错误,也不会回收强引用的对象。

4.2 软引用(Soft Reference)

软引用通过 java.lang.ref.SoftReference 类来实现。一个对象如果只被软引用所指向,那么当 JVM 认为内存足够时,可以选择不回收该对象。但是,当 JVM 内存不足(即将发生 OutOfMemoryError)时,一定会回收软引用的对象。

例如:

java
// -Xmx100m -Xms100m
public static void main(String[] args) {
    byte[] obj = new byte[1024*1024*50];
    SoftReference<Object> softReference = new SoftReference<>(obj);
    obj = null;  // 解除强引用
    System.out.println("初始状态:" + softReference.get());

    System.gc();
    System.out.println("gc后:" + softReference.get());

    byte[] obj1 = new byte[1024*1024*50];
    System.out.println("最终:" + softReference.get());
}

结果如下:

txt
初始状态:[B@1edf1c96
gc后:[B@1edf1c96
最终:null

可以看到,在内存充足时,软引用指向的对象可以存活,但是内存不足时,会回收软引用指向的对象。

软引用可以用来实现缓存,当内存充足时保留缓存以提高性能,当内存不足时则释放缓存,避免程序崩溃。

4.3 弱引用(Weak Reference)

弱引用通过 java.lang.ref.WeakReference 类来实现。无论当前内存是否充足,只要垃圾回收器在下一次执行垃圾回收时发现了只被弱引用指向的对象,都会回收这些对象。

例如:

java
public static void main(String[] args) {
    byte[] obj = new byte[1024];
    WeakReference<Object> weakReference = new WeakReference<>(obj);
    obj = null;
    System.out.println("初始状态:" + weakReference.get());

    System.gc();
    System.out.println("GC后:" + weakReference.get());
}

结果如下:

java
初始状态:[B@1edf1c96
GC后:null

弱引用和软引用一样,可以用作缓存。

WeakHashMap使用弱引用(WeakReference)作为键(key)。当垃圾回收器回收了一个只被 WeakHashMap 的键引用的对象时,WeakHashMap 会在下次对其进行操作(例如 get(), put(), remove(), size() 等)时,或者在垃圾回收器后台运行的过程中,自动移除与这个被回收的键关联的键值对。

例如:

java
public class WeakHashMapDemo {
    private static WeakHashMap<Object, String> cache = new WeakHashMap<>();

    public static void main(String[] args) throws InterruptedException {
        // 创建一些输入对象
        Object input1 = new Object();

        // 首次获取结果,会执行计算并放入缓存
        System.out.println("Result 1: " + getResult(input1));
        // 再次获取结果,会从缓存中获取
        System.out.println("Result 1: " + getResult(input1));

        input1 = null; // 解除强引用

        System.gc();  // 垃圾回收弱引用对象
        Thread.sleep(1000);  // 睡眠1秒,让垃圾回收完成
        System.out.println("缓存条目:" + cache.size());
    }

    public static String getResult(Object input) {
        if (cache.containsKey(input)) {
            System.out.println("从缓存中获取结果:" + input.hashCode());
            return cache.get(input);
        } else {
            System.out.println("执行昂贵的计算:" + input.hashCode());
            String result = ExpensiveComputation.compute(input);
            cache.put(input, result);
            return result;
        }
    }
}

class ExpensiveComputation {
    public static String compute(Object input) {
        // 模拟耗时计算
        try {
            Thread.sleep(100); // 模拟计算延迟
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Result for " + input.hashCode();
    }
}

结果如下:

txt
执行昂贵的计算:517938326
Result 1: Result for 517938326
从缓存中获取结果:517938326
Result 1: Result for 517938326
缓存条目:0

4.4 虚引用(Phantom reference)

虚引用通过 java.lang.ref.PhantomReference 类来实现。虚引用是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。

虚引用唯一的目的是在对象被垃圾回收器回收时收到一个系统通知。虚引用必须与一个引用队列联合使用,当垃圾回收器准备回收一个只被虚引用指向的对象时,它会将这个虚引用对象放入与之关联的引用队列中。

例如:

java
public static void main(String[] args) throws InterruptedException {
    // 引用队列
    ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
    PhantomReference<Object> phantomReference = new PhantomReference<>(new Object(), referenceQueue);
    System.out.println(phantomReference.get());
    System.out.println(referenceQueue.poll());

    System.gc();
    Thread.sleep(1000);  // 等待1秒待垃圾回收完成

    Reference<?> reference = referenceQueue.poll();
    if(reference != null){
        System.out.println(reference);
        // 另开线程,完成清理工作
        new Thread(()->{
            System.out.println("做清理工作");
        }).start();
    }
}

结果如下:

txt
null
null
java.lang.ref.PhantomReference@1edf1c96
做清理工作

4.5 终结器引用(Final Reference)

FinalReference 是 Java 虚拟机(JVM)内部使用的一种特殊类型的引用。它并不像 SoftReferenceWeakReferencePhantomReference 那样是公开的 API 类,开发者通常不会直接使用它。

FinalReference 的主要作用是用于管理那些定义了 finalize() 方法的对象。

当一个对象符合垃圾回收的条件时,如果该对象定义了 finalize() 方法,JVM 并不会立即回收这个对象。而是会执行以下步骤:

  1. 创建 FinalReference 对象: JVM 会创建一个 FinalReference 对象,这个对象会持有对即将被回收的原始对象的引用。
  2. 加入 Finalizer 队列: 这个 FinalReference 对象会被放入一个特殊的队列中,通常称为 Finalizer 队列(Finalizer Queue)。
  3. Finalizer 线程处理: JVM 会有一个专门的 Finalizer 线程(Finalizer Thread)来处理这个队列。这个线程会从队列中取出 FinalReference 对象,并调用其引用的原始对象的 finalize() 方法。

因此,FinalReference 的存在是为了确保对象的 finalize() 方法在对象被真正回收之前有机会被执行。

java
class FinalReference<T> extends Reference<T> {

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

    @Override
    public boolean enqueue() {
        throw new InternalError("should never reach here");
    }
}

5. 其他概念

5.1 垃圾回收类型

在JVM中,根据回收的目标区域和触发时机的不同,通常将 GC 分为以下几种类型:

  • Minor GC:主要针对 新生代(Young Generation)进行垃圾回收。新生代通常包含 Eden 区和两个 Survivor 区(S0 和 S1)。
  • Major GC:主要针对 老年代(Old Generation)进行垃圾回收。老年代中存放的是经过多次 Minor GC 仍然存活下来的对象,这些对象通常生命周期较长。
  • Full GC:对 整个堆内存(包括新生代、老年代,以及方法区,取决于 JVM 版本和垃圾回收器 进行全面的垃圾回收。

关于三种GC的特性对比表如下:

特性Minor GC (Young GC)Major GC (Old GC)Full GC
目标区域新生代老年代整个堆(包括方法区/元空间)
触发时机Eden 区满老年代满等老年代满、System.gc()等
频率较高较低最低
速度较快较慢最慢
暂停时间较短较长最长
影响较小较大最大

注意:Major GC 和 Full GC 的概念有时会混淆。 在某些语境下,"Major GC" 可能特指对老年代的回收,而在另一些语境下,它可能被用作 "Full GC" 的同义词,指清理整个堆。为了避免混淆,最好根据具体的垃圾回收器和语境来理解。

5.2 System.gc()

在默认情况下,通过System.gc()Runtime.getRuntime().gc(),会显式地触发Full GC ,同时对老年代和新生代进行垃圾回收,尝试释放不可达对象占用的内存。但是,System.gc()并不保证垃圾回收一定会执行。

我们可以通过虚拟机参数-XX:+DisableExplicitGC禁用显式垃圾回收。

java
public static void gc1() {
    {
        byte[] obj = new byte[1024 * 1024 * 5];
    }
    System.gc();
}

上面代码并不会回收obj所指向的数组对象,因为局部变量表插槽复用的原因,obj仍然存在局部变量表中,而且指向对象。下面的代码就会回收obj所指向的数组对象。

java
public static void gc2() {
    {
        byte[] obj = new byte[1024 * 1024 * 5];
    }
    int a = 1;
    System.gc();
}

5.3 finalize()

Object类中存在finalize()方法,其作用是当该对象被作为垃圾时,如果重写了finalize()方法,那么会执行该方法。finalize()方法最初设计的目的是允许对象在被垃圾回收器回收之前执行一些清理操作,例如释放对象持有的外部资源(如文件句柄、网络连接等),但是,finalize() 方法存在很多严重的缺点,因此在现代 Java 开发中强烈建议避免使用它。

finalize()方法工作原理:

  • 当垃圾回收器判断一个对象已经不再被任何存活的线程直接或间接地引用时,该对象就符合垃圾回收的条件。
  • 如果这个对象定义了 finalize() 方法,那么在正式回收这个对象之前,垃圾回收器会先将这个对象放入一个特殊的队列(通常称为 Finalizer 队列)。
  • JVM 会有一个专门的 Finalizer 线程来处理这个队列。这个线程会从队列中取出对象,并调用该对象的 finalize() 方法。
  • 只有当 finalize() 方法执行完毕(或者超时),垃圾回收器才会真正回收这个对象的内存。

我们可以在finalize()中复活对象:

java
public class ExplicitGCDemo {
    private static Object obj = null;
    
    public static void main(String[] args) throws InterruptedException {
        new Object(){
            @Override
            protected void finalize() throws Throwable {
                super.finalize();
                System.out.println("finalize()被执行了");
                obj = this;
            }
        };

        System.gc();
        Thread.sleep(200);

        System.out.println(obj);
    }
}

结果如下:

txt
finalize()被执行了
gc.explicit.ExplicitGCDemo$1@368102c8

5.4 内存泄漏与内存溢出

内存泄漏:内存泄漏指的是程序在申请内存后,无法释放已经不再使用的内存空间,导致这些内存被程序一直占用,直到程序结束或者系统资源耗尽。可能的原因如下:

  • 不再需要的对象仍然被引用:这是最常见的内存泄漏原因。例如,一个对象不再被使用,但是仍然被其他对象的生命周期更长的引用所持有,导致垃圾回收器无法回收它。
  • 未关闭的资源:比如打开的文件流、数据库连接、网络连接等,如果在使用完毕后没有及时关闭释放,这些资源占用的内存可能不会被回收。
  • 缓存管理不当:如果缓存中的数据没有设置过期策略或者移除机制,可能会无限增长,导致内存泄漏。
  • 静态集合持有长期不用的对象:静态集合的生命周期通常很长,如果向其中添加了不再需要的对象,并且没有及时清理,这些对象会一直被持有。

内存溢出:内存溢出指的是程序在申请内存时,没有足够的内存空间可供使用,从而导致程序抛出异常或崩溃。内存溢出的可能原因如下:

  • 内存泄漏:由于内存泄漏,导致可用内存变少,所以分配内存空间时不足;
  • 内存空间过小:由于硬件设备的限制,或者虚拟机参数设置的堆内存空间过小,导致内存空间过小,都会造成内存溢出;

5.5 STW

STW,全称Stop The World,指的是垃圾回收发生过程中,会产生应用程序(用户线程)的停顿,停顿产生时整个程序都会被暂停,没有任何响应,这个停顿称为STW。

在垃圾回收的可达性分析算法中,分析工作必须在一个一致性的快照中进行,所谓一致性指的是分析期间整个执行系统看起来像被冻结在某个时间点上,因为如果分析过程中引用关系还在不断变化,则分析结果的准确性无法保证。

STW事件和采用哪款垃圾回收器无关,所有的垃圾回收器都有这个事件。哪怕G1也不能完全避免STW事件的发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了停顿事件。STW是JVM在后台自动发器和完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉。

所以在开发中尽量不要使用System.gc(),会引起STW事件的发生。

5.6 安全点与安全区域

在JVM进行垃圾回收时,需要所有的用户线程停止下来,但是,用户线程并不能在任何地方停下来,只有在特定的地方才能停止,这些地方称为安全点。

为了不频繁地中断程序执行,安全点不会在所有的字节码指令之间都存在。JVM 会在一些特定的位置设置安全点,通常是那些执行时间较长或者具有特定语义的指令序列之后,例如:

  • 方法返回之前(Method Return)
  • 循环的末尾(Loop Back Edge)
  • 调用其他方法之后(Method Call)
  • 可能抛出异常的位置

当用户线程到达安全点时,怎么知道自己需要停下来呢?原来当垃圾回收需要停止用户线程的时候,将设置某个中断标志位,各个线程不断轮询这个标志位,发现需要挂起时,自己运行到最近的安全点,更新完 OopMap 才能挂起。

什么是OopMap

在局部变量表中,对象引用和基本数据类型都是占据插槽的,JVM怎么知道哪个插槽存储的是对象引用,哪个插槽存储的是基本数据类型呢?如果在进行垃圾回收时,需要遍历每个栈帧的局部变量表,然后判断哪个插槽存储的是对象引用,无疑会造成效率的低下,STW时间变长,所以OopMap应运而生。

oopmap 的作用就是记录在特定的时间点(通常是安全点),哪些栈帧的哪些局部变量槽(Slot)以及哪些寄存器中存放着指向堆中对象的引用。

oopmap 的工作原理:

  1. 生成时机: oopmap 是在类加载即时编译过程中生成的。当 JVM 加载一个类或 JIT 编译器编译一个方法时,会分析该方法在特定位置(安全点)上,哪些局部变量和哪些寄存器可能持有对象引用,并将这些信息记录在 oopmap 中。
  2. 存储位置: oopmap 通常与方法代码关联存储,例如在代码缓存(Code Cache)中。
  3. 使用时机: 当 JVM 触发垃圾回收,并且需要进行根集合扫描时,对于每一个线程的栈帧,垃圾回收器会根据当前执行到的位置(通常是安全点),查找对应的 oopmap。通过 oopmap,垃圾回收器就能精确地知道哪些位置存储着对象引用,从而进行正确的标记。

当我们说垃圾回收需要停止用户线程时,会设置一个中断标志位,用户线程不断轮询标志位。如果用户线程正处于 Sleep 或者 Blocked 状态,该怎么办?这些线程他不会自己走到安全点,就停不下来了。这个时候,安全点解决不了问题,需要引入 安全区域 (Safe Region)

安全区域指的是,在某段代码中,引用关系不会发生变化,线程执行到这个区域是可以安全停下进行 GC 的。因此,我们也可以把 安全区域 看做是扩展的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。那样当这段时间里虚拟机要发起 GC 时,就不必去管这些在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否处于 STW 状态,如果是,则需要等待直到恢复。

6. 垃圾回收器

在Java的发展历史中,出现了以下的垃圾回收器,下文会逐一介绍这些垃圾回收器:

  • Serial
  • Serial Old
  • ParNew
  • Parallel
  • Parallel Old
  • CMS
  • G1
  • ZGC