Skip to content

JVM 深度剖析

JVM

本质

JVM 不是物理硬件,其本质可理解成一个软件实现的虚拟计算机。它定义了一套规范:

  • 指令集:一套特定的、与硬件无关的指令。
  • 内存模型:对内存如何组织和管理的规定。
  • 运行时数据区:程序执行时所需的各种内存区域。
  • 类加载机制:如何将.java文件加载到内存并转化为可执行代码。

JVM 通俗比喻

角色/物品对应 Java 技术概念
你(写信的人)程序员
用中文写的信件Java 源代码(.java 文件)
把中文翻译成国际通用语言(如英文)javac 编译器将 .java 编译为字节码(.class 文件)
邮局JVM(Java 虚拟机)
邮局的翻译员/工作人员JVM 内置的解释器或 JIT 编译器
不同国家的收信人不同的目标操作系统(Windows、Linux、macOS 等)
  1. 程序员编写的 Java 源代码(中文信件)无法直接被操作系统识别,就像中文信件无法被不同国家的收信人直接看懂;
  2. 先通过 javac 编译器将 .java 源码编译为字节码(.class)(国际通用语言),这是跨平台的中间格式;
  3. 无论目标操作系统(收信人)是 Windows、Linux 还是 macOS(美国、日本、德国),只要安装了 JVM(当地邮局),JVM 就会通过自身的解释器/JIT 编译器(翻译员),将字节码翻译成对应操作系统能识别的机器码(当地语言),最终让代码在该系统上运行。

总结

  1. JVM 相当于跨国邮局,核心作用是把通用的字节码翻译成对应系统的机器码。
  2. javac 负责将源码转字节码,JVM 负责将字节码转机器码,二者配合实现 Java 跨平台特性
  3. 不同操作系统对应不同版本的 JVM,但字节码文件无需修改,体现“一次编写,到处运行”。

JVM 的本质是跨平台的翻译中转站,让编译后的字节码文件,能在任意装有 JVM 的操作系统上运行,这也是 Java 一次编写,到处运行(Write Once,Run Anywhere)的核心原理。

常见 JVM 实现

  • Oracle HotSpot VM:最主流、最广泛使用的 JVM,也是 OpenJDK 的默认实现。
  • OpenJ9:由 IBM 开发,以启动快、内存占用低著称,适合云环境。
  • GraalVM:支持多语言(如 JavaScript、Python、Ruby)的高性能 JVM,可以将多种语言代码编译成原生镜像。

Java 类加载机制

一个类被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括以下七个阶段:加载、验证、准备、解析、初始化、使用、卸载

JVM类加载流程

其中验证、准备、解析统称为链接(Linking)解析阶段在某些情况下可以在初始化之后再进行(为了支持 JAVA 的运行时绑定)

加载

加载是类加载过程的第一个阶段,在加载阶段,虚拟机要完成以下三件事:

  • 通过一个类的全限定类名来获取定义此类的二进制字节流——此处由类加载器负责实现。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

注意

对于数组类而言,情况有所不同。数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造的。

验证

验证是链接阶段的第一步,目的是确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的约束,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段大致会完成以下四个检验动作:

  • 文件格式验证:验证字节流是否符合 Class 文件格式规范(例如是否以 0xCAFEBABE 开头,主次版本号是否在当前虚拟机处理范围内等)。
  • 元数据验证:对字节码描述的信息进行语义分析,保证其符合 Java 语言规范(列入这个类是否有父类、是否继承了不允许继承的类(final 类)、是否实现了父类或接口的所有抽象方法等)。
  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的(例如保证数据操作数栈的数据类型与指令代码序列能配合工作、保证跳转指令不会跳到方法体以外的字节码指令上等)。
  • 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,对类自身以外的信息进行匹配性校验(例如符号引用中通过字符串描述的全限定名是否能找到对应的类、字段描述是否匹配方法等)。

准备

准备阶段是链接阶段的第一步,虚拟机需要完成以下一件事:

  • 类静态变量准备:正式为类中定义的静态变量分配内存并设置类变量的初始值的阶段。这些变量所使用的内存都将在方法区中进行分配。

此时进行内存分配的仅包括类变量(被 static 修饰的变量),不包括实例变量,实例变量会在对象实例化时随着对象一起分配在 Java 堆中。

通常情况

这里所说的初始值“通常情况”下是数据类型的零值,例如:

java
public static int value = 123;

在准备阶段后,value 的初始值为0,而不是123。把value 赋值为 123 的 putstatic 指令是程序被编译后存放于类构造器 <clinit> 方法中,会在初始阶段执行

特殊情况

如果类字段的字段属性表中存在ConstantValue属性(即同时被 final 和 static 修饰),那么在准备阶段变量就会被初始化为指定的值。

java
public static final int value = 123;

在准备阶段后,value 的值就是123。

解析

解析阶段是链接阶段的最后一步,此时虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用:以一组符号来描述所引用的目标,符号可以使任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已加载到内存中的内容。
  • 直接引用:可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,有了直接引用,引用的目标必定已经在内存中存在。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符等7类符号引用进行。

初始化

初始化阶段是类加载过程的最后一步,开始真正执行类中定义的 Java 程序代码(或者说是字节码)。在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其它资源。初始化阶段是执行类构造器<clinit>方法的过程

  • <clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。
  • <clinit>方法与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>方法执行前,父类的<clinit>方法已执行完毕。由于父类的<clinit>方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
  • <clinit>方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>方法。
  • 虚拟机保证一个类的<clinit>方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其它线程都需要阻塞等待,直到活动线程执行完毕。

主动引用(会触发初始化)的场景包括:

  • 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。典型的 Java 代码场景:使用 new 关键字实例化对象、读取或设置一个类的静态字段(被 final 修饰、已在编译器把结果放入常量池的静态字段除外)、调用一个类的静态方法。
  • 使用 java.lang.reflect 包的方法对类型进行反射调用时。
  • 当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个执行的主类(包含 main 方法的类),虚拟机会先初始化这个主类。
  • 当使用 JDK7 新加入的动态语言支持时,如果一个 java.lang.invoke.Method.MethodHandle 实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

被动引用(不会触发初始化)的例子

  • 通过子类引用父类的静态字段,不会导致子类初始化。
  • 通过数组定义来引用类,不会触发此类的初始化。
  • 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量类的初始化。

类加载器

类加载器是一个实现了特定功能的模块(在 Java 中通常继承自 java.lang.ClassLoader),它的核心任务是根据类的全限定名获取定义此类的二进制字节流,并将这些字节流转化为 JVM 方法区中的运行时数据结构,最终生成 java.lang.Object 对象

简单来说:类加载器负责“找到”并“装入”类文件。在JVM类加载机制中,类加载器直接应用于类加载机制的第一个阶段——加载阶段。它决定了从何处获取字节码:可以从本地文件系统、JAR包、网络、甚至动态生成(如代理类)的源中获取。

JVM 内置类加载器

类加载器名称实现类加载路径特点
启动类加载器(Bootstrap ClassLoader)C++实现(HotSpot)<JAVA_HOME>/lib 目录或 -Xbootclasspath 指定的路径最顶层的加载器,加载Java核心类库(如rt.jar中的java.lang.*等)。它不是Java类,无法在Java代码中直接引用。
扩展类加载器(Extension ClassLoader)sun.misc.Launcher$ExtClassLoader<JAVA_HOME>/lib/ext 目录或 java.ext.dirs 系统变量指定的路径负责加载Java的扩展库,开发者可以直接使用。
应用程序类加载器(Application ClassLoader)sun.misc.Launcher$AppClassLoader用户类路径(ClassPath)也称为系统类加载器,是程序中默认的类加载器。如果没有自定义类加载器,默认由此加载器加载用户类。

自定义类加载器

除了上述三种,开发人员还可以自定义类加载器,通过继承ClassLoader重写findClass()方法,实现特殊加载逻辑。

类加载器的层次结构

双亲委派模型

双亲委派模型是一种类加载器的协作机制,其核心思想是:当一个类加载器收到类加载请求时,它首先不会自己尝试加载,而是将这个请求委派给父类加载器去完成。只有当父类加载器反馈无法加载(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己加载

双亲委派模型的关键作用在于保证 Java 核心类库的安全:例如类 java.lang.Object,无论哪个类加载器试图加载它,最终都会委派给启动类加载器,从而确保在整个 JVM 中 Object 类都是同一个版本,防止用户自定义的恶意 Object 类污染核心 API。

工作流程(伪代码)
java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 1. 先检查类是否已被加载
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            // 2. 如果有父加载器,委派给父加载器
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                // 3. 如果没有父加载器,调用启动类加载器
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 父类加载器无法加载
        }
        if (c == null) {
            // 4. 父加载器加载失败,自己尝试加载
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}

打破双亲委派模型

自定义类加载器

JVM内存区域

JVM 内存结构

在 Java 8 中,JVM 内存主要分为线程私有和线程共享两大部分。

在 JVM 内存结构中,堆(Heap) 与本地内存(Native Memory) 承担着不同的职责:所有对象实例、数组以及从 Java 7 开始移入的字符串常量池静态变量存放在堆中,由垃圾收集器统一管理;而线程私有的虚拟机栈、本地方法栈、程序计数器,以及存储类元数据的元空间(Metaspace)、供 NIO 使用的直接内存(Direct Memory) 和 JIT 编译后的代码缓存等,则属于本地内存,它们的分配与释放直接由操作系统或 JVM 内部逻辑控制,具有确定的生命周期,不参与堆的垃圾回收

程序计数器

程序计数器为线程私有,也被称为PC寄存器,是一块较小的内存空间。它可以看作是当前线程所执行的字节码的行号指示器

Java虚拟机栈

Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。它负责保存方法的局部变量、部分结果,并参与方法的调用和返回。可以通过参数-Xss来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。

栈中可能出现的异常: Java虚拟机规范允许 Java 虚拟机栈的大小是动态的或者是固定不变的

  • 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个StackOverflowError异常。
    栈溢出异常示例
    java
    public class StackOverflowDemo {
        private int stackLength = 1;
    
        public void stackLeak() {
            while (stackLength < 3000) {
                stackLength++;
                stackLeak(); // 递归调用自身
            }
        }
    
        public static void main(String[] args) {
            StackOverflowDemo demo = new StackOverflowDemo();
            // 获取 HotSpotDiagnosticMXBean
            HotSpotDiagnosticMXBean hotSpotDiagnosticMXBean = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
    
            // 获取 ThreadStackSize 选项(对应 -Xss,单位 KB)
            String stackSizeValue = hotSpotDiagnosticMXBean.getVMOption("ThreadStackSize").getValue();
    
            System.out.println("当前 JVM 的 -Xss 设置值(ThreadStackSize): " + stackSizeValue + " KB");
            try {
                demo.stackLeak();
                System.out.println(demo.stackLength);
            } catch (Throwable e) {
                System.out.println("Stack length: " + demo.stackLength);
                e.printStackTrace();
            }
        }
    }
    1. 在"Edit Configurations..."的在"VM options"中添加JVM参数,输入 -Xss256k操作步骤一操作步骤二操作步骤三操作步骤四

    WARNING

    注意:栈容量不能设置过小,否则 JVM 可能无法启动。不同操作系统和 JDK 版本有不同的最小值要求,例如在 Linux 上最小可能是 228KB。如果设置 160k 启动报错,可以尝试调大到 256k

    1. 运行 main 方法。
    2. 预期结果:当栈大小过小时,会导致 JVM 无法启动,当方法执行所需栈深度超过栈大小时,会抛出java.lang.StackOverflowError,当栈大小足够时,则程序能够正常结束。 栈过小导致JVM启动失败栈大小不足以支撑方法的运行栈大小足够支撑方法的运行
  • 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个OutOfMemoryError异常

WARNING

OutOfMemoryError: unable to create new native thread 是由于创建了过多的线程,导致操作系统无法再为新的线程分配本地内存(Native Memory)。这种错误与堆内存(Heap)大小没有直接关系,因此通过设置 JVM 总内存大小(例如 -Xmx)并不能解决或预防 OutOfMemoryError 此类问题

本地方法栈

Java虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。本地方法是使用 C 语言实现的。

  • 本地方法栈也是线程私有的。
  • 允许线程固定或者可动态扩展的内存大小。
  • 如果线程请求分配的站栈量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个StackOverflowError异常
  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,则虚拟机会抛出一个OutOfMemoryError异常。

Java堆

Java堆是虚拟机所管理的内存中的最大的一块。Java堆是被所有线程共享的一块区域内存,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,Java里几乎所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的内存区域,因此也被称为GC堆。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现新生代、老年代、Eden空间、From Survivor空间、To Survivor空间等名词,需要注意的是这种划分只是根据垃圾回收机制来进行划分的,不是Java虚拟机规范本身制定的。

方法区(元空间)

  • 方法区与Java堆一样,是所有线程共享的内存区域。
  • 虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java堆区 分开。
  • 运行时常量池是方法区的一部分。Class文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的是 String.intern()方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
  • 方法区的大小和堆空间一样,可以选择固定大小,也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出 OutOfMemoryError 异常。
  • JVM关闭后方法区即被释放。

Java7及之前,逻辑上的方法区,物理上的永久代

Java7 及以前,HotSpot 使用方法区的概念,但在物理上使用 “永久代” 来实现。这意味着 Java 类信息被放在 JVM 所管理的内存中(类似于堆的一部分),且大小由 -XX:PermSize 和 -XX:MaxPermSize 控制。

Java8及以后,逻辑上的方法区,物理上的元空间

Java8 及以后,HotSpot 彻底移除了永久代,改用 “元空间” 来实现方法区。最大的区别在于:元空间不再使用 JVM 的堆内存,而是使用本地内存(Native Memory)。

对象内存分配方式

虚拟机为新对象分配内存,从堆中划出一块确定大小的内存,因为对象所需内存的大小在类加载完成后可以完全确定。

堆内存是否规整:

  • 堆内存规整:已使用的内存在一边,未使用的内存在另一边
  • 堆内存不规整:已使用内存和未使用内存相互交错

堆内存是否规整是由垃圾收集器是否带有压缩整理功能决定的。

内存分配方式

分配方式的选择取决于Java堆内存是否规整:

  • 指针碰撞方式:

    • 堆内存绝对规整。
    • 分配过程:将已使用内存和未使用内存之间放一个分界点的指针,分配内存时,指针会向未使用内存方向移动,移动一段与对象大小相等的距离。
  • 空闲列表:

    • 堆内存不规整。
    • 分配过程:虚拟机内部维护了一个记录可用内存块的列表,在分配时从列表找一块足够大的空间划分给对象实例,并更新列表上的记录。

对象的内存布局

在Java虚拟机中,对象在Java内存中的存储布局可分为三块:

  • 对象头 存储区域
  • 实例数据 存储区域
  • 对其填充 存储区域

对象头区域

存储对象自身的运行时数据,如:哈希码、GC分代年龄、锁标志状态、线程持有的锁、偏向线程ID、偏向时间戳。 存储对象类型指针,即对象指向类元数据的指针,JVM可以确定这个对象属于哪个类的实例。 如果是数组,对象头中还有一块记录数组长度的数据。

实例数据区域

代码中定义的字段内容。

对齐填充区域

  • 占位符。
  • 非必须。

TIP

占位符起站位作用,因为对象的大小必须是8字节的整倍数,而因 HotSpot VM 要求对象起始地址必须是8字节的整倍数,且对象头部分正好是8字节的倍数。 因此,当对象实例数据部分没有对齐时(即对象的大小不是8字节的整数倍),就需要通过对齐填充来补全。

内存泄漏原因

内存泄漏通常指的是程序中不再需要的内存由于某种原因未能被Java虚拟机(JVM)的垃圾回收器(GC)正确回收,从而导致该部分内存不可用,最终可能耗尽应用程序的所有可用内存。虽然Java有自动的垃圾回收机制来管理内存,但在某些情况下,对象仍然可能会意外地保持活动状态,无法被回收。

静态集合类

如果将对象添加到静态的集合类(如HashMap、ArrayList等)中,并且这些对象没有从集合中移除,那么它们会一直存在,因为静态成员在整个应用程序生命周期内都有效。这可能导致大量不再使用的对象占用内存空间。

java
public class OOM {
    static List list = new ArrayList();
    
    public void oomTests() {
        Object object = new Object();
        
        list.add(obj);
    }
}

未关闭的资源

像数据连接、IO、Socket等连接,创建的连接不再需要时,需要使用 close 方法关闭连接,只有连接关闭后,GC才会回收对应的对象(Connection、Statement、ResultSet、Session). 忘记关闭这些资源会导致持续占有内存,无法被GC回收。

java
    public static void main(String[] args) {
        try {
            Connection connection = null;
            Class.forName("com.mysql.jdbc.Driver");
            connection = DriverManager.getConnection("url", "", "");
            Statement statement = connection.createStatement();
            ResultSet resultSet = statement.executeQuery("...");
        } catch (Exception e) {

        } finally {
            // 不关闭连接
        }
    }

Hash值发生变化

对象Hash值改变,使用HashMap、HashSet等容器的时候,由于对象修改之后的Hash值和存储进容器时的Hash值不同,所以无法找到存入的对象,自然也就无法单独删除了,这也会造成内存泄漏。这也是为什么String类型被设置成了不可变类型。

如何判断对象仍然存活

Java中判断对象是否存活是通过垃圾回收机制(Garbage Collection, GC)实现的,主要涉及两种经典算法:引用计数法和可达性分析算法。 Java 主流的垃圾回收器(如 HotSpot VM)采用可达性分析算法来判断对象是否存活。当对象不再被任何“GC Roots”引用链可达时,它会被标记为可回收对象。

特性引用计数法可达性分析算法
循环引用处理无法解决可以解决
实时性高(立即回收)低(需要周期性扫描)
实现复杂度简单(分散在代码各处)复杂(需要遍历整个对象图)
性能开销频繁更新计数器暂停用户线程进行遍历
适用场景简单场景或辅助回收复杂内存结构的现代编程语言

引用计数法

原理
  • 每个对象维护一个引用计数器,记录当前有多少引用指向它。
  • 当新引用指向对象时,计数器加 1;当引用失效时,计数器减 1。
  • 当计数器为 0 时,对象被判定为可回收。
缺点
  • 无法解决循环引用问题:若对象 A 和 B 互相引用(A→B 且 B→A),即使它们不再被其他对象引用,计数器仍不为 0,导致内存泄漏。
  • 频繁更新计数器影响性能:每次引用关系变化都需要修改计数器。

可达性分析算法

原理
  • 从一系列称为 GC Roots 的根对象出发,遍历所有可达对象。
  • 未被遍历到的对象(即不可达对象)被判定为可回收。

GC Root 对象

  • 虚拟机栈中的局部变量引用的对象(当前执行方法中的局部变量)。
  • 方法区中静态变量引用的对象(类静态属性)。
  • 方法区中常量引用的对象(如字符串常量池中的引用)。
  • 本地方法栈中 JNI 引用的对象(Native 方法)。
  • Java 虚拟机内部引用(如基本类型对应的 Class 对象)。
优点
  • 能解决循环引用问题:只要循环引用的对象链不可达于 GC Roots,就会被回收。
  • 回收效率更高:适用于复杂的内存结构。
缺点
  • 需要暂停用户线程(Stop-The-World)进行对象图遍历。

垃圾收集算法

在 Java 中,垃圾收集(Garbage Collection, GC)的核心算法主要有以下三种:标记-清除算法、标记-复制算法和标记-整理算法。它们的设计目标是在不同场景下高效回收内存,减少程序停顿时间(Stop-The-World)。

特性标记-清除标记-复制标记-整理
内存碎片严重
空间开销无额外空间需预留一半内存无额外空间
时间效率中等(两次遍历)高(存活率低时)低(移动对象成本高)
适用场景存活对象多(老年代)存活对象少(新生代)存活对象多(老年代)

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

原理

标记-复制算法(Mark-Copy)主要应用于对象存活率较低的内存区域(如新生代)。其核心思想是通过内存空间的划分和对象复制,实现回收内存的同时避免产生内存碎片。具体实现是将可用内存划分为大小相等的两块(如From区和To区),每次只使用其中一块。当使用的内存块耗尽时,暂停程序(Stop The World),将其中所有存活的对象复制到另一块空闲内存中,然后一次性清理原内存块的所有对象。这样,每次回收都只针对半个内存区间。

流程

标记-复制算法的执行过程可分为四个阶段(以两块内存为例):

  1. 标记阶段:从 GC Roots 出发,遍历对象引用图,标记当前使用区(From 区)中所有存活的对象。
  2. 复制阶段:将标记的存活对象依次复制到另一块空闲区(To 区),复制过程中保持对象之间的原有引用关系,并确保新空间内对象紧密排列(无碎片)。
  3. 清除阶段:清空From区中所有对象(包括存活和已死亡),此时From区变为完全空闲。
  4. 角色交换:回收完成后,将From区和To区的角色互换(即原To区成为下一轮的新From区,原From区成为下一轮的空闲To区),为下一次垃圾收集做好准备。

标记-赋值算法流程

复制前

复制后

优点

  • 无内存碎片:复制后对象在目标空间中连续排列,分配新对象时只需移动指针(指针碰撞),分配速度快。
  • 高效回收:只需遍历存活对象,无需扫描整个堆;对于存活率低的场景,复制开销小,吞吐量高。
  • 实现简单:只需维护两个内存区域的指针和角色切换。

缺点

  • 内存利用率低:始终有一半内存处于空闲状态(作为复制目标),空间浪费明显。
  • 复制成本高:如果存活对象较多(如老年代),复制大量对象的开销会显著增加,甚至导致算法不可用。
  • 需暂停程序:标记和复制阶段需要暂停用户线程(STW),存活对象多时暂停时间较长。

优化:分代设计与分配担保

为了克服“一半内存闲置”的缺点,实际商用虚拟机(如HotSpot)对新生代的标记-复制算法进行了优化,将内存划分为更大的Eden区和两个较小的Survivor区(通常比例是8:1:1)

  • Eden区:新对象分配的主要区域,占新生代的80%。
  • 两个Survivor区(From和To):各占10%,用于存放每次垃圾收集后存活下来的对象。
  • 回收过程:
  • 新对象优先分配在Eden区(以及一个Survivor区,但初始时两个Survivor区均为空)。
  • 发生Minor GC时,将Eden区和From区中的存活对象复制到To区。
  • 如果To区空间不足,存活对象会通过分配担保机制直接进入老年代。
  • 回收完成后,清空Eden和From区,然后交换From和To的角色。
  • 效果:实际可用的内存空间为Eden + 一个Survivor(共90%),仅浪费10%的空间,大幅提升了内存利用率。

应用场景

  • Serial / ParNew:新生代使用标记-复制(基于指针碰撞和STW)。
  • G1:虽然整体是Region化,但新生代回收仍基于标记-复制思想,将存活对象从一个Region集复制到另一个Region集。
  • ZGC / Shenandoah:并发复制算法,但本质上也是标记-复制的并发化变体。

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

原理

标记清除算法分为标记清除两个阶段。首先从 GC Roots 触发,标记所有存活的对象;然后遍历整个堆,回收未被标记的对象(即死亡对象)。该算法不需要移动对象,因此无需额外的内存空间,但会产生内存碎片

流程

  • 标记阶段:从 GC Roots 出发,遍历所有可达对象,标记为存活对象(通常使用对象头中的标记位)。
  • 清除阶段:线性遍历堆中的所有对象,回收未被标记的垃圾对象(未标记即不可达),将它们的空间加入空闲列表(Free List),并清除存活对象的标记位以备下次使用。

流程

内存前后状态

优点

  • 空间利用率高:不需要像复制算法那样预留一半内存,所有内存均可用于分配对象。
  • 实现简单:只需要标记和清除两个阶段,无需移动对象,无需处理指针更新。

缺点

  • 内存碎片化:清除后剩余的内存空间不连续,导致后续分配大对象时可能因找不到连续空间而触发另一次垃圾收集(即使总空闲空间充足)
  • 分配效率低:由于存在碎片,分配对象通常需要使用“空闲列表”方式,而非简单的“指针碰撞”,分配速度较慢。
  • 标记和清除效率随对象增长而下降:需要扫描整个堆(或老年代)的所有对象(包括存活和死亡),当堆很大时,标记和清除的时间都会显著增加。
  • 停顿时间不可控:标记和清除阶段都需要暂停用户线程(Stop The World),对于大堆,停顿时间可能较长。

优化与改进

  • 标记-整理算法(Mark-Compact):针对碎片问题,在标记后将所有存活对象向一端移动(整理),然后清理边界以外的内存。这样既避免了碎片,又提高了分配效率(可使用指针碰撞),但移动对象会增加开销。
  • 并发标记-清除(如CMS):将标记阶段变为并发(与用户线程同时运行),减少停顿时间,但实现复杂,且仍存在碎片问题(需通过-XX:CMSFullGCsBeforeCompaction参数在多次GC后整理一次)。
  • 分代回收:在老年代使用标记-清除(或标记-整理),新生代使用复制算法,结合两者优势。

应用场景

  • CMS 垃圾收集器的老年代回收阶段

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

原理

标记-整理算法是标记-清除算法的改进版,主要用于解决内存碎片问题。其核心思想分为两个阶段:首先标记所有存活对象(与标记-清除相同),然后将所有存活对象向内存空间的一端移动(整理),最后直接清理掉边界以外的内存。通过移动对象,算法保证了内存的连续性,既避免了碎片,又提高了内存分配效率。

流程

  • 标记阶段:从GC Roots出发,遍历对象引用图,标记所有存活的对象。
  • 整理阶段:将所有存活对象向内存空间的一端(通常是起始端)移动,使它们紧凑排列。移动过程中需要更新所有指向这些对象的引用(指针修复),以保证程序的正确性。
  • 清除阶段:清空整理后边界之外的所有内存,使其成为连续的空闲空间。

流程

结果

优点

  • 无内存碎片:整理后内存连续,分配新对象可使用“指针碰撞”(Bump-the-Pointer),分配速度快。
  • 内存利用率高:无需像复制算法那样预留空间,所有内存均可用于分配。
  • 避免碎片带来的连锁GC:减少因碎片导致的提前Full GC。

缺点

  • 移动对象开销大:整理阶段需要移动大量存活对象并更新指针,如果老年代存活对象多,开销会显著增加,导致较长的暂停时间(Stop The World)。
  • 实现复杂:需要精确计算新地址、处理跨代引用、确保移动过程中对象引用关系正确。
  • 停顿时间不可控:移动对象通常需要暂停所有用户线程。

应用场景

  • Serial Old收集器:作为Serial的老年代版本,使用标记-整理算法(实际上是标记-压缩),在CMS并发失败时作为后备方案。
  • Parallel Old收集器:注重吞吐量的并行老年代收集器,基于标记-整理算法实现,与Parallel Scavenge新生代收集器搭配使用。
  • G1收集器:从整体上看,G1采用的是标记-整理算法,但它将堆划分为多个Region,通过局部复制(两个Region之间)来实现全局的整理效果,避免了全堆移动,从而平衡了停顿时间与碎片问题。
  • ZGC / Shenandoah:这些低延迟收集器虽然采用并发整理技术,但本质上仍属于标记-整理的并发变体,通过读屏障或染色指针实现对象的并发移动。

三种算法比对

特性标记-清除标记-复制标记-整理
内存利用率低(需预留一半)
内存碎片严重
分配方式空闲列表指针碰撞指针碰撞
执行效率标记和清除都慢仅复制存活对象移动对象开销大
适用场景老年代新生代老年代
停顿时间较长较短最长

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

Serial 收集器

基本概述

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器。它是一个单线程工作的收集器。它的单线程的意义不仅意味着它只会使用一条垃圾收集线程成去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其它所有的工作线程(“Stop The World”),直到它收集结束。

Serial收集器有新生代和老年代两个版本:

  • Serial(新生代):采用复制算法进行垃圾回收
  • Serial Old(老年代):采用标记整理算法进行垃圾回收`

Serial Old 收集器是 Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 及之前版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案

执行过程

工作原理

新生代回收(Serial): 当Eden区空间不足时,触发垃圾回收。Serial收集器暂停所有用户线程,将Eden区和From Survivor区中存活的对象复制到To Survivor区(或老年代),然后清空Eden和From区,最后交换Survivor区的角色。

老年代回收(Serial Old): 当老年代空间不足时,Serial Old收集器暂停所有用户线程,标记所有存活对象,然后将它们向内存一端移动整理,最后清理边界外的内存空间。

优点

  • 简单高效:与其他收集器的单线程版本相比,Serial没有线程交互的开销,专心做垃圾收集可以获得最高的单线程收集效率
  • 内存占用小:对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的
  • 实现简单:代码实现简单,不易出错

缺点

  • Stop-The-World:垃圾收集时必须暂停所有用户线程,可能导致应用卡顿
  • 无法利用多核:在多核处理器环境下无法发挥硬件优势
  • 扩展性差:随着堆内存增大,停顿时间会线性增加

适用场景

Serial收集器虽然在多核时代显得有些过时,但在特定场景下依然是最佳选择。

适用场景原因
单核/单处理器环境单线程执行无线程上下文切换开销,在单核环境下效率最高
小内存应用当堆内存较小时(如100MB以内),垃圾回收的停顿时间很短(几十毫秒),可被接受
微服务/嵌入式环境在资源受限的场景下,Serial收集器简单、低内存消耗的特性成为显著优势
CMS收集器的后备预案当CMS收集器发生"Concurrent Mode Failure"等情况时,会退化使用Serial Old进行Full GC

常用 JVM 调优参数

参数释义
-XX:+UseSerialGC同时启用 Serial / Serial Old

开启后,新生代使用 Serial(复制算法),老年代使用 Serial Old(标记-整理算法)
-XX:+UseSerialOldGC仅启用 Serial Old 收集器

新生代使用哪种收集器,取决于其他参数的设置

Parallel Scavenge 收集器

基本概述

Parallel Scavenge 收集器是 HotSpot 虚拟机中一款经典的新生代垃圾收集器,可以理解为 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等)和 Serial 收集器类似。默认的收集线程数跟CPU合数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。

Parallel Scavenge 收集器关注点是吞吐量(高效的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。Parallel Scavenge 收集器提供了很多参数供用户找到合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,可以选择把内存管理优化交给虚拟机去完成,也是一个不错的选择。

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记整理”算法。

在注重吞吐量以及 CPU 资源的场合,都可以考虑 Parallel Scavenge 收集器和 Paraller Old 收集器(JDK8 默认的新生代和老年代收集器)。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

因此,它非常适合那些主要在后台运算而不需要太多交互的分析任务,例如批量处理、订单处理、科学计算、数据报表生成等。

工作原理

Parallel Scavenge 是一个并行且多线程的新生代收集器。

  • 算法:采用标准的 复制算法
  • 内存布局:同其它新生代收集器一样,将内存划分为一个 Eden 区和两个 Survivor 区(S0/S1)。
  • 回收过程:在垃圾收集时,会暂停所有用户线程(Stop-The-World),然后启动多个 GC 线程并行地标记和复制存活对象。由于是多线程并行,它能充分利用多核CPU的优势,显著提升回收效率 。

流程

常用 JVM 调优参数

参数名称默认值作用与说明
-XX:+UseParallelGCJDK 8默认开启启用新生代使用 Parallel Scavenge 收集器。
-XX:+UseParallelOldGCJDK 8默认开启启用老年代使用 Parallel Old 收集器(标记-整理算法)。

通常开启一个,另一个也会被激活。
-XX:ParallelGCThreads=NCPU核心数有关设置垃圾收集的线程数。

当CPU数 ≤ 8时,默认等于CPU数;

当CPU数 > 8时,默认值为 3 + (5 * CPU数) / 8。
-XX:MaxGCPauseMillis=N无默认值设置最大GC停顿时间(毫秒)。

这是一个软目标,JVM会尽量满足。

如果设置过小,JVM可能会缩小堆空间,导致GC更频繁,反而降低吞吐量。
-XX:GCTimeRatio=N99设置GC时间占总时间的比例。

计算公式为 1 / (1 + N)。

N=99表示允许最大1%的GC时间,即吞吐量为99%。
-XX:+UseAdaptiveSizePolicy开启开启自适应调节策略。

开启后,将自动调整新生代大小、Eden/Survivor比例、晋升年龄等参数,无需手动指定。

ParNew 收集器

基本概述

ParNew 收集器其实跟 Parallel Scavenge 与 Serial 收集器很类似,区别主要在于它是能与 CMS 收集器配合工作的新生代收集器。

JDK8 及之前,它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器)配合工作。

建议

ParNew 收集器是 JVM 并行垃圾收集的早期代表,它通过多线程并行回收,解决了单核时代到多核时代的过渡问题。

它最重要的历史角色是作为 CMS 收集器的"黄金搭档",共同构成了 JDK 8 时代经典的"低延迟"组合(ParNew + CMS + Serial Old)

虽然在 JDK9 及之后,G1 成为今天的主流,ParNew 的使用场景已大幅减少,但理解 ParNew 仍然是理解 CMS 和 G1 等更现代收集器工作原理的重要基础。

流程

核心概念

ParNew 的本质是一个并行(Parallel)但"Stop-The-World"(STW)的新生代收集器。

  • 并行:这里的"并行"特指多条垃圾收集线程并行工作。在 GC 期间,所有用户线程都会被暂停,但 GC 本身是由多个线程同时执行的。
  • 与 Serial 的关系:除了是多线程的,ParNew 在回收算法(复制算法)、对象分配规则、STW 行为等各方面,都与 Serial 收集器完全一致,甚至两者共享了大量代码。可以把它看作是 Serial 的"多线程优化版"。

适用场景

  • 与 CMS 配合:这是 ParNew 最核心的价值所在。在 JDK 8 及更早版本中,如果你想使用低延迟的 CMS 老年代收集器,新生代几乎只能选择 ParNew。
  • Server 模式:它曾是许多运行在 Server 模式下的 JVM 中新生代的默认收集器 。

局限性

  • 单核/单线程环境效率低:在单核 CPU 上,ParNew 的多线程切换开销反而会成为负担,其表现甚至不如简单的单线程 Serial 收集器。
  • 存在 STW 停顿:虽然回收变快了,但它仍然需要暂停所有用户线程,无法做到像 CMS 那样在大部分时间里并发执行。
  • 版本演进:从 JDK 9 开始,G1 成为默认垃圾收集器,ParNew + CMS 的组合被标记为废弃。-XX:+UseParNewGC 这个强制启用参数在 JDK 8 及之后版本中已失效,现在启用 ParNew 的唯一方式就是通过 -XX:+UseConcMarkSweepGC 来间接激活。

常用 JVM 调优参数

参数名称默认值作用与说明
-XX:+UseConcMarkSweepGC关闭老年代启用 CMS 收集器,新生代会自动默认使用 ParNew 。

这是目前激活 ParNew 的唯一有效方式。
-XX:+UseParNewGC关闭强制启用 ParNew 收集器(JDK 8 及之后版本已失效,不再推荐使用)。
-XX:ParallelGCThreads=nCPU 核心数有关设置 ParNew 并行收集的线程数。

当 CPU 核心数小于 8 时,默认等于核心数;大于 8 时,默认值为 3 + (5 * CPU_count) / 8 。

建议设置不超过 CPU 核心数,避免因线程上下文切换导致性能下降 。

CMS 收集器

基本概念

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

核心流程

从名字中的 Mark Sweep 这两个词可以看出,CMS 收集器是一种标记-清除算法实现的,整个运作过程可分为四个步骤:

1. 初始标记:暂停所有用户线程(STW),并记录下 GC Roots 直接能引用的对象,速度很快。

与 GC Roots 直接关联的对象:如栈中引用的对象,静态变量等。

2. 并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图,标记所有存活对象。这个过程耗时较长但不需要停顿用户线程,可以与垃圾收集线程一起并发运行。

因为用户程序继续运行,可能会随着用户线程执行的结束,使已经标记过的对象状态发生改变。
(即原先被标记存活的对象可能此时已变成了可回收对象,也称为浮动垃圾

3. 重新标记:再次暂停所有用户线程(STW),修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(主要是处理漏标问题),这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法做重新标记。

多标对象:即本身是垃圾的对象被 JVM 视为了非垃圾对象而未被回收

漏标对象:即本身非垃圾的对象被 JVM 视为了垃圾对象而被回收

📌必读
在并发标记阶段产生的多标对象可以在下一轮 GC 回收时再回收,

但漏标对象一定要在本次标记完成,否则可能导致非垃圾对象被回收。

所以才需要重新标记阶段。

4. 并发清理:开启用户线程,同时 GC 线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理。

注意

并发清理阶段也一样可能会再次产生浮动垃圾,但这并非是要在本次垃圾回收需要清理的对象。这个时间段产生的垃圾对象等到下一次 GC 再清理也是没问题的。

5. 并发重置:重置本次 GC 过程中的标记数据。

流程

TIP

虽然 CMS 完成一次垃圾回收的总耗时可能长于 Parallel Old,但其 STW(Stop-The-World)时间占比极低,单次停顿时间通常远短于 Parallel Old,从而显著提升了用户体验。

优点

  • 并发收集、低停顿:这是 CMS 最核心的优点。它将最耗时的标记和清除阶段都设计为与用户线程并发执行,使得垃圾回收过程对应用的影响降到了最低 。
  • 对延迟敏感的应用友好:非常适合那些需要快速响应用户请求的在线服务 。

缺点

  • 对 CPU 资源非常敏感:在并发阶段,CMS 虽然不会导致用户线程暂停,但会占用一部分 CPU 资源,可能导致应用程序的整体吞吐量下降 。
  • 无法处理浮动垃圾(Floating Garbage):由于并发清理阶段用户线程仍在运行,期间产生的新的垃圾对象(浮动垃圾)无法在这次收集中被清理掉,只能留到下一次 GC 。这要求必须预留一部分内存空间给用户线程使用。
  • 基于“标记-清除”算法导致内存碎片:CMS 使用的标记-清除算法会产生大量内存碎片。当碎片化严重时,即使老年代总空间还很大,也可能因为无法找到连续空间而无法分配大对象,从而提前触发 Full GC(这时会用 Serial Old 收集器进行整理,导致很长的停顿)。
  • “并发模式失败”(Concurrent Mode Failure)风险:如果在 CMS 回收过程中,老年代空间在预留内存被消耗完之前就被填满,就会发生“并发模式失败”。此时 JVM 会暂停应用,启用后备的 Serial Old 收集器进行 Full GC,导致很长的停顿。

CMS关键参数

参数名称默认值作用与说明
-XX:+UseConcMarkSweepGC关闭启用 CMS 收集器(老年代),新生代将自动默认使用 ParNew。
-XX:+UseParNewGC启用CMS时自动开启启用 ParNew 作为新生代收集器。在 JDK 8 及之后,与 CMS 搭配时此参数已自动生效。
-XX:ParallelGCThreads=nCPU核心数有关设置 STW 期间并行 GC 的线程数(如初始标记、重新标记)。当 CPU 数 ≤ 8 时,默认等于 CPU 数;> 8 时,默认值为 3 + (5 * CPU_count) / 8
-XX:ConcGCThreads=nParallelGCThreads 计算得出设置并发阶段(如并发标记、并发清除)的线程数。通常为 ParallelGCThreads / 4 或类似比例。
-XX:+CMSParallelInitialMarkEnabledJDK7 默认关闭,JDK8 默认开启开启多线程并行执行初始标记阶段,减少 STW 停顿时间。
-XX:+CMSParallelRemarkEnabled开启(JDK8 默认开启)开启多线程并行执行重新标记阶段,减少 STW 停顿时间。
-XX:CMSInitiatingOccupancyFraction=nJDK5:68%
JDK6+:92%
设置触发 CMS GC 的老年代使用率阈值。设置得太低会导致 GC 过于频繁,太高则容易发生“并发模式失败”。
-XX:+UseCMSInitiatingOccupancyOnly关闭强制 JVM 始终使用 CMSInitiatingOccupancyFraction 设定的阈值,而不使用 JVM 动态调整的值。
-XX:+UseCMSCompactAtFullCollectionJDK9前开启,后废弃设置 CMS 在 Full GC 后是否进行内存整理(标记-整理),以解决碎片问题。这会增加停顿时间。
-XX:CMSFullGCsBeforeCompaction=n0与上一个参数配合,设置执行多少次 Full GC 后进行一次内存整理。
-XX:+CMSClassUnloadingEnabled关闭允许在 CMS GC 时对方法区(元空间)进行类卸载。
-XX:+ExplicitGCInvokesConcurrent关闭使 System.gc() 调用触发一次 CMS GC 而不是 Full GC,避免显式 GC 导致的长停顿。
-XX:+CMSScavengeBeforeRemark关闭在重新标记阶段前强制进行一次 Minor GC,以减少新生代对象对老年代引用的扫描,从而缩短重新标记的停顿时间。

三色标记

基本概念

三色标记(Tri-color Marking)是垃圾回收(GC)中一种用于追踪对象存活状态的抽象算法。主要应用在并发标记阶段(如 CMS、G1 等收集器),它通过将对象标记为三种颜色(黑、灰、白)来高效的记录标记进度,解决在并发环境下对象引用变化导致的漏标问题。标记开始之前,所有的对象都默认是白色对象。

颜色含义状态
白色尚未被垃圾回收器访问过的对象。在标记结束时,仍为白色的对象被视为不可达,即垃圾。
灰色自身已被标记,但其引用的对象尚未被全部扫描完的对象。处于灰色状态的对象是标记工作的中间状态,需要进一步扫描其引用。
黑色自身及其所有直接引用对象都已被扫描标记完的对象。黑色对象是安全存活的对象,不需要再扫描其引用。

三色标记的工作流程

1. 初始标记(Initial Marking),暂停用户线程(短暂 Stop-The-World),标记所有从 GC Roots 直接可达的对象为灰色。例如:栈中的局部变量、静态变量等直接引用的对象。

2. 并发标记(Concurrent Marking),恢复用户线程,垃圾回收线程并发遍历灰色对象:将灰色对象的所有子对象标记为灰色,自身标记为黑色。重复此过程,直到没有灰色对象。

3. 重新标记(Remark),再次暂停用户线程,处理并发标记期间因用户线程修改引用导致的漏标对象。例如:使用 增量更新(Incremental Update) 或 原始快照(SATB,Snapshot-At-The-Beginning)修正标记。

4. 并发清除(Concurrent Sweep),恢复用户线程,清除所有白色对象(未被标记的垃圾)。

漏标

并发标记漏标示例

三色标记

java
public class ThreeColorRemark {

    public static void main(String[] args) {
        A a = new A();
        // 开始做并发标记
        D d = a.b.d; // 1. 读
        a.b.d = null; // 2. 写
        a.d = d; // 3. 写
    }

}

class A {
    B b = new B();
    D d = null;
}

class B {
    C c = new C();
    D d = new D();
}

class C {

}

class D {

}
text
A对象(a):b 指向 B,d 为 null。

B对象:c 指向 C,d 指向 D。

C对象:无引用。

D对象:无引用。

所有对象初始均为白色(未标记)。根对象为 a(main方法中的局部变量)。
text
假设并发标记进行到某一时刻:

A 已被标记为黑色(从根出发扫描完其直接引用,即已处理 a.b,并将 B 标记为灰色)。

B 为灰色(自身已标记,但尚未扫描其所有引用,比如还未处理 b.d)。

C 为黑色(C 被 b 引用,自身无其它引用,已被标记)。

D 仍为白色(未被标记)。

此时,用户线程执行以下三行代码:
D d = a.b.d; // 1. 读
a.b.d = null; // 2. 写
a.d = d; // 3. 写
text
D d = a.b.d;

读取 a.b.d,得到 D 对象的引用。此时 D 仍为白色,但局部变量 d 持有其引用(局部变量属于根,但这里只是读取,未改变对象图)。

a.b.d = null;

将 B 的 d 字段置为 null,断开 B → D 的引用。此时 D 不再被 B 引用。

a.d = d;

将 A 的 d 字段指向 D,建立 A → D 的新引用。
text
A(黑色)→ D(白色)

B(灰色)不再指向 D

C 仍为黑色

并发标记-漏标风险分析

  • A 是黑色,其引用已被视为扫描完毕,不会再重新扫描,因此新建立的 A→D 引用不会被发现。
  • B 是灰色,后续会扫描其引用,但 B→D 已删除,所以扫描时不会标记 D。
  • 结果:D 虽然存活(通过 A 可达),但在标记结束时仍为白色,将被错误回收。
  • 这就是典型的漏标问题:黑色对象引用了白色对象,而原本指向该白色对象的灰色引用被删除。

漏标解决方案

增量更新(Incremental Update)就是当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦插入了指向白色对象的引用之后,它就变回灰色对象了

原始快照(Snapshot At The Beginning,SATB)就是当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色,认为它在标记开始时是存活的。(目的就是让这种对象在本轮 GC 清理中能存活下来,待下一轮 GC 的时候重新扫描,这个对象也可能是浮动垃圾

TIP

CMS 使用增量更新实现漏标处理, G1使用原始快照的方式实现漏标处理。