Java GC 全系列一小时速通教程

GC一小时速通教程

JVM GC 之 "垃圾" 是什么?从用户注册业务说起

在讲解 JVM 中的 GC(垃圾回收)之前,我们首先要搞清楚一个核心问题:什么是 "垃圾"? 为了更直观地理解,我们先从一个常见的业务场景 ------ 用户注册业务入手,逐步剖析业务执行过程中 JVM 内存的变化,进而引出 "垃圾" 的定义。

一、用户注册业务流程梳理

用户注册是我们日常开发中非常基础的业务,完整的业务流程大致如下:

  1. 用户操作层:用户在前端页面输入注册信息,包括用户名、真实姓名、昵称、密码等,然后点击 "注册" 按钮。
  2. 请求传输层:前端将用户输入的注册信息封装成请求,发送到后端的 Controller 层。
  3. 业务逻辑层(Service 层) :Controller 层接收请求后,调用对应的 Service 层方法处理业务逻辑。在 Service 层中,会对用户信息进行校验、组装等操作(这一步是我们关注的重点,涉及对象创建和内存分配)。
  4. 数据访问层(DAO 层) :Service 层处理完业务逻辑后,调用 DAO 层方法,将用户信息最终持久化到数据库中。
  5. 结果返回层:数据库存储成功后,通过 DAO 层、Service 层、Controller 层逐层返回结果给前端,告知用户注册成功。后续用户就可以使用数据库中存储的用户名和密码进行登录。

二、Service 层核心操作:对象创建与内存分配

为了简化讲解,我们假设前端传递到 Service 层的是零散的用户信息(而非完整的 User 对象),因此在 Service 层需要手动组装这些信息,创建一个 User 对象。这一步操作直接关联到 JVM 的内存分配,也是理解 "垃圾" 的关键。

\1. 代码示例:Service 层创建 User 对象

首先定义一个简单的 User 实体类,用于封装用户信息:

arduino 复制代码
// User实体类
public class User {
    // 成员变量:对应用户注册信息
    private String username;   // 用户名
    private String realName;   // 真实姓名
    private String nickname;   // 昵称
    private String password;   // 密码
​
    // 无参构造器
    public User() {}
​
    // 有参构造器(用于组装用户信息)
    public User(String username, String realName, String nickname, String password) {
        this.username = username;
        this.realName = realName;
        this.nickname = nickname;
        this.password = password;
    }
​
    // Getter和Setter方法(省略,用于访问和修改成员变量)
    public String getUsername() {
        return username;
    }
​
    public void setUsername(String username) {
        this.username = username;
    }
​
    // 其他成员变量的Getter/Setter方法省略...
}

然后在 Service 层中,通过new关键字创建 User 对象,组装用户信息:

typescript 复制代码
// 用户注册Service层实现类
@Service
public class UserRegisterService {
​
    // 注入DAO层对象(用于后续操作数据库)
    @Autowired
    private UserDao userDao;
​
    // 用户注册核心方法
    public boolean register(String username, String realName, String nickname, String password) {
        // 1. 业务校验(省略,如校验用户名是否已存在、密码格式是否合法等)
        // 2. 组装用户信息:创建User对象(关键操作:new User())
        User user = new User(username, realName, nickname, password);
​
        // 3. 调用DAO层方法,将User对象存入数据库
        boolean saveResult = userDao.save(user);
​
        // 4. 返回注册结果
        return saveResult;
    }
}

三、JVM 内存视角:对象存放在哪里?

当执行User user = new User(...)这行代码时,JVM 会在不同的内存区域分配空间,这涉及到 JVM 内存模型中的栈内存堆内存

  1. 栈内存(Stack):存放引用变量
  • 变量user的存储 :代码中的user是一个引用变量(并非真正的 User 对象),它会被存储在栈内存中。
  • 引用的本质:栈内存中的user变量存放的是一个 "内存地址",这个地址指向堆内存中真正的 User 对象。
  1. 堆内存(Heap):存放对象实例
  • User 对象的存储 :通过new User(...)创建的 User 对象(实例),会被存储在堆内存中。
  • 堆内存的归属:堆内存是 "线程共享" 的,所有线程创建的对象实例都统一存放在堆内存中。
  • 对象的内容:堆内存中的 User 对象会存储其成员变量(username、realName等)的值,以及对象的元数据信息(如对象类型、类的引用等)。

四、从业务结束到 "垃圾" 产生:引用的生命周期

当用户注册业务执行完成后(即register()方法执行结束),JVM 内存会发生什么变化?这是判断 "垃圾" 的关键。

  1. 方法执行结束:栈帧出栈,引用消失

当register()方法执行完成(即 DAO 层将 User 对象存入数据库后),该方法对应的栈帧会从线程栈中 "出栈"。此时,栈帧中的引用变量user会被销毁 ------ 也就是说,栈内存中不再有指向堆内存中 User 对象(地址 0x0012FF3A)的引用。

  1. 堆内存中的对象:失去引用后成为 "垃圾"

堆内存中的 User 对象(地址 0x0012FF3A)原本是通过user引用被访问的。当user引用被销毁后,这个 User 对象就再也无法被程序中的任何部分访问到了(因为没有任何引用指向它)。

此时,这个无法被程序访问的对象,就是 JVM GC 中所说的 "垃圾"。

核心结论:"垃圾" 的定义

在 JVM 中,"垃圾" 指的是堆内存中那些失去所有引用(或没有任何引用指向它)的对象实例------ 这些对象无法再被程序使用,继续占用堆内存会造成内存浪费,因此需要通过 GC 将它们回收,释放内存空间。

五、扩展思考:哪些情况会导致对象成为 "垃圾"?

除了上述 "方法执行结束,引用出栈" 的情况,还有其他场景会导致对象成为 "垃圾",例如:

引用变量被显式赋值为 null:如user = null;,此时user不再指向堆内存中的 User 对象,该对象成为垃圾。

引用变量被重新赋值 :如User user = new User(...); user = new User(...);,第一个 User 对象的引用被覆盖,成为垃圾。集合中的对象被移除:如List list = new ArrayList<>(); list.add(new User(...)); list.remove(0);,被移除的 User 对象若没有其他引用,成为垃圾。

JVM GC 之如何识别垃圾:从活跃线程到 GC Roots

上一部分我们理解了 "垃圾" 是堆内存中失去所有引用的对象实例。但堆内存是所有线程共享的区域,里面可能存在成千上万的对象,如何准确区分 "存活对象" 和 "垃圾对象" 呢?这就需要从线程的运行机制和 GC Roots 说起。

一、线程与堆内存的关系:共享与隔离

当我们的项目部署到 Tomcat 等服务器运行时,会涉及到多个线程与堆内存的交互:

  1. 服务器线程池的作用

    • Tomcat 启动后会维护一个线程池,当用户请求(如注册、登录、操作等)到来时,会从线程池中分配一个线程处理该请求
    • 每个请求对应一个独立的线程,这些正在处理任务的线程称为 "活跃线程"
  2. 线程与堆内存的交互

    • 所有线程共享堆内存,不同线程创建的对象都存放在堆中
    • 每个线程有自己独立的栈内存(线程私有),用于存储当前执行方法的局部变量(包括对象引用)

二、栈帧与对象引用:活跃线程中的关键线索

每个活跃线程的栈内存中,会为正在执行的方法创建对应的 "栈帧":

  1. 栈帧的作用

    • 存储方法的局部变量(包括基本类型变量和对象引用)
    • 记录方法的执行状态(如返回地址、操作数栈等)
  2. 以用户注册业务为例

    • register() 方法执行时,线程会创建对应的栈帧
    • 栈帧中存储 user 引用变量,指向堆内存中的 User 对象
    • 方法执行过程中,这个引用始终有效(活跃状态)
    java 复制代码
    // 栈帧中存储的引用关系
    public boolean register(...) {
        // 栈帧中创建user引用,指向堆内存的User对象
        User user = new User(...);  // 引用有效(活跃状态)
        // ...业务逻辑...
        return saveResult;
    }
    // 方法执行结束,栈帧出栈,user引用消失
  3. 多线程场景下的栈帧状态

    • 系统运行时存在多个活跃线程
    • 每个线程有多个嵌套执行的方法,形成栈帧链
    • 这些栈帧中存储的对象引用,是判断对象是否存活的关键线索

三、GC Roots:垃圾回收的 "起点集合"

JVM 如何确定哪些对象是存活的?核心就是通过 GC Roots(垃圾回收根集):

  1. GC Roots 的定义

    • 是一组 "活跃的引用" 的集合
    • 主要包括:活跃线程栈帧中的局部变量、静态变量、常量引用、JNI 引用等
    • 在我们的业务场景中,主要关注活跃线程栈帧中的局部变量引用
  2. GC Roots 的收集过程

    • JVM 扫描所有活跃线程的栈内存
    • 收集所有栈帧中正在使用的对象引用
    • 这些引用共同构成 GC Roots集合

四、标记 - 清除:垃圾回收的基本流程

有了 GC Roots 后,JVM 就能通过 "标记 - 清除" 过程识别并回收垃圾:

  1. 标记阶段

    • 从 GC Roots 集合中的引用出发,遍历所有可达的对象(可达性分析算法)
    • 被遍历到的对象会被标记为 "存活对象"(在堆内存中设置标记位)
    • 未被标记的对象就是 "垃圾对象"
  2. 清除阶段

    • 回收所有未被标记的垃圾对象
    • 释放其占用的堆内存空间
  3. 标记的周期性

    • 每次 GC 执行时,都会重新进行标记(上一次的标记会被清零)
    • 保证标记结果反映当前对象的存活状态

五、GC Roots 的通用性

虽然不同垃圾回收器(如 SerialGC、ParallelGC、G1、ZGC 等)的实现细节不同:

  • 传统回收器在堆内存中标记对象
  • 新一代回收器(如 ZGC)采用染色指针技术,不在堆内存中标记

GC Roots 作为垃圾回收的起点这一核心机制,贯穿了所有垃圾回收器的设计。它是 JVM 判断对象存活状态的根本依据,也是理解垃圾回收原理的关键概念。

下一部分我们将深入讲解不同的垃圾器,回收算法及其实现原理。

垃圾回收算法:基于分代的处理策略(CMS为例)

一、堆内存中垃圾的分类与分代处理

在垃圾清理的时候,每次都要扫描整个堆内存,是非常耗时的,所以,每一代都会对堆内存进行分代处理。

在堆内存中,垃圾主要分为两类:

  1. 短期存活的垃圾

    • 例如用户注册流程,当注册完成后,线程中的相关信息就不再需要,这些对象很快就会变成垃圾。
  2. 长期存活的垃圾

    • 比如用户登录后,将用户对象信息存放在 Session 中,这些对象在很长一段时间内都不会被回收。

即:

  • 年轻代:存放那些很快就会被回收的对象,例如在用户注册等操作完成后就没用的对象。
  • 老年代:存放长期存活的对象,比如存储在 Session 中的用户对象信息。

二、年轻代的特点与标记复制算法

(一)年轻代的特点

年轻代的显著特点是每次回收时需要处理的对象数量非常多。

(二)标记复制算法

  1. 垃圾回收的主要操作

    • 垃圾回收主要有两个关键操作:标记和回收。标记是指找出存活的对象,回收则是处理那些未被标记的对象。
  2. 标记复制算法的原理

    • 年轻代采用标记复制算法。可以把年轻代想象成有两个 "箱子"。每次创建对象时,都将对象放入其中一个 "箱子"。当进行垃圾回收时,把存活的对象复制到另一个空 "箱子" 中,并排列整齐。这样做的好处是不需要考虑碎片清理的问题。

1.未标记

2.标记存活对象

3.先复制对象到S1

4.清空

5.第二次标记

6.第二次复制

7,清空Eden和S1

8.下一次复制后清空Eden和S2,周而复始。连续存活到一定次数的对象会被移动到老年代。

三、老年代的特点与标记清理、标记整理算法

(一)老年代的特点

老年代中的对象大部分是长期存活的,每次需要回收的对象相对较少。

(二)标记清理算法

  1. 标记清理算法的原理

    • 标记清理算法适用于老年代。它的操作比较直接,首先标记出那些不再存活的对象,然后直接将这些未被标记的(即无用的)对象清理掉。
  2. 碎片问题

    • 在堆内存中,可以将内存空间想象成 Excel 中的小格子。每个对象会占用若干个小格子。当对象被清理后,会留下一些空格子,这些空格子就是碎片。例如,原来内存中的对象是紧密连续排列的,经过一次清理后,出现了一些零散的空格子。当有新对象需要分配内存时,如果新对象所需的内存空间大于当前零散的空格子,就会出现无法分配的情况,这就需要进行内存整理。

标记(绿色存活)

清理,但清理后会留下小面积的空格,无法存入大一点的对象,这些小空格就叫做碎片

(三)标记整理算法

  1. 标记整理算法的原理

    • 当老年代中碎片过多或者没有足够的连续空间来存放新对象时,就需要使用标记整理算法。该算法首先标记出存活的对象,然后将这些存活对象移动到一起,使它们在内存中排列得更加紧凑,从而腾出连续的空间。

标记

整理,将所有对象向左上角对齐排列,之前的垃圾位置会被直接覆盖

清理剩下的内存空间

JVM 垃圾回收器

什么是 STW(Stop The World)?

STW 全称 "Stop The World",即全局停顿。垃圾回收器有专属的 GC 线程,日常业务由工作线程(或用户线程)处理,理想状态下两者各干各的。但垃圾回收时,若工作线程继续往堆内存写入新实例,会干扰回收(比如 GC 刚标记垃圾,工作线程又给垃圾加引用),所以早期 GC 会先停掉所有工作线程,只让 GC 线程单独完成回收,回收完再恢复工作线程,这个过程就是 STW。

早期小型项目中,STW 影响不大;但现在互联网行业发展到顶峰,高并发场景下,STW 时间需越短越好,最好能实现 "回收与业务并行",让工作不受回收影响。

一、先看俩早期 GC:Serial 和 Parallel,实战里的 "卡脖子" 问题

这俩是 JVM 早期的默认 GC,特点鲜明,但在高并发场景下,痛点特别明显。

1. Serial GC(串行垃圾回收器)

  • 工作方式:全程就一个线程干垃圾回收的活儿。

  • 实战痛点:全程 STW,高并发直接崩

    比如电商大促,用户疯狂下单,突然触发 Serial GC 回收。这时候所有处理下单的线程全被暂停(Stop The World,STW),用户点 "提交订单" 没反应,页面卡个几百毫秒甚至几秒要是卡 1 秒,用户可能直接刷新或换平台,订单就丢了。

    这种 GC 现在只适合本地跑小 Demo,生产环境基本见不着了。

2. Parallel GC(并行垃圾回收器,JDK 1.8 默认)

  • 改进点:多线程一起回收,比 Serial 快。
  • 实战痛点:还是 STW,只是短了点,但高并发下仍致命

二,里程碑:并发垃圾回收器CMS

CMS(老年代垃圾回收器,年轻代还是使用早期的GC,因为年轻代使用标记复制算法效率极高,所以停顿几乎感知不到)(Concurrent Mark-Sweep,并发标记 - 清除)是 JVM 中首个以 "低停顿" 为目标的垃圾回收器,核心设计是让 "标记" 和 "清除" 阶段与工作线程并行.

cms执行流程图,源:zhuanlan.zhihu.com/p/34921587

流程:

  1. 初始标记:短暂 STW,仅标记 GC Roots 直接引用的老年代对象,建立标记起点。
  2. 并发标记:GC 与工作线程并行,从初始标记的对象出发,遍历并标记老年代所有存活对象。
  3. 并发预清理:并发处理 "写屏障" 标记的脏卡片,提前修正引用变更,减少下一阶段的 STW 时间。
  4. 重新标记:短暂 STW,最终修正漏标,确保所有存活对象都被正确标记。(主要处理脏卡片和并发标记中工作线程新增的但是未标记的对象)
  5. 并发清理:GC 与工作线程并行,清理未标记的垃圾对象,释放内存(会产生碎片)。(清理过程中新产生的对象会放在并发清理之前设定好的"非清理区域")
  6. 并发重置:并发重置内部状态(如标记位、卡片表),为下一轮回收做准备。
  7. 新生代联动:回收期间若年轻代满,暂停并发阶段优先执行 Minor GC,完成后再恢复 CMS。(解释一下MinorGC MajorGC FullGC)

并发标记过程中存在一个问题,GC标记和用户线程同时执行,如果被标记的对象引用发生改变怎么办?GC Roots中的A引用了B,B已经被标记为存活,但是线程执行的时候A被重新引用了C怎么办?

解决方案:脏卡片 。线程执行的时候,如果发生引用变更,Jvm会感知(学名叫做写屏障),会给变更后的C对象所在的区域特别标记(学名脏卡片),等到第3和第4阶段时,被脏卡片标记的对象会被重新标记为存活。注意,之前失去引用但依然被标记的B对象不会被清理,依然标记存活,这就是GC的核心原则之一:宁可多标不可漏标的原则

CMS确实可以大大缩短STW的时间,但是还存在以下问题

1.全堆扫描,大对象直接丢到老年代,标记清理算法导致内存碎片问题严重,易触发 "致命 Full GC"

2.无法主动控制停顿时间,延迟稳定性差

3.CPU 资源占用高,吞吐量损耗明显

CMS通常适合中小堆内存,比如8G左右的堆内存。

三.G1垃圾回收器

为了解决上面的问题,推出了新一代的垃圾回收器:G1

优势:

针对于cms全堆扫描以及采用的标记清理算法进行了升级:分成若干region的方式可以很方便的采用标记复制算法(可以参考cms中年轻代的处理方式,非常快,几乎无感),大大优化了碎片问题以及清理速度。

cms中的大对象存入老年代,每次标记都要扫描大对象,最主要的还是cms是一块连续的大内存区,多次回收后大量碎片导致无法存入大对象从而引发频发的Full GC.

G1中直接创建了大对象区,每个大对象区只存入一个大对象,扫描标记时如果发现已经死亡直接回收即可。

CMS 是全堆连续内存,多线程得 "瓜分整块内存" 来扫描,容易出现 "有的线程扫完没事干、有的线程还在啃大块" 的负载不均,效率受影响;

而 G1 把堆拆成了小 Region 分片,多线程能 "各管一片 Region",不用抢着扫同一块大内存,天然能均匀分担任务,还能针对性优先扫垃圾多的分片,线程利用效率比 CMS 高得多。

cms老年代清理需要全堆扫描,无法控制扫描时间,G1 能通过 -XX:MaxGCPauseMillis 设目标停顿时间,会挑垃圾多的 Region 优先回收,尽量贴合设定时间,这也是 "Garbage-First" 的意思。

G1 执行流程(含 SATB 与 RSet 作用时机)

  1. 初始标记(STW) :暂停用户线程,标记 GC Roots 直接可达的对象。此阶段会初始化部分 RSet 基础信息,为后续并发标记做准备。

  2. 并发标记

    :用户线程恢复运行,GC 线程并行标记所有可达对象。

    • 此阶段通过 SATB(初始快照) 配合写屏障,记录并发中引用的变化(如 A 从指向 B 改为指向 C 时,记录 B 到 SATB 旧地址列表;A 新增指向 C 时,直接标灰 C)。
    • 同时,RSet 会实时维护外部 Region 对本 Region 对象的引用(如其他 Region 对象引用本 Region 对象时,更新 RSet 记录 Region + Card 信息),支撑后续回收时的存活判断。
  3. 最终标记(STW) :暂停用户线程,处理并发标记中 SATB 日志记录的旧引用,将旧地址列表中的对象标黑;同时扫描标灰对象及其子引用,批量标黑,确保标记完整。

  4. 筛选回收(STW)

    :暂停用户线程,统计各 Region 存活对象占比,优先选择垃圾率高的 Region 回收。

    • 回收时,通过查询目标 Region 的 RSet,快速定位外部引用来源,判断本 Region 对象是否存活;将存活对象复制到新 Region,清理垃圾并压缩内存。

部分核心原理:G1中通过Rset集合,解决不同Region之间的引用关系。比如有三个Region区,R1,R2,R3,分别存放了三个对象,A,B,C。

R1的A对象被B对象和C对象引用。

  • 它就像一个 "便签本",上面写着:

    "R2 的 Card 0x0A 有人引用我"

    "R3 的 Card 0x05 有人引用我"

  • 这个便签本不是给你平时看的,是GC 回收时备用的

  1. 这表示:

    • R2 的 0x0A 卡页里有对象引用了 R1 里的对象;
    • R3 的 0x05 卡页里有对象引用了 R1 里的对象。
  2. 去这些 Card 里找引用对象

    • 到 R2 的 0x0A 卡页,找到里面的对象(比如 b1);
    • 到 R3 的 0x05 卡页,找到里面的对象(比如 c1)。
  3. 检查这些引用对象

    • 看 b1、c1 本身是不是存活的(可达的);
    • 如果它们存活,那它们引用的 R1 内的对象(比如 a)也必须标记为存活。
  4. 回收未被引用的对象

    • 遍历完 RSET 里的所有外部引用源,再加上 R1 内部引用链,就能确定哪些对象还活着;
    • 把存活对象复制到新 Region,清空 R1。

并发标签期间,如果发生类似CMS中的脏卡片处理,采用G1专用的SATB处理方式,即:

G1 并发标记:SATB 与写屏障的核心逻辑

G1 和 CMS 一样,并发标记时用户线程(工作线程)不会停,但为了避免漏标,G1 靠 SATB(Snapshot-At-The-Beginning,初始快照) 和写屏障配合,核心原则还是 "宁可多标活,不可漏标",具体分两种关键场景:

一、先简单说:三色标记法基础

为了清晰跟踪对象存活状态,GC 会给对象 "涂色":

  • 黑色:已扫描完,对象本身存活,且它引用的子对象也都标记过了;
  • 灰色:对象本身存活,但它引用的子对象还没扫描;
  • 白色:未被扫描,默认是 "待回收垃圾"(如果最后还是白色,就会被回收)。

二、场景 1:引用 "断旧连新"(A 原本→B,改成 A→C)

  1. 并发标记前:初始快照(SATB)记录下 "A→B" 这个引用,此时 A、B 还没被 GC 线程扫描;
  2. 并发标记中:用户线程先动手,把 A 的引用改成 "A→C"(断旧连新);
  3. 写屏障触发 :因为改了引用,写屏障会把 "B 的旧引用" 记录到 SATB 旧地址列表(相当于给 B 发 "保护令");
  4. 后续处理 :不管 B 之后有没有其他引用,GC 到了 重新标记阶段(STW 短停顿) ,会把旧地址列表里的 B 直接标为 黑色(多标活)------ 哪怕 B 其实已经没引用了,也先保它一次,避免漏标。

三、场景 2:已标黑对象 "新增引用"(A指向B 已黑,B 新→C)

  1. 并发标记中 :GC 已经扫描完 A 和 B,两者都标为 黑色(默认它们的子引用都处理完了);
  2. 用户线程操作:程序继续跑,B 突然新增一个引用 "B→C"(C 之前是白色,没被扫描);
  3. 写屏障触发 :因为 B 是已标黑的对象,新增引用会触发写屏障,把 C 直接标为 灰色(不让 C 一直是白色,避免被当垃圾);
  4. 后续处理:到了重新标记阶段,GC 会优先扫描灰色的 C,把 C 及其子引用都标为黑色(补全标记)------ 确保 C 不会因为 "新增引用没被扫描" 而漏标。

总结:SATB + 写屏障的核心作用

  • 场景 1 处理 "断旧引用":靠 SATB 旧地址列表保旧对象(B),避免误杀;
  • 场景 2 处理 "新增引用":靠写屏障把新对象(C)标灰,避免漏标;
  • 本质都是用 "多标活"(比如场景 1 的 B 可能多标)换 "无漏标",同时不用长时间停用户线程,平衡了停顿和安全性。

图例:G1直接将内存分成了一个一个的region(区),如下图,分别用不同的字母标出每个区域的用途。除了大对象区,每个region的大小是一样的,只是数量不同。

  • Eden 区:由若干个 Region 组成,数量动态变化(比如 50 个 → 100 个)。
  • 老年代区:由若干个 Region 组成,数量随对象晋升逐渐增加。
  • 大对象会占用连续多个 Region,比如一个 5MB 的对象会占用 3 个连续的 2MB Region(最后一个只用 1MB,剩下 1MB 浪费)

G1 是非常优秀的垃圾收集器,日常工作里,不管是单体中小型项目,还是负载均衡做得好的微服务,只要单机堆内存控制在 16G 左右,它的性能完全够用,压根不用换。但像美团这类企业,在 TB 级海量内存的大型服务器上,就优先用 ZGC 了.

因为 G1 有个坎:堆内存一旦超 64G,问题就全来了。首先,G1 的 Region 是初始化时就固定大小的,堆越大,划分出的 Region 数量越多,管理这些 Region 以及维护 RSet、处理 SATB 日志的开销会暴增。其次,G1 延续了 CMS 的思路,标记对象时要去内存里找对象,改对象头的标记位 ------ 单次寻址开销不大,但堆大了,寻址范围广,来回折腾的时间就扛不住了,吞吐量直接掉下来。

而 ZGC 正好解决了这些问题:

第一,它不用固定 Region,改成动态页面管理。就像按物品大小选箱子,小对象给 2MB 小页面,大对象给 32MB 甚至更大的页面,不用一次性把 1T 内存全分成小格子,管理负担自然轻。

第二,最关键的是染色指针技术。这里可以用 Redis 类比:原来项目读数据要直接查数据库,慢;加个 Redis 缓存常用数据,速度就快了。CPU 里的寄存器和缓存也一样,是内存的'临时 Redis',会存当前常用的指针。ZGC 直接在这些指针的高位加标记(比如用 00、01 表示对象状态),不用去内存里改对象头 ------ 就像给快递单号加前缀表示状态,不用拆快递看单子。尤其在 TB 级内存里,不用频繁往内存寻址、改对象头,速度和效率直接拉满。

所以总结下来:中小堆用 G1 性价比最高,超大堆场景,ZGC 的动态页面 + 染色指针就是降维打击。

什么是染色指针:

先说指针,我们在学习Java的入门课的时候,都会在创建对象的时候学到如下的知识点,比如:

ini 复制代码
User user = new Uesr();

其中前面的user叫做引用变量,后面的new User()叫做对象创建的表达式,可以简单理解为就是一个方法(函数),该方法(函数)会返回在内存中创建User类实例所在的内存地址。也就是说,引用变量user就是指针,它保存了User实例的内存地址。

而在64位的Linux系统中,使用64位中的低46位用来寻址,剩余的高18位是闲置的,ZGC利用了这个规则,从闲置的前18位中取出来4位用来标识该地址的对象实例的具体状态:

sql 复制代码
// 1. 栈上的user是指针,指向堆中User实例的内存地址(如0x000123456789ABCD)
User user = new User(); 
// 2. ZGC在指针高位嵌入"颜色标记",假设高4位为0001 表示"已标记存活"
//    此时实际使用的指针为:0001 + 000123456789ABCD → 0x500123456789ABCD
User anotherUser = user; // 引用传递时,指针高位的"颜色"也随之传递

G1 标记时要修改堆中User实例的mark word,而 ZGC 只需修改栈上user指针的高位 ------ 由于栈上的指针(尤其是频繁访问的引用)会被 CPU 缓存到寄存器或 L1/L2 缓存中,修改操作可在 CPU 内部完成,无需访问堆内存,速度比对象头修改快数百倍。

染色指针的核心 "颜色" 类型(状态标记)

ZGC 通过指针高位的二进制组合定义对象状态,当前主要包含三类核心标记(不同 JDK 版本可能微调位定义):

ZGC 64 位指针高 4 位元数据含义表

指针高 4 位标识 对应状态 状态含义
0000 Marked0 对象的三色标记状态之一
0001 Marked1 对象的三色标记状态之一
0010 Remapped 表示对象已进入重分配集,即被移动过
0011 Finalizable 表示对象只能通过 finalize () 方法才能被访问到

指针类型:明确 Java 中与染色指针相关的指针形态

Java 开发中虽不直接操作指针,但 ZGC 的染色指针设计需结合 JVM 层面的指针类型理解,核心分为两类:

栈指针(Stack Pointer)

  • 即方法栈中局部变量对应的引用(如User user),是 GC Roots 的核心组成部分。
  • 特点:频繁被访问,大概率被 CPU 缓存,ZGC 初始标记阶段仅需扫描栈指针,通过高位染色标记根对象,无需遍历堆。

堆内指针(Heap Pointer)

  • 堆中对象内部的引用(如User实例中private Address addr对应的指针)。
  • 特点:数量庞大,但 ZGC 通过 "读屏障" 实时拦截堆内指针的访问 ------ 若指针高位标记为 "已转移",读屏障会自动将指针重定向到对象的新地址(即 "引用自愈"),无需暂停用户线程。

新的问题来了,写屏障我们在了解cms,G1时已经非常清楚了,这个读屏障又是什么呢?

GC 线程和用户线程可能 "抢着" 访问对象 ------ 若用户线程先访问未标记的对象,而 GC 线程还没扫描到它,就可能被误判为垃圾回收。读屏障通过实时标记 "未标记" 的指针,确保所有被用户线程访问的对象都会被标记为存活,避免漏标。

scss 复制代码
// 读屏障伪代码(嵌入在引用读取操作中)
Address readBarrier(Address pointer) {
    // 1. 检查指针高位的颜色标记
    if (pointer.color == 未标记) {
        // 2. 将指针颜色改为"已标记(0000)",标记该对象为存活
        pointer.color = 已标记;
        // 3. 将该对象加入GC线程的"待扫描队列",后续遍历其引用链
        addToMarkQueue(pointer);
    }
    // 4. 返回原始指针,用户线程正常访问对象
    return pointer;
}

场景二:并发转移阶段 ------ 实现 "引用自愈"

当 ZGC 处于 "并发转移" 阶段(GC 线程正在将存活对象复制到新内存地址),用户线程可能访问 "已转移" 的对象(假设指针高位为0010)------ 此时旧地址已无效,新地址存储在 "转发表(Forwarding Table)" 中。读屏障会触发 "引用自愈" 逻辑:

scss 复制代码
// 读屏障伪代码(并发转移阶段)
Address readBarrier(Address oldPointer) {
    // 1. 检查指针高位的颜色标记
    if (oldPointer.color == 已转移(0010)) {
        // 2. 从转发表中查询旧地址对应的新地址
        Address newPointer = forwardingTable.get(oldPointer);
        // 3. 关键:将用户线程的旧指针"更新为新指针"(引用自愈)
        updateUserPointer(oldPointer, newPointer);
        // 4. 返回新地址,用户线程访问新对象
        return newPointer;
    }
    // 若未转移,直接返回旧指针
    return oldPointer;
}

好的,接下来看一下ZGC具体的执行流程:

ZGC执行流程图,源:zhuanlan.zhihu.com/p/585254683

ZGC 执行流程以 "极致低延迟" 为核心,仅 2 个极短 STW 阶段(均 <1ms),其余步骤全与用户线程并发执行,核心流程可简述为 7 步:

  1. 初始标记(STW) :快速标记 GC Roots 直接可达的对象,仅处理 "根→对象" 的第一层引用,耗时与堆大小无关;
  2. 并发标记:GC 线程与用户线程并行,通过染色指针遍历引用链,标记所有存活对象(仅修改指针颜色,不碰对象头);
  3. 再标记:并发处理标记遗漏(如用户线程新增的引用),极少场景需微秒级 STW;
  4. 并发转移准备:筛选垃圾率高的页面,创建转发表(记录对象转移后新地址),锁定待回收页面;
  5. 初始转移(STW) :快速转移 GC Roots 直接指向的对象,更新根引用指针,耗时极短;
  6. 并发转移:GC 线程与用户线程并行转移剩余存活对象,用户线程访问已转移对象时,读屏障自动重定向到新地址(引用自愈);
  7. 并发重映射:并发更新所有旧指针为新地址,删除转发表,释放旧页面供后续分配。

全程靠染色指针管理对象状态、读屏障处理并发冲突,实现 "超大堆下低延迟回收"。

相关推荐
小江的记录本1 小时前
【事务】Spring Framework核心——事务管理:ACID特性、隔离级别、传播行为、@Transactional底层原理、失效场景
java·数据库·分布式·后端·sql·spring·面试
sheji34161 小时前
【开题答辩全过程】以 基于springboot的校园失物招领系统为例,包含答辩的问题和答案
java·spring boot·后端
寂静or沉默1 小时前
2026最新Java岗位从P5-P7的成长面试进阶资源分享!
java·开发语言·面试
程序员cxuan1 小时前
人麻了,谁把我 ssh 干没了
人工智能·后端·程序员
wuyikeer2 小时前
Spring Framework 中文官方文档
java·后端·spring
Victor3562 小时前
MongoDB(61)如何避免大文档带来的性能问题?
后端
Victor3563 小时前
MongoDB(62)如何避免锁定问题?
后端
wuyikeer3 小时前
Spring BOOT 启动参数
java·spring boot·后端
studyForMokey4 小时前
【Android面试】Activity生命周期专题
android·面试·职场和发展
子木HAPPY阳VIP4 小时前
Ubuntu 22.04 VMware 设置固定IP配置
人工智能·后端·目标检测·机器学习·目标跟踪