Skip to content

垃圾回收基础

垃圾收集主要是针对堆和方法区进行;程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收

垃圾回收一般回指堆内存的垃圾回收,简单介绍一下方法区的回收后重点讲解堆内存的垃圾回收

方法区的垃圾回收

方法区的垃圾回收主要回收两部分的内容:常量池中废弃的常量和不再使用的类

  • 常量回收策略:只要常量池中的常量没有被任何地方引用,就可以被回收

  • 类回收策略,需要同时满足三个条件:

    • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
    • 加载该类的类加载器已经被回收,这个条件通常很难达成
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

判断一个对象是否可以被回收

JVM 判断对象可回收有两种方法引用计数法(已淘汰)和可达性分析(主流实现)。Java 采用可达性分析,通过GC Roots作为起点,向下搜索形成引用链,不可达的对象判定为可回收。

引用计数算法

实现原理:给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收,这种方式实现起来简单、判定效率高。

弊端:两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。

java
public class ReferenceCountingGC {

    public Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGC objectA = new ReferenceCountingGC();
        ReferenceCountingGC objectB = new ReferenceCountingGC();
        objectA.instance = objectB;
        objectB.instance = objectA;

        // 对象 A 的引用次数是 1,被对象 B 引用
        // 对象 B 的引用次数是 1,被对象 A 引用
        // 两个对象行程互相循环引用,导致即使外部不再使用这两个对象,但它们永远无法被回收
    }
}

可达性分析算法

实现原理:通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。JVM 使用这种算法来判断对象是否可以被回收

GC Root

面试话术:GC Roots 包括四类对象——虚拟机栈中引用的对象方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中 JNI 引用的对象

┌─────────────────────────────────────────────────┐
│              GC Roots 四大来源                   │
├─────────────────────────────────────────────────┤
│ 1. 虚拟机栈(栈帧中的本地变量表)                 │
│    - 方法的参数                                  │
│    - 局部变量                                    │
│    - 临时变量                                    │
├─────────────────────────────────────────────────┤
│ 2. 方法区中的静态属性                            │
│    - static 修饰的字段                           │
│    - 类变量                                      │
├─────────────────────────────────────────────────┤
│ 3. 方法区中的常量                                │
│    - 字符串常量池中的引用                        │
│    - final 修饰的常量                            │
├─────────────────────────────────────────────────┤
│ 4. 本地方法栈中的 JNI 引用                       │
│    - Native 方法持有的 Java 对象引用             │
└─────────────────────────────────────────────────┘

引用类型

无论使用哪种算法,判断对象是否可以被回收,都和引用有关。Java 中具有四种不同强度的引用类型(强/软/弱/虚)

用一个例子来说明四种不同强度的引用类型

java
import java.lang.ref.*;
import java.util.*;

public class ReferenceTypes {
    public static void main(String[] args) {
        // 1. 强引用 - 被强引用的对象不会被回收
        Object strong = new Object();

        // 2. 软引用 - 被软引用的对象在内存不足时回收
        SoftReference<Object> soft = new SoftReference<>(new Object());

        // 3. 弱引用 - 被弱引用的对象在下次 GC 必回收,也就是说只能存活到下一次垃圾回收之前
        // 典型案例:ThreadLocal 中 Entry 的设计
        WeakReference<Object> weak = new WeakReference<>(new Object());

        // 4. 虚引用 - 无法获取对象,用于跟踪回收
        // 为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。
        PhantomReference<Object> phantom = new PhantomReference<>(
            new Object(), new ReferenceQueue<>()
        );
    }
}
引用类型回收时机使用场景
强引用永不回收普通对象引用
软引用内存不足 OOM 前缓存(图片缓存、数据缓存)
弱引用下次 GC 立即回收WeakHashMap、监听器
虚引用无法获取对象对象回收通知(ReferenceQueue)

垃圾回收算法

标记-清除算法(Mark-Sweep)

工作流程

  • 从 GC Root 出发遍历,将存活的对象进行标记
  • 回收所有未被标记的对象

优点:

  • 算法简单,无需移动对象

弊端

  • 标记和清除过程的效率不高
  • 产生大量不连续的内存碎片,导致无法给大对象分配内存

标记-复制算法(Mark-Copy)

工作流程

  • 把内存空间分为两份等大区域
  • 从 GC Root 出发遍历,将存活的对象进行标记
  • 把存活对象复制到空闲块,并清理原区域中的对象
  • 原区域和空闲块角色交换

优点

  • 不产生内存碎片

弊端

  • 需要双倍内存空间
  • 如果对象存活率高,那么复制算法的效率较低

应用新生代(98% 对象朝生夕死,复制效率高)

标记-整理算法(Mark-Compact)

工作流程

  • 从 GC Root 出发遍历,将存活的对象进行标记
  • 把存活的对象都向一端移动
  • 清理边界以外的内存

优点

  • 不产生内存碎片
  • 不需要双倍的空间

弊端

  • 移动对象的计算成本高,需要更新引用

应用老年代(存活率高,不适合复制)

分代回收

堆内存根据对象生命周期划分为新生代和老年代,目的是为了提高 gc 效率,可以根据分代来分别采用不同的垃圾回收算法

核心思想:为提高内存使用率,减少内存碎片;对于新生代,存放的对象都是短生命周期的,可以采用标记-复制算法;对于老年代,存放的对象都是长生命周期的,可以采用标记-整理算法

GC 类型触发条件范围频率速度
Young GCMinor GCEden 满新生代
Old GCMajor GC
(CMS 收集器特指老年代 GC)
老年代满老年代
Full GC元空间不足、老年代满整个堆(新生代 + 老年代) + 元空间最慢

垃圾收集器

以上是7个垃圾收集器,连线表示可以配合使用

垃圾回收算法是抽象的解决方案,垃圾收集器是具体的实现方案,一个收集器可能会采用一种或多种回收算法组合

Serial 收集器

Serial 收集器从字面上可以知道它是以串行的方式执行的,Serial 收集器是单线程的收集器,只会使用一个线程进行垃圾收集工作。

它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率,但是 GC 时会暂停所有用户线程 (Stop The World)。它是 Client 模式下的默认新生代收集器。

Serial Old 收集器

Serial Old 收集器是 Serial 收集器的老年代版本,同时它作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

参数:

bash
-XX:+UseSerialGC  # 启用 Serial + Serial Old

ParNew 收集器

ParNew 收集器是 Serial 收集器的多线程版本,其行为和 Serial 完全相同,可以和 CMS 收集器配合工作

默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

参数:

bash
-XX:+UseParNewGC  # 启用 ParNew + CMS
-XX:ParallelGCThreads=4  # 设置 GC 线程数

Parallel Scavenge 收集器

Parallel Scavenge 收集器是 JDK1.8 的默认收集器,同样是多线程进行 GC,关注重点是吞吐量

吞吐量计算公式:运行用户代码时间 / (运行用户代码时间 + GC 时间)

参数:

bash
-XX:+UseParallelGC          # 启用 Parallel Scavenge + Parallel Old
-XX:MaxGCPauseMillis=100    # 最大 GC 停顿时间(毫秒)
-XX:GCTimeRatio=99          # 吞吐量 = 1/(1+99) = 1%

CMS 收集器

CMS(Concurrent Mark Sweep),其中 Mark Sweep 指的是标记-清除算法

目前只有 CMS 收集器会有单独针对老年代进行垃圾回收的行为

CMS 收集器的工作流程:

  • 初始标记:仅仅标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记:遍历对象图,标记存活对象,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记对象的变动,需要停顿。
  • 并发清除:清除未被标记的对象,不需要停顿。

并发清除的过程中由于用户线程继续运行也可能会产生垃圾,称为浮动垃圾,而这一部分的浮动垃圾只能留到下一次垃圾回收才能进行回收

优点:停顿时间短,在整个过程中耗时最长的并发标记和并发清除的过程,收集器可以和用户线程一起工作,不需要停顿。

缺陷:

  • 并发标记和并发清除阶段,会占用 CPU 资源
  • 无法处理浮动垃圾,可能会出现 Concurrent Mode Failure。由于浮动垃圾的存在,所以每次都需要预留出一部分内存,不能像其他收集器一样等待老年代快满了再进行回收。如果预留的内存甚至不足以存放浮动垃圾,那么就会出现 Concurrent Mode Failure,这时虚拟机只能临时启用 Serial Old 来替代 CMS。
  • 标记-清除算法会导致内存空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

参数:

bash
-XX:+UseConcMarkSweepGC                 # 启用 CMS
-XX:CMSInitiatingOccupancyFraction=68   # 触发 GC 阈值(默认 68%)
-XX:+UseCMSCompactAtFullCollection      # Full GC 时压缩空间
-XX:CMSFullGCsBeforeCompaction=0        # 每次 Full GC 都压缩

G1 收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。

Region

G1 收集器引入了 Region 这个概念,把新生代和老年代一视同仁,把一整块堆内存空间划分成多个 Region

这种划分方法带来了很大的灵活性,每个 Region 可动态扮演 Eden/Survivor/Old/Humongous,每一个 Region 都可以单独地进行垃圾回收

收集器会记录每个 Region 垃圾回收时间以及回收后所获得的空间,来维护一个优先列表,这样每次回收时可以根据允许的收集时间来优先回收价值最大的 Region。

而且每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过 Remembered Set,在做可达性分析的时候可以避免全堆扫描。

划分 Region 后的堆结构图:

工作流程

G1 收集器发生的 GC 一般称为 Mixed GC,针对新生代和部分老年代进行回收

  • 初始标记:仅仅标记一下 GC Roots 能直接关联到的对象,需要停顿
  • 并发标记:遍历对象图,标记存活对象,不需要停顿
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记对象的变动,需要停顿。另外虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中
  • 筛选回收:对各个 Region 中的回收价值和成本进行排序,回收回收价值高的 Region,存活对象复制到其他 Region,需要停顿

G1 收集器的特点:

  • 空间整合:从整体来看是基于「标记-整理」算法实现的收集器,从局部(两个 Region 之间)上来看是基于「标记-复制」算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

G1 vs CMS 对比

特性CMSG1
算法标记-清除标记-整理 + 复制
内存碎片
停顿控制不可预测可预测(用户指定)
Region有(动态角色)
大对象直接进入老年代Humongous Region
JDK 版本JDK8 推荐JDK11+ 默认

参数:

bash
-XX:+UseG1GC                            # 启用 G1(JDK11+ 默认)
-XX:MaxGCPauseMillis=200                # 目标最大停顿时间
-XX:G1HeapRegionSize=4m                 # Region 大小(1-32MB)
-XX:InitiatingHeapOccupancyPercent=45   # 触发并发 GC 阈值

ZGC 收集器

超低延迟,JDK15+ 生产可用。

特点

  • 停顿时间 < 10ms(不分堆大小)
  • 支持 TB 级堆内存
  • 并发执行(标记、整理、重映射)

参数:

bash
-XX:+UseZGC  # JDK15+

对象分配与晋升

新对象分配流程

  • 新对象创建时,检查对象大小,是否超出阈值 PretenureSizeThreshold
    • 超出阈值,一是可能新生代没有足够的连续内存空间,二是避免对象在新生代中被多次复制的开销,直接在老年代上分配
    • 未超出阈值,可以在新生代中分配内存
  • 在新生代分配内存时,检查 Eden 区空间是否足够
    • Eden 区空间足够,在 Eden 区中分配内存
    • Eden 区空间不足,执行一次 Minor GC
  • Eden 区执行 Minor GC 后是否有足够空间
    • GC 后 Eden 区拥有足够空间,分配内存
    • GC 后 Eden 区仍然没有足够空间,分配失败,检查老年代空间
  • 老年代是否有足够空间
    • 空间足够,在老年代上分配内存
    • 老年代空间不足,执行 Full GC,如果 Full GC 后仍然空间不足,抛出 OutOfMemoryError

对象晋升策略

面试话术:对象进入老年代有四种情况——大对象直接进入长期存活(年龄≥15)、动态年龄判断空间分配担保失败

情况 1:大对象直接进入老年代

bash
-XX:PretenureSizeThreshold=1048576  # 1MB,超过直接进入老年代

原因:需要连续内存空间的大对象(长字符串、数组)直接进入老年代,避免在 Eden 区和 Survivor 区之间的大量内存复制


情况 2:长期存活对象(年龄阈值)

bash
-XX:MaxTenuringThreshold=15  # 默认 15,超过晋升老年代

年龄计算

  • 对象每经历一次 Minor GC,年龄 +1
  • 同龄对象批量晋升

情况 3:动态年龄判断

如果 Survivor 区中相同年龄的对象大小的总和大于 Survivor 区空间的一半,那么大于等于这个年龄的对象直接进入老年代,无需等到交换次数的阈值

规则:Survivor 区中,同年龄对象大小总和 > Survivor 空间的 50%
结果:该年龄及更大年龄的对象全部晋升老年代

示例

Survivor 区总大小:100MB
年龄 3 的对象总大小:60MB(>50MB)
结果:年龄≥3 的对象全部晋升老年代

情况 4:空间分配担保

空间分配担保:在发生 Minor GC 之前,JVM 会检查老年代最大可用的连续空间是否大于新生代所有对象的总大小(假设新生代所有对象在本次 GC 后全都进入到老年代)

  • 如果大于,则可以保证这次 Minor GC 是安全的
  • 如果小于,那么会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小(假设这一次 GC 后有平均大小的对象进入到老年代)
    • 如果大于,则可以尝试进行一次 Minor GC,虽然存在一定风险
    • 进行 Full GC

参数

bash
-XX:-HandlePromotionFailure  # JDK6u24+ 默认开启