Loading...

深入理解 Java 虚拟机(一)

本文是在 JVM 学习过程中整理的笔记,其中最主要参考了《深入理解 Java 虚拟机》一书,归纳其要点,并在此基础上结合网上博客、ChatGPT 等知识平台进行完善。

本文知识点主要涵盖 JVM 内存模型和垃圾回收两大部分,几乎全文重点,可以结合书本阅读,也可以与视频教程配套进行理解。

如果您对文本教程感到枯燥,这里也可以推荐观看满一航老师的视频教程(约 18h):Java虚拟机快速入门_哔哩哔哩_bilibili

JVM 概览

基本介绍

JVM(Java Virtual Machine,Java 虚拟机)是 Java 程序运行的虚拟计算机,它是 Java 程序的核心组件之一。JVM 将 Java 源代码编译后生成的 Java 字节码解释执行,并提供了一些基本的运行时环境,如内存管理、垃圾回收、线程管理等。JVM 是 Java 程序跨平台性的基础,使得 Java 程序可以在不同的操作系统和硬件平台上运行。

使用 JVM 具有以下好处:

  1. 跨平台性:JVM 提供了 Java 程序在不同操作系统和硬件平台上运行的能力,使得 Java 程序具有很好的跨平台性。

  2. 自动内存管理:JVM 提供了自动内存管理功能,包括垃圾回收、内存分配等,这减少了程序员对内存管理的负担,提高了开发效率。

  3. 安全性:JVM 提供了安全管理机制,可以保护 Java 程序免受恶意代码攻击。同时,JVM 还提供了安全沙箱机制,可以隔离 Java 程序与操作系统之间的交互,从而增强了程序的安全性。

  4. 高性能:JVM 采用了即时编译技术,可以将 Java 字节码编译成本地机器码,从而提高了程序的执行效率。

  5. 动态性:JVM 支持动态类加载和卸载,可以在程序运行时根据需要加载和卸载类,从而提高了程序的灵活性和可扩展性。

如下是一些常见的 JVM 实现:

图源:https://en.wikipedia.org/wiki/Comparison_of_Java_virtual_machines

其中,HotSpot 是我们最为常用的一种虚拟机实现。

JVM,JRE,JDK 对比:

  1. JDK:Java SE Development Kit,Java 标准开发包,提供了编译、运行 Java 程序所需的各种工具和资源。

  2. JRE:Java Runtime Environment,Java 运行环境,用于解释执行 Java 的字节码文件。

调优方向

JVM 调优主要集中在以下方向:

  1. 堆内存调优:调整堆内存大小、调整垃圾回收策略、调整对象的生命周期等。

  2. GC 调优:通过调整 GC 算法、GC 回收时间、GC 线程数量等来优化 GC 性能。

  3. 线程调优:通过调整线程池大小、线程优先级、线程调度策略等来优化线程性能。

  4. 类加载调优:通过调整类加载器的数量、类加载的顺序等来优化类加载性能。

  5. JIT 调优:通过调整JIT编译器的参数、调整编译策略等来优化 JIT 性能。

  6. IO 调优:通过使用缓存、调整 IO 线程数量等来优化 IO 性能。

  7. 内存泄漏调优:通过检查代码中的内存泄漏问题、优化对象的生命周期等来避免内存泄漏问题。

内存区域与内存溢出

运行时数据区域

运行时数据区域是计算机程序在运行时所使用的内存空间,如下图所示:

运行时数据区域在 Java 虚拟机启动时就会被创建,并在 Java 程序运行期间动态地进行内存分配和回收。

程序计数器

「程序计数器」是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,也可以理解为当前线程所执行的指令的地址。

在 JVM 中,每个线程都有自己的程序计数器,当线程执行一个方法时,程序计数器会记录下该线程正在执行的字节码指令的地址或是下一条指令的地址。当线程执行完一个方法,程序计数器会被更新为下一个方法的第一条指令的地址。程序计数器只有在线程执行 Java 方法时才有意义,如果线程执行的是 Native 方法,则程序计数器的值为 Undefined。

程序计数器的作用主要有两个:

  1. 线程切换:由于 JVM 采用的是抢占式线程调度模型,当线程切换时,需要记录当前线程的执行状态,程序计数器就是用来记录线程执行状态的。

  2. 指令跳转:在 Java 程序中,有很多控制流语句,如 if-else、for、while 等,这些语句需要根据条件跳转到不同的指令,程序计数器就是用来记录跳转指令的地址的。

JAVA 虚拟机栈

与程序计数器一样,虚拟机栈也是线程私有的(生命周期与线程一致),它用于存储线程执行 Java 方法的信息,包括局部变量、操作数栈、方法出口等。

虚拟机栈的主要作用是管理 Java 方法的调用和执行。当一个方法被调用时,虚拟机会为该方法创建一个栈帧,用于存储该方法的局部变量、操作数栈、方法出口等信息。当该方法执行完毕时,虚拟机会将该栈帧弹出,恢复到调用该方法的栈帧中继续执行。

Java 虚拟机栈的大小是可以设置的,一般情况下,栈的大小越大,可以支持的方法调用层数就越深。但是,栈的大小也受到系统内存的限制,在内存不足的情况下,栈大小过大会导致系统崩溃。

Java 虚拟机栈的大小对程序性能也有一定的影响。栈的大小越小,虚拟机栈就越容易发生 StackOverflowError,而栈的大小越大,虚拟机栈就需要更多的内存,可能会导致 GC 频繁触发,进而影响程序的性能。因此,在设置虚拟机栈大小时,需要根据具体情况进行权衡和调整。

本地方法栈

本地方法栈(Native Method Stack)是 JVM 为 Java 方法调用所准备的一块内存区域,也是虚拟机栈的一部分。与虚拟机栈中的栈帧一样,本地方法栈也是用于支持 Java 方法调用和执行的。不同的是,虚拟机栈是为 Java 方法执行服务的,而本地方法栈则是为 Java 虚拟机使用到的 Native 方法服务的。

Native 方法是指使用其他语言(如C、C++等)编写的方法,它们不是用 Java 语言编写的。在 Java 程序中调用Native 方法时,Java 虚拟机需要将控制权转移到本地方法栈中,执行对应的本地方法。因此,本地方法栈也承担着 Java 虚拟机与本地代码交互的桥梁的作用。

与虚拟机栈类似,本地方法栈也是一个后进先出(LIFO)的栈结构。每个本地方法在执行时都会创建一个栈帧,用于存储本地方法的局部变量、操作数栈、返回值等信息。当本地方法执行完成后,对应的栈帧会被弹出,控制权重新回到 Java 虚拟机。

JAVA 堆

Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块,也是程序中所有线程共享的一块内存区域。Java 堆是 Java 程序中用于动态分配内存的主要区域,所有的对象实例、数组等都是在 Java 堆上分配的。

Java 堆的大小可以通过启动参数-Xms-Xmx来控制,其中-Xms用于设置 Java 堆的初始大小,-Xmx用于设置 Java 堆的最大大小。Java 堆的大小可以动态调整,但调整过程会影响程序的性能。

Java 堆的实现方式有很多种,其中比较常见的是基于指针的堆实现方式和基于索引的堆实现方式。基于指针的堆实现方式使用指针来指向下一个可用的内存地址,而基于索引的堆实现方式则使用数组来管理内存。

Java 堆在内存中的位置是连续的,它的大小可以在运行时动态调整。Java 堆中的对象实例可以被所有线程共享,因此需要考虑线程安全的问题。Java 虚拟机还提供了垃圾回收机制来自动回收 Java 堆中不再使用的对象实例,以避免 Java 堆溢出的问题。

📌 TLAB

所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread-Local Allocation Buffer,TLAB),以提升对象分配时的效率。

在 Java 中,对象的创建是通过在堆上分配内存来实现的。当 JVM 需要为一个对象分配内存时,它通常会在堆上寻找一个足够大的连续空间,然后将这个空间标记为已使用,并返回这个空间的起始地址。这个过程需要进行锁定和同步,所以会导致性能下降。

为了避免这种性能下降,JVM 引入了 TLAB。每个线程都有自己的 TLAB,这个 TLAB 是一个预先分配的、线程专用的内存缓冲区。当一个线程需要分配一个新的对象时,它会从自己的 TLAB 中获取一块空间,并在这块空间中分配对象。由于 TLAB 是线程专用的,所以不需要进行锁定和同步,因此对象分配的效率可以得到极大的提高。同时,由于 TLAB 是预先分配的,所以也减少了堆的碎片化问题,提高了垃圾回收的效率。

不是所有的对象实例都能在 TLAB 中成功分配到内存。当一个对象的大小超过了 TLAB 的大小时,它就无法在 TLAB 中分配内存了。此时,JVM 会尝试从堆中的其他区域中分配内存,或者进行垃圾回收以释放一些空间,以便为对象分配足够的内存。如果在堆上无法找到足够的连续内存空间,则 JVM 会抛出 OutOfMemoryError 异常。

参数设置:

  • -XX:UseTLAB:设置是否开启 TLAB 空间

  • -XX:TLABWasteTargetPercent:设置 TLAB 空间所占用 Eden 空间的百分比大小,默认情况下 TLAB 空间的内存非常小,仅占有整个 Eden 空间的1%

  • -XX:TLABRefillWasteFraction:指当 TLAB 空间不足,请求分配的对象内存大小超过此阈值时不会进行 TLAB 分配,直接进行堆内存分配,否则还是会优先进行 TLAB 分配

方法区

方法区(Method Area)是 Java 虚拟机中的一块内存区域,用于存储类的信息、静态变量、常量、编译器编译后的代码等数据。它与 Java 堆一样,方法区也是所有线程共享的一块内存区域。方法区的大小可以通过启动参数-XX:PermSize-XX:MaxPermSize来控制,其中-XX:PermSize用于设置方法区的初始大小,-XX:MaxPermSize用于设置方法区的最大大小。

方法区中存储的数据包括:

  1. 类的信息:包括类的名称、父类的名称、类的访问修饰符、类的字段、方法等信息。

  2. 静态变量:Java 中的静态变量是被所有对象所共享的,它们存储在方法区中。

  3. 常量:Java 中的常量包括字符串常量、数字常量等,它们也存储在方法区中。

  4. 编译器编译后的代码:Java 虚拟机将 Java 源代码编译成字节码后,会将字节码存储在方法区中。

方法区的垃圾回收主要针对常量池和无用的类。常量池中的常量不再使用时,会被垃圾回收机制自动回收。无用的类是指没有任何实例被引用,也没有被任何其他类所引用的类,这些类也会被垃圾回收机制自动回收。方法区溢出的情况比较少,但如果不合理地使用动态代理、反射等技术,也有可能导致方法区溢出。

运行时常量池的大小是由 JVM 在运行时动态分配的,它的大小也受到 JVM 参数的限制。当运行时常量池无法再分配更多的内存时,就会抛出 OutOfMemoryError 异常。

在 Java 8 及之前的版本中,方法区是一个独立的区域,而在 Java 8 及之后的版本中,方法区被替换为了 Metaspace。相比之下,方法区的内存回收相对较难触发,这是由于以下几个原因:

  1. 方法区中存储的是类的元数据、常量池等信息,这些信息通常是在应用程序启动时就加载到方法区中的,很少会发生变化。因此,方法区中存储的对象的生命周期比较长,不容易被回收。

  2. 方法区中的对象通常是由 JVM 内部生成的,而不是由应用程序代码生成的。这些对象的生命周期是由 JVM 内部控制的,应用程序很难直接控制它们的生命周期。

  3. 方法区中的对象通常是共享的,多个线程都可能引用同一个对象。因此,在回收方法区中的对象时,需要考虑对象之间的引用关系,这增加了内存回收的复杂性。

为了解决方法区内存回收的问题,JVM 在 Java 8 中引入了 Metaspace,它使用了一种新的内存管理模型,使得内存回收更加高效和稳定。在 Metaspace 中,类的元数据等信息是以元空间(MetaSpace)的形式存储的,它位于堆外内存中,随着应用程序的运行而动态地进行内存分配和回收。相比之下,Metaspace 的内存回收更加灵活和高效。

运行时常量池

运行时常量池(Runtime Constant Pool)是 Java 虚拟机中的一块内存区域,用于存储编译器生成的各种字面量和符号引用。它是方法区的一部分,也是所有线程共享的一块内存区域。

在 Java 7 及之前的版本中,运行时常量池是方法区的一部分,而在 Java 8 及之后的版本中,运行时常量池被移到了堆中。其目的主要是为了解决方法区内存回收的问题,同时也能够提高运行时常量池的性能和灵活性。在堆中,运行时常量池能够共享堆的内存管理机制,包括垃圾回收、内存分配等,这使得运行时常量池的内存管理更加简单和高效。同时,由于堆是线程共享的,因此在多线程环境下,运行时常量池的访问和操作也更加方便和高效。

在 Java 程序中,所有的字面量和符号引用都会被编译器存储在 class 文件的常量池中。当 Java 虚拟机加载类时,会将常量池中的数据加载到运行时常量池中,供程序在运行时使用。运行时常量池中的数据包括类的常量池中的数据以及一些动态生成的数据。

运行时常量池中存储的数据类型包括:

  1. 字符串常量:Java 程序中的字符串常量都会被编译器存储在常量池中,并在运行时加载到运行时常量池中。

  2. 基本类型常量:Java 程序中的基本类型常量(如整型、浮点型、布尔型等)也会被编译器存储在常量池中,并在运行时加载到运行时常量池中。

  3. 类和接口的全限定名:类和接口的全限定名也会被编译器存储在常量池中,并在运行时加载到运行时常量池中。

  4. 方法和字段的符号引用:Java 程序中的方法和字段都有一个符号引用,它们的符号引用也会被编译器存储在常量池中,并在运行时加载到运行时常量池中。

直接内存

直接内存(Direct Memory)不是由 Java 虚拟机所管理的,而是由操作系统管理的一块内存区域。直接内存通常是通过 Java 的 NIO(New IO)库来使用的,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,这样能在很多场景中显著地提高直接内存的性能。也正因此,本机直接内存的分配不会受到 Java 堆大小的限制。

在 Java 程序中,如果需要进行大量的 I/O 操作,使用 Java 的 NIO 库可以提高程序的性能。NIO 库中的缓冲区可以直接从直接内存中分配,而不需要将缓冲区的数据先拷贝到 Java 堆中,然后再进行 I/O 操作。这样可以避免在进行 I/O 操作时频繁地进行内存拷贝,提高程序的性能。

直接内存的优势在于它可以避免 Java 堆和操作系统之间的复制,从而提高了程序的性能。但同时,直接内存的缺点在于它的分配和回收都需要进行系统调用,因此比 Java 堆的分配和回收更加耗时。此外,直接内存也容易导致内存泄漏等问题,因此在使用时需要格外注意。

直接内存的大小可以通过 JVM 参数-XX:MaxDirectMemorySize来控制,它的大小也受到操作系统的限制。当直接内存无法再分配更多的内存时,就会抛出 OutOfMemoryError 异常。

HotSpot 虚拟机对象探秘

对象的创建

Java 对象在堆中的分配过程包括以下几个步骤:

  1. 确定对象的类型:在程序中创建一个新的对象时,首先需要确定对象的类型。对象的类型决定了对象所占用的内存空间大小和对象实例变量的布局。

  2. 计算对象的大小:确定对象的类型后,JVM 会计算对象所需的内存空间大小。对象的大小由对象的类型和实例变量的数量和类型决定。

  3. 为对象分配内存空间:JVM 会在堆中查找一块足够大的连续内存空间,用于存储对象的实例变量。如果堆中没有足够大的连续内存空间,JVM 会触发垃圾回收机制来释放一些内存空间。如果垃圾回收机制无法释放足够的内存空间,JVM 会抛出 OutOfMemoryError 异常。

    扩展:指针碰撞与空闲列表

    对象在堆中进行内存分配时可以使用两种方式:指针碰撞和空闲列表。

    1. 指针碰撞:指针碰撞是一种内存分配算法,它假定堆中的内存空间是一段连续的内存区域,分配对象时只需要将堆的起始地址和已分配内存的末尾地址相加,就可以得到下一个可分配的内存地址。这种算法适用于堆空间的分配和回收比较频繁的场景。

    2. 空闲列表:空闲列表是一种内存分配算法,它维护一个链表来记录已经分配和未分配的内存块。当程序需要分配一个新的对象时,会遍历空闲列表,找到一个足够大的内存块来存储对象。在对象被回收时,空闲列表会将该内存块标记为未分配状态,以便下一次分配时可以重复使用。这种算法适用于堆空间的分配和回收比较不频繁的场景。

    指针碰撞和空闲列表都有各自的优缺点。指针碰撞的优点是分配速度快,缺点是容易产生碎片,导致内存利用率降低。空闲列表的优点是可以避免碎片问题,缺点是需要维护一个链表来记录内存块,分配和回收速度相对较慢。

    在实际应用中,JVM 会根据当前堆空间的情况来选择使用指针碰撞或空闲列表。如果堆空间比较大且分配和回收比较频繁,JVM 会选择使用指针碰撞;如果堆空间比较小且分配和回收比较不频繁,JVM 会选择使用空闲列表。

  4. 初始化对象:在分配内存空间后,JVM 会对对象进行初始化。对象的初始化包括实例变量的初始化和对象头的设置。对象头用于存储对象的元数据信息,比如对象的哈希码、同步状态等。

  5. 返回对象引用:在对象初始化完成后,JVM 会返回一个指向对象在堆中内存空间的引用。程序可以使用这个引用来访问对象的实例变量和方法。

对象的内存布局

在 HotSpot 虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充。

  1. 对象头:HotSpot 虚拟机对象的对象头部分包括两类信息,第一类是用于存储对象自身的运行时数据,如哈希码,GC 分代年龄,锁状态标志,线程持有的锁,偏向线程 ID,偏向时间戳等信息;另一类则是类型指针,即对象指向它的类型元数据的指针,其作用是用于确定对象的类型和访问对象的方法和实例变量。

  2. 实例数据:实例数据是对象真正存储的有效信息,即我们在程序代码中所定义的各种类型的字段内容。这些内容在对象的内存布局中的位置通常是按照声明的顺序依次存储的。在程序中访问对象的实例变量时,JVM 会使用对象的内存地址和实例变量的偏移量来定位实例变量的值。

  3. 对齐填充:对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。其主要目的是为了保证对象的起始地址是按照 JVM 实现的对齐规则对齐。

对象的访问定位

所有对象的存储空间都是在堆中分配的,但这个对象的引用却是在栈中分配的。如何根据对象的引用寻找到对象的具体位置,产生了两种不同的方式:句柄访问和直接指针访问。

  1. 句柄访问

    基于句柄访问时,Java 堆中可能会划分出一块内存作为句柄池,栈中所存储的对象的引用,实际上就是对象的句柄地址,而句柄中又包含了对象的实例数据与类型数据各自具体的地址信息。

    TODO 绘图说明。

  2. 直接指针访问(HotSpot)

    基于直接指针访问时,栈中所存储的对象的引用就直接是实际数据的地址。

    TODO 绘图说明。

句柄访问可以提高安全性和灵活性,因为它可以隐藏实际数据的地址,并且可以让程序在不同的环境中使用相同的句柄来访问不同的数据。但相应的,句柄访问的效率比直接指针访问低,因为它需要多次访问内存来获取实际数据的地址。而直接指针访问的效率高,因为它直接访问实际数据,不需要额外的内存访问。但是,直接指针访问也存在一些安全问题,因为它暴露了实际数据的地址,可能被恶意程序利用。

注:HotSpot 虚拟机采用的是直接指针的访问方式。

OutOfMemoryError 异常

除了程序计数器之外,虚拟机内存的其他几个运行时区域都有发生 OutOfMemoryError 异常的可能。

Java 堆溢出

Java 堆内存的 OOM 异常是实际应用中最常见的一种,一个简单的示例如下:

根据异常提示,我们可以明确定位到问题为堆内存溢出。

在处理堆内存溢出问题时,首先应当确认是内存泄漏还是内存溢出。

  1. 内存泄漏(Memory Leak)

    指程序中存在一些对象或数据结构,它们已经不再被使用,但由于某些原因,它们没有被释放。这些对象或数据结构会一直占用堆内存,导致堆内存不断增加,最终导致内存溢出。Java 堆的内存泄漏通常是由程序编写不当、设计不良或者程序 bug 引起的。

    内存泄漏通常可以通过工具查看泄漏对象到 GC Root 的引用链,找到垃圾回收器无法回收的原因。

  2. 内存溢出(Memory Overflow)

    指程序需要的堆内存超过了 JVM 分配给它的堆内存大小。这种情况通常是由于程序需要的堆内存太大,而 JVM 分配的堆内存太小,导致无法满足程序的需求。Java 堆的内存溢出通常是由于程序设计不合理、数据量过大或者系统资源不足引起的。

    内存溢出则可以尝试通过调整虚拟机堆内存大小参数、排查生命周期过长的对象和排查不合理的存储结构设计等方式进行解决。

虚拟机栈和本地方法栈溢出

在 HotSpot 虚拟机中,是不会区分虚拟机栈和本地方法栈的,这是因为在 HotSpot 虚拟机中,虚拟机栈和本地方法栈是合并在一起的,它们共用同一块内存,也就是说,一个线程要么在虚拟机栈中执行 Java 方法,要么在本地方法栈中执行本地方法。

通常,虚拟机栈和本地方法栈出现内存溢出,则代表着线程请求的栈深度超过了虚拟机所允许的最大深度,此时就会抛出 StackOverflowError 异常。

栈帧太大或虚拟机栈容量太小都会压缩栈的有效使用空间,最终导致栈中无法容纳新的栈帧,从而引发异常。

此外,有的虚拟机实现还允许动态扩展栈的容量大小,但 HotSpot 选择的是不支持动态扩展,因为在总体资源不变的情况下,动态扩展的栈内存会抢占其他内存区域的可用容量,从而引发其他异常问题。

出现 StackOverflowError 异常时通常会有明确的错误堆栈可供分析,相对容易查找到问题所在。

在很多情况下,栈内存溢出一般都是由于代码编写过程中递归过深导致的,这时应当优先考虑通过代码排查,优化算法,然后再考虑通过虚拟机参数调整栈空间。

方法区和运行时常量池溢出

在 JDK8 之后,永久代退出了历史舞台,取而代之的是元空间(Metaspace),以下是一个方法区溢出的示例,但在 JDK8 环境下将很难复现。

package com.chinmoku.jvm;

import java.lang.reflect.Method;

public class Test {
    public static void main(String[] args) {
        while (true) {
            new Thread(() -> {
                try {
                    Class<?> clazz = Class.forName("com.chinmoku.jvm.Test");
                    Method m = clazz.getDeclaredMethod("m");
                    m.invoke(null);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }

    public static void m() {
        // do nothing
    }
}

当然,为了避免使用者在实际应用中出现上述示例中类似的破坏性的操作,HotSpot 虚拟机也提供了一些参数作为元空间的防御措施,主要包括:

  • -XX:MaxMetaspaceSize:设置元空间大小,默认值为 -1,即不限制,或者说只受限于本地内存大小。

  • -XX:MetaspaceSize:元空间初始大小,达到该值就会触发垃圾收集进行类型卸载,同时,收集器也会对该值进行动态地调整。

  • -XX:MinMetaspaceFreeRatio:其作用是在垃圾收集之后控制最小的元空间的剩余容量的百分比。

本机直接内存溢出

直接内存的容量大小可以通过-XX:MaxDirectMemorySize参数来指定,如果不进行指定,则默认与堆的最大值(由 -Xmx 指定)保持一致。

在这个示例中,通过不断分配内存的方式,使内存耗尽,最终就会产生直接内存溢出。

直接内存溢出通常在以下场景下容易发生:

  1. 大量的 IO 操作,例如文件读写、网络传输等,如果没有合理地管理直接内存的分配和释放,就容易出现直接内存溢出的问题。

  2. 大量的数据处理,例如图像处理、视频处理等,需要使用大量的直接内存来存储数据,如果没有合理地管理内存,就容易出现直接内存溢出的问题。

  3. 大量的字符串操作,例如字符串拼接、字符串替换等,如果没有合理地管理内存,就容易出现直接内存溢出的问题。

  4. 高并发场景下,如果大量的线程同时使用直接内存,就容易出现直接内存溢出的问题。

垃圾收集器与内存分配策略

从前文我们知道,对于 Java 运行时数据区中的程序计数器、虚拟机栈、本地方法栈这三个区域,它们都与线程相关联,伴随着线程的生命周期而进行创建、内存布局、访问定位和回收等。而栈中的栈帧也会随着方法的进入和退出执行着入栈和出栈的操作,这些操作所分配的内存空间,基本上在编译期间就是可知的。

但 Java 堆和方法区这两个区域则有着显著的不确定性,因为这部分内存的分配和回收是动态的,垃圾回收最主要的关注点也正是这两部分区域。

对象死亡的判定

Java 虚拟机在进行垃圾回收时,一个关键点就在于如何判定对象是否满足垃圾回收的条件,即如何判定对象死亡。

引用计数算法

引用计数算法(已过时)是一种较早出现的算法。它会在对象中添加一个引用计数器,每当一个对象被引用时,它的引用计数就会增加一;当一个对象不再被引用时,它的引用计数就会减一。当一个对象的引用计数变为零时,就表示该对象就可以被释放,以回收内存空间。

这种算法尽管原理简单,判定效率也很高,但却有一个很明显的弊端,那就是无法对循环引用等场景进行判定:

public class Main {
    public static void main(String[] args) {
        Instance instance1 = new Instance();
        Instance instance2 = new Instance();
        instance1.setInstance(instance2);
        instance2.setInstance(instance1);
        instance1 = null;
        instance2 = null;
        // 此时instance1和instance2对象互相引用,但是它们的引用计数都为1
        // 若此时发生GC,两个对象均无法被释放,导致内存泄漏
        System.gc();
    }
}

class Instance {
    private Instance instance;

    public void setInstance(Instance instance) {
        this.instance = instance;
    }
}

其对象引用链示意如下:

可达性分析算法

当前主流的商用程序语言的内存管理子系统,都是通过可达性分析算法来判定对象是否存活的。这是一种基于根(GC Root)搜索的算法,从一组根对象开始,通过遍历对象图,标注所有可达对象,然后将未被标记的对象视为垃圾对象进行回收。

在 Java 中,固定可作为 GC Roots 的对象包括以下几种:

  1. 虚拟机栈中引用的对象:虚拟机栈中引用的对象是指当前执行的方法所持有的局部变量、参数和返回值等。这些对象在方法执行完毕后会被释放,因此它们可以作为 GC Roots。

  2. 方法区中类静态属性引用的对象:方法区中存储了类的静态属性和常量等信息,这些属性引用的对象可以作为 GC Roots。

  3. 方法区中常量引用的对象:方法区中存储了类的常量池,包括字符串常量和基本类型常量等。这些常量引用的对象可以作为 GC Roots。

  4. 本地方法栈中 JNI(Java Native Interface)引用的对象:本地方法栈中保存了 JNI 方法调用时传递的参数和返回值等信息,这些对象可以作为 GC Roots。

  5. Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象和系统类加载器等。

  6. 若有被同步锁(synchronized)持有的对象。

  7. 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

对象的引用

无论是引用计数算法还是可达性分析算法,都无法脱离对象的引用单独进行。而在 Java 虚拟机中,根据对象引用的紧密程度,按照强度进行排序,又分成了如下几种不同的引用方式:

  1. 强引用:强引用是最常见的引用方式,在程序中直接使用 new 操作符创建对象时,就是强引用。强引用指向的对象不会被垃圾回收器回收,只有当该对象的所有强引用都被释放时,垃圾回收器才会回收该对象。

  2. 软引用:软引用是一种比较弱化的引用方式,它可以让对象存活一段时间,直到内存不足时才被回收。当内存不足时,垃圾回收器会根据当前内存使用情况来决定是否回收软引用指向的对象。

  3. 弱引用:弱引用比软引用更加弱化,它的生命周期更短。弱引用指向的对象在下一次垃圾回收时就会被回收,无论当前内存使用情况如何。

  4. 虚引用:虚引用也被称为幽灵引用或幻影引用,它是最弱化的引用方式。虚引用并不会影响对象的生命周期,它只是用来在对象被回收时收到一个通知。当对象被回收时,虚引用会被加入到引用队列中,通过引用队列可以得知对象已经被回收。

GC 逃逸

在垃圾回收过程中,通常会分为多个阶段进行执行,可达性分析算法即被应用于标记阶段。经过这一阶段后,所有对象将被区分为存活对象和垃圾对象。

但是,在标记阶段只对存活对象进行了标记,而没有标记垃圾对象,无法确定垃圾对象的地址,因此需要再次遍历堆内存,将未被标记的对象标记为垃圾对象,并进行回收,这一阶段被称为清除阶段。

而在标记阶段和清除阶段之间,垃圾对象如果产生了其他 GC Roots 相连接的引用链,那么,这个对象将会在本次垃圾回收的过程中存活下来,这种情况则被称为 GC 逃逸。

以下是一个简单的 GC 逃逸示例:

虚拟机在执行垃圾回收的过程中,对于同一对象,虚拟机将会判断 finalize 方法是否已经被执行,如果已被执行,finalize 方法则不会再次执行,即 finalize 方法只会执行一次。因此,在执行 finalize 方法时,将垃圾对象如果被重新关联,那么该对象则会成功逃离 GC 范围。但由于同一对象的 finalize 方法只会执行一次的特点,导致这种逃逸方式只会在初次执行时有效。

“同一对象的 finalize 方法只会执行一次”的说法其实并不严谨“同一对象的 finalize 方法只会执行一次”这一说法其实并不适应与所有 JDK 版本。准确来说,在 JDK8 及之前的版本中,finalize 方法执行的次数是不确定的,在 JDK9 及之后的版本中,finalize 方法才被明确限制为只会被调用一次。另外,虽然在 JDK8 版本中 finalize 方法执行的次数不确定,但是在实际运行中,finalize 方法也可能只被调用一次。这是因为 finalize 方法的调用次数受到多种因素的影响,包括垃圾回收器的实现、GC 策略、系统负载等等,不同的环境下可能会有不同的表现。

当然,这种重写 finalize 方法进行 GC 逃逸的做法并不值得推荐,因为它的运行代价高昂,不确定性大,且无法保证各个对象的调用顺序。

元空间垃圾回收

方法区中的垃圾收集主要回收两部分内容:废弃的常量和不在使用的类型。对废弃的常量进行回收的方式与 Java 堆中的对象非常类似,其主要判定逻辑是:当一个常量不再被引用时,它就可以被回收。相比于对废弃常量的回收而言,对类回收的判定条件则更为苛刻,它需要同时满足如下三个条件:

  1. 该类所有的实例都已经被回收。

  2. 加载该类的类加载器已经被回收。

  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用。

    永久代垃圾回收与元空间垃圾回收:

    以 JDK8 作为分界线,在早期的 JVM 版本中,方法区被称为永久代,因此,其方法区的垃圾回收也被称为“永久代垃圾回收”,而在 JDK8 及之后的版本中,方法区已被元空间所取代,其方法区的垃圾回收则被称为“元空间垃圾回收”。并且,二者不只是在名称上有所差异,在垃圾回收效率方面也相差很大。

    由于永久代的内存空间是固定的,无法自动扩展,因此在进行垃圾回收时,其采用的“标记-清除”算法需要考虑到内存碎片问题,这会影响到垃圾回收的效率。

    而元空间垃圾回收采用的则是基于“标记-清除”算法的“标记-整理”算法,这种算法在垃圾回收时可以避免内存碎片问题,且元空间使用的是本地内存区域,其垃圾回收与堆中的垃圾回收相互独立,不会产生干扰。综合这些因素,JDK8 及之后版本中的元空间垃圾回收相比于早期的永久代垃圾回收,在效率方面有较大的提升。

垃圾收集算法

从如何判定对象消亡的角度触发,垃圾收集算法可以划分为“引用计数式垃圾收集”和“追踪式垃圾收集”两大类,这两类通常也被称作“直接垃圾收集”和“间接垃圾收集”。

分代收集理论

分代收集理论是指在垃圾回收中,将对象按照其存活时间的长短分为不同的代,对不同代的对象采用不同的回收策略,以提高垃圾回收的效率和性能。

一般来说,新创建的对象被分配在第 0 代,随着时间的推移,如果对象没有被回收,则被移到更高的代。当某一代中的对象数量达到一定阈值时,垃圾回收器会对该代进行回收。由于不同代的对象具有不同的存活时间和生命周期,因此采用不同的回收策略可以更好地适应不同代的特征,提高垃圾回收的效率和性能。

分代收集理论建立在两个分代假说基础上:

  1. 强分代假说

    强分代假说认为不同代的对象具有明显的生命周期差异,即大部分对象的生命周期短暂,只存在于新生代中,只有少部分对象的生命周期长久,存在于老年代中。因此,强分代假说认为,相同代内的对象生命周期相似,而不同代之间的对象生命周期差异显著。

  2. 弱分代假说

    弱分代假说是相对于强分代假说而言的,这种假说认为不同代之间的对象生命周期差异可能不是非常明显,即对象的生命周期可能存在一定的随机性和不确定性,并不完全受到代的划分和对象的存储位置的影响。

    弱分代假说认为,不同代之间的对象生命周期可能存在一定的交叉和重叠,即有些对象可能存在于新生代和老年代中,而且对象的生命周期可能受到多种因素的影响,比如对象的使用频率、内存分配策略等。

    因此,弱分代假说认为,在进行垃圾回收时,应该根据对象的实际情况和特征,采用不同的回收策略,而不是仅仅按照代的划分进行回收。这样可以更好地适应不同对象的生命周期和特征,提高垃圾回收的效率和性能。

这两个分代假说共同奠定了多款常用垃圾收集器的一致的设计原则:收集器应当将 Java 堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中进行存储。

基于分代收集理论,Java 虚拟机设计通常至少会将 Java 堆划分为新生代和老年代两个区域。在不同代区域之间,各自采用不同类型的垃圾回收:

  1. Minor GC:也称为年轻代垃圾回收,主要针对新生代,即年轻代中的对象进行回收。Minor GC 的过程一般较快,因为大部分对象都是短时间存活的,只有少部分对象会存活到老年代。Minor GC 的主要任务是回收年轻代中的垃圾对象,同时将存活的对象复制到另一个 Survivor 区域中。

  2. Major GC:也称为老年代垃圾回收,主要针对老年代中的对象进行回收。Major GC 的过程一般较慢,因为老年代中的对象大部分都是长时间存活的,需要进行复杂的标记清除或标记整理操作。Major GC 的主要任务是回收老年代中的垃圾对象。

  3. Mixed GC:也称为混合垃圾回收,其目标是收集整个新生代以及部分老年代的垃圾,目前只有 G1 收集器才会有此行为。此外,Mixed GC,Minor GC 和 Major GC 也统称为 Partial GC。

  4. Full GC:也称为全堆垃圾回收,是对整个堆内存进行回收。Full GC 会同时回收年轻代和老年代中的垃圾对象,包括无法被回收的对象(比如类的静态变量和常量池中的对象)。Full GC 的过程一般较慢,因为需要对整个堆内存进行扫描和回收。

然而,由于 Java 对象并不是孤立存在的,往往会出现对象跨越代际之间的引用,这无疑增加了内存回收的性能负担,为解决这个问题,又提出了第三条假说:

跨代引用假说:它认为存在相互引用关系的两个对象应该是倾向于同时生存或同时消亡的。

如何解决跨代引用假说的问题:

依据跨代引用假说,只有少量对象才会存在跨代引用的问题。因此没有必要为了少量的跨代引用而去扫描整个老年代,也没有必要浪费空间去专门记录每一个对象是否存在及存在哪些跨代引用,只需要在新生代上建立一个全局的数据结构(记忆集,Remembered),这个数据结构将老年代划分为若干个小块,每次都只记录老年代中的哪一块内存存在跨代引用。此后每当发生 Minor GC 时,只有包含了跨代引用的那一小块内存才会被加入到 GC Roots 中进行扫描。

标记 - 清除算法

标记 - 清除算法(Mark-Sweep)是最早出现也是最基础的垃圾收集算法,该算法的基本思想是通过标记所有不再使用的对象,然后将他们从内存中清除,其过程主要分为两个阶段:

  1. 标记阶段:从 GC Roots 开始,遍历所有可达对象,将其标记为“活动”状态。

  2. 清除阶段:遍历所有内存空间,将未被标记的对象清除并回收其内存空间。

标记 - 清除算法的主要缺点有两个:其一是执行效率不稳定,它需要完全遍历整个堆内存才能找到回收对象,可能导致程序出现阻塞;其二则是无法处理内存空间的碎片化问题,标记、清除之后往往产生大量不连续的内存碎片,这可能导致后续需要分配较大对象时无法获取到足够的连续内存而提前触发垃圾收集动作。

标记 - 复制算法

标记 - 复制算法(Copying)简称复制算法,它将堆空间分为两个区域,一个是存活对象区域,另一个是空闲区域。在垃圾回收过程中,首先对存活对象进行标记,将其复制到空闲区域中,并将原有的存活对象区域清空,然后交换两个区域的角色,最终完成垃圾回收。

这种算法的主要优点在于它可以避免内存碎片的问题,因为空闲区域总是整齐的,不会出现分散的碎片。同时,由于只需要对存活对象进行标记,因此它也可以减少垃圾回收的时间和空间复杂度。

而其主要的缺点则是在存活对象较多的情况下,复制操作会产生大量的额外开销,同时,复制操作本身也是一种效率较低的操作。

但是,对于新生代这种对象存活率很低的内存空间来说,复制算法的缺点则微乎其微。因此,现在的商用 Java 虚拟机大多都优先采用这种收集算法来对新生代的垃圾进行收集处理。

半区复制分代策略

半区复制分代策略是一种在复制算法基础上进行了优化处理的垃圾回收算法。它将新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间(8:1:1),每次分配内存时只使用 Eden 和其中一块 Survivor 空间(From 半区),发生垃圾收集时,将存活的对象一次性复制到另一块 Surviror 空间(To 半区),并清空之前两块空间。

标记 - 整理算法

标记 - 复制算法(Mark-Compact)适用于存活率较高的内存空间,而对于老年代这种存活率较高的场景则难以适用。因此,针对老年代对象的存亡特性,又出现了一种标记 - 整理算法。

标记 - 整理算法在标记 - 清除算法的基础上,增加了一个整理阶段,即将经历过清除阶段之后仍然存活的对象进行位置移动,使存活的对象在内存空间上连续分布,从而解决了标记 - 清除算法中存在的内存碎片问题。

但值得注意的是,标记 - 整理算法在整理阶段需要对对大量存活对象进行位置移动,这意味着内存地址会产生变更,因此在整理操作的过程中,就需要暂停所有用户应用程序以保证内存数据的可靠性。这种在垃圾回收过程中暂停用户进行的操作,被称为 Stop The Word(STW)。

总体来说,标记 - 清除和标记 - 整理算法各有优缺点。不同的垃圾收集器也根据其各自的关注点采用了不同的算法,例如关注整体垃圾收集效率的 G1 垃圾收集器对于老年代的垃圾收集处理采用的是标记 - 整理算法,而主要关注点侧重于减少垃圾收集停顿时间的 CMS 垃圾收集器则使用的是标记 - 清除算法。

HotSpot 的算法细节实现

根节点枚举

这里的根节点实际上就是指在前文中提到的 GC Roots,所有垃圾收集器在根节点枚举这个环节都必须暂停用户线程。

枚举根节点对象是 JVM 垃圾回收的重要步骤,它可以确保未被使用的对象被及时回收,从而释放内存资源。在 HotSpot JVM 中,根节点枚举是由垃圾回收器自动完成的,程序员无需手动干预。

安全点

用户程序在执行时并非在代码指令流的任意位置都能停顿下来开始垃圾收集,而是要求程序必须执行到某个节点后才能够暂停,这样的节点被称为安全点(Safepoint)。

安全点中通过 OopMap 这种数据结构记录了 Java 对象在堆内存中的位置,JVM 在执行的过程中,会定期检查当前线程是否处于安全点位置,在安全点检查时,JVM 会记录当前线程的状态信息,包括当前线程的栈帧和堆对象,这些信息用于保证程序能够正确地恢复执行。

安全点的选择十分重要,既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。通常是以“是否具有让程序长时间执行的特征”为标准进行选择。

安全点的作用是确保垃圾回收器能够准确地识别哪些对象可以被回收,从而避免出现内存泄漏和内存溢出等问题。不过,安全点的引入也会带来一定的性能开销,因为它需要暂停所有线程,等待垃圾回收器完成操作。因此,Java 虚拟机通常会尽量减少安全点的数量,以提高程序的性能。

触发安全点检查的情况通常包括:

  1. 执行方法调用或返回时;

  2. 进行循环或递归操作时;

  3. 进行垃圾回收操作时;

  4. 发生异常时;

  5. 等待网络或 I/O 操作时。

要保证在垃圾收集时所有线程都处于就近的安全点位置,有如下两种方案:

  1. 抢占式中断(Preemptive Suspension)

    不需要线程的执行代码主动配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有的用户线程中断的地方不在安全点上,就恢复该线程的执行,直到该线程处于安全点上为止。

  2. 主动式中断(Voluntary Suspension)

    当垃圾收集发生时,JVM 不直接对线程进行操作,而是设置一个标记,各个线程在执行过程中会不停地主动轮询这个标记,如果标记为真则主动在最近的安全点上中断挂起。

安全区域

安全点的设计解决了线程在运行时如何进行中断的问题,但对于一些处于 Sleep 状态或 Blocked 状态的线程则无法进行中断,因此在安全点的基础上引入了安全区域来解决。

安全区域(Safe Region)是指能够确保在某一段代码片段之中,引用关系不会发生变化,因而在这个区域中任意地方开始垃圾收集都是安全的,安全区域可以被看做是包含了一系列安全点的区域。

安全区域如何对 Sleep 或 Blocked 的线程进行中断:

当线程处于 Sleep 或 Blocked 状态时,JVM 会先检查线程所持有的对象是否处于安全区域内。如果对象不在安全区域内,JVM 会将对象移动到安全区域内,然后再进行垃圾回收。

如果对象已经在安全区域内,JVM 会先检查线程是否拥有对象的锁。如果线程没有对象的锁,JVM 会等待线程获取锁后再进行垃圾回收。如果线程已经获取了对象的锁,JVM 会将线程唤醒,使其执行垃圾回收。

记忆集与卡表

记忆集(Remembered Set)是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。当 JVM 进行分代垃圾收集时,若收集对象存在跨代引用,就会使用记忆集这种数据结构来存储对象的跨代引用关系。

卡表(Card Table)是记忆集最常用的一种实现方式,它主要定义了记忆集的记录精度、与堆内存的映射关系等,卡表中的每一个元素都对应着一块特定大小的内存块,这个内存块称之为卡页(card page),当存在跨代引用时,会将卡页标记为 dirty,当垃圾收集发生时只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,并将其加入到 GC Roots 中一并扫描。

在 HotSpot 虚拟机中是通过写屏障技术维护卡表状态的。

写屏障

写屏障(Write Barrier)是一种垃圾回收算法中的一种技术,用于跟踪对象之间的引用关系,以便在对象被移动或删除时进行适当的处理。在多线程环境下,写屏障可以保证对象引用关系的正确性,避免出现数据竞争和不一致性的问题。具体来说,当一个线程修改一个对象的引用时,写屏障会在这个修改操作完成之前,将对象的引用信息添加到一个特定的数据结构中,以便垃圾回收器在后续的处理中能够正确地跟踪这个对象的引用关系。

三色标记

三色标记是垃圾回收算法中的一种标记方式,常用于基于追踪的垃圾回收算法中。在三色标记法中,所有对象都被标记为三种不同的颜色:

  1. 白色:表示对象未被访问过。

  2. 灰色:表示对象已经被访问过,但它的引用还未被扫描。

  3. 黑色:表示对象已经被访问过,并且它的引用已经被扫描。

垃圾回收器从根对象开始遍历所有对象,并将它们标记为灰色。然后,垃圾回收器递归地遍历灰色对象的所有引用,并将它们标记为灰色。当所有引用都被扫描完毕时,将对象标记为黑色。最后,所有未被标记为黑色的对象都可以被认为是垃圾,可以被回收。

按照三色标记的方式,在并发场景下进行可达性分析时,垃圾收集线程与用户线程会相互干扰,从而影响垃圾收集的准确性,甚至将对象错误地标记为垃圾对象,导致程序异常。

如图所示,如果垃圾收集线程在标记过程中,对象的引用链发生变化,就可能会引起“对象消失”的现象,引发该问题需要同时满足两个条件:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用。

  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,要解决“对象消失”问题,只需要对任意一个条件进行破坏即可,由此产生了两种解决方案:

  1. 增量更新(Incremental Update)

    当黑色对象插入新的指向白色对象的引用关系时,将该引用关系记录下来,在到并发扫描结束后,再将这些记录中的黑色对象作为根再次进行扫描。

  2. 原始快照(Snapshot At The Begining,STAB)

    当灰色对象要删除指向白色对象的引用关系时,将这个要删除的引用记录下来,在并发扫描结束后,再讲这些记录中的灰色对象作为根再次进行扫描。

    在 HotSpot 虚拟机中,这两种方案都有实际应用,比如:CMS 是基于增量更新来处理并发标记,而 G1、Shenandoah 则是使用原始快照来实现的。

垃圾收集器

随着 Java 语言的发展,JVM 垃圾收集器也经历了一个漫长的过程,并且还在持续探索。在这个发展的过程中,产生了多种多样的垃圾收集器。

一些注意点:

  1. “垃圾收集器”其实更应当称之为自动内存管理子系统,因为一个垃圾收集器除了垃圾收集的工作之外,还需要负责堆的管理与布局、对象的分配,与解释器的写作、与编译器的写作、与监控子系统的写作等职责。

  2. 衡量垃圾收集器的三项重要的指标是:内存占用、吞吐量和延迟。三者共同构成了一个“不可能三角”,一款优秀的垃圾收集器通常最多只能同时达成其中两项。在三项指标中,延迟和吞吐量备受关注,而内存占用的影响则相对较低。

  3. 垃圾收集器种类繁多,通常可以依据三项指标和并发能力进行选择,但最终仍旧需要结合业务和对各种垃圾收集器的了解进行更加细致的评估。

Serial / Serial Old

Serial 收集器是 HotSpot 虚拟机中最基本、最古老的新生代垃圾收集器,它基于标记 - 复制算法,并使用单线程进行垃圾收集。Serial 收集器的特点是简单、高效、低延迟,适用于小型应用程序。

Serial 收集器由于其单线程的特性,使其在垃圾收集过程中会 Stop The World,如下图所示:

图源网络

尽管 Serial 收集器在垃圾收集的过程中会 STW,但由于其简单高效的特点,它仍未完全退出历史舞台。虽然 Serial 收集器不适用于高负载环境和大型应用程序,但是在一些小型应用程序或低配置的计算机上,Serial 收集器仍然可以发挥出较好的性能表现。

Serial Old 和 Serial 是配套使用的垃圾收集器,分别应用于老年代和新生代,除此之外几乎没有其他区别。

Parallel Scavenge / Parallel Old

Parallel Scavenge 收集器同样是一款基于标记 - 复制算法的、多线程并行的新生代垃圾收集器,它的目标是达到一个可控制的吞吐量,也就是尽可能地多执行应用程序的业务逻辑而不是垃圾收集。由于 Parallel Scavenge 收集器与吞吐量关系密切,因此也被称为“吞吐量优先收集器”。

图源网络

Parallel Old 和 Parallel Scavenge 是配套使用的垃圾收集器,分别应用于老年代和新生代,两者在原理和实现上几乎相同。

ParNew / CMS

ParNew 是 Parallel Scavenge 收集器的一个变体版本,但与 Parallel Scavenge 不同的是,ParNew 不再追求吞吐量的可控性,而是追求低停顿时间,并且,ParNew 通常也与表现更加优秀的 CMS 收集器配套使用。

图源网络

ParNew 收集器是激活 CMS 后的默认新生代收集器。

但是随着更先进的 G1 收集器的出现,ParNew 收集器几乎退出历史舞台,或者说 ParNew 收集器几乎被合并到了 CMS 收集器中。

CMS(Concurrent Mark Sweep)收集器是一种采用并发方式的、以获取最短回收停顿时间为目标的垃圾收集器,它是针对低延迟的应用场景而设计的。

CMS 收集器是基于标记 - 清除算法实现的,其运作过程也相对较为复杂,整个过程包括以下几个步骤:

  1. 初始标记(Initial Mark)

    CMS 收集器首先会暂停应用程序线程,标记所有根对象,并且标记所有直接与根对象相连的对象。这个阶段需要暂停应用程序线程,因为在这个阶段中,垃圾收集器需要保证对象的状态不会发生变化。

  2. 并发标记(Concurrent Mark)

    在初始标记之后,CMS 收集器会启动应用程序线程,并且与应用程序线程并发地标记对象。这个阶段中,垃圾收集器会标记所有与根对象间接相连的对象。在这个阶段中,应用程序线程和垃圾收集器线程是并发执行的,不需要暂停应用程序线程。

    这一阶段是最为耗时的阶段,并且使用到了前文提到的三色标记算法。

  3. 重新标记(Remark)

    在并发标记阶段结束之后,CMS 收集器会再次暂停应用程序线程,重新标记所有在并发标记期间发生变化的对象,并且标记所有与这些对象相连的对象。重新标记的目的是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。

  4. 并发清除(Concurrent Sweep)

    在重新标记阶段结束之后,CMS 收集器会启动应用程序线程,并且与应用程序线程并发地回收无用的对象。在这个阶段中,垃圾收集器会回收所有被标记为无用的对象,并且释放它们所占用的内存。由于不需要移动存活对象,所以这个阶段也是可以与用户线程并发执行的。

  5. 并发重置(Concurrent Reset)

在并发清除阶段结束之后,CMS 收集器会重置所有标记位,为下一次垃圾回收做好准备。

可以说 CMS 是一款相当优秀的收集器(在一些文档中,CMS 也被称为“并发低停顿收集器”),但它仍然存在几个明显的缺点:

  1. 会占用部分线程(或处理器计算能力),导致用户线程变慢,影响吞吐量。

  2. 无法处理浮动垃圾(Floating Garbage),可能会并发处理失败而导致一次完全 STW 的 Full GC 产生。

  3. 标记 - 清除算法会产生内存碎片,剩余空间不足时会引发 Full GC。

Garbage First

Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果。

G1 的设计目标是在不牺牲吞吐量的情况下,尽可能地减少停顿时间。它采用了分代收集的思想,但与传统的分代收集器不同,G1 将整个Java堆分成多个小块(Region),并使用卡表(Card Table)来维护各个 Region 之间的引用关系。

与包括 CMS 在内的其他收集器不同,G1 收集器不再局限于基于新生代、老年代或整个堆内存进行垃圾收集,而是面向堆内存中的任何部分来组成回收集(Collection Set,CSet)进行回收,其衡量标准不再是哪个分代,而是哪块内存中存放的垃圾数量最多,回收效益最大,这就是 G1 收集器的 Mixed GC 模式。

G1 收集器的堆内存布局与其他收集器有着非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每个区域都可以根据需要扮演新生代的 Eden 空间、Surviror 空间或者老年代空间。收集器对扮演不同角色的空间采用不同的策略进行处理,因此能够达到最佳的收集效果。

G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的,他们都是一系列 Region 的集合,且并不要求这些 Region 必须是连续的。而在垃圾回收过程中,这些 Region 将作为单次回收的最小单元,JVM 将根这些 Region 垃圾堆积的价值大小维护一个优先级列表,并按照用户设定允许的手机停顿时间优先处理回收价值收益较大的 Region。

G1 收集器的运作过程大致可以分为四个阶段:

  1. 初始标记(Initial Mark)

    G1 收集器首先会扫描整个 Java 堆,标记出所有的根对象和直接与根对象关联的对象。这个过程是在应用程序暂停的情况下进行的。

  2. 并发标记(Concurrent Mark)

    G1 收集器会在应用程序运行的同时,扫描整个 Java 堆,标记出所有存活的对象。这个过程是在应用程序运行的情况下进行的。

  3. 最终标记(Final Mark)

    在并发标记完成后,G1 收集器会再次暂停应用程序,进行最终标记。这个过程标记出了在并发标记期间可能产生的新的垃圾对象。

  4. 筛选回收(Live Data Counting and Evacuation)

    在最终标记完成后,G1 收集器会根据可回收的垃圾量和最大的停顿时间,决定哪些 Region 需要回收。然后,G1 会将这些 Region 中的存活对象复制到其他空闲的 Region 中,并且清空这些 Region 中的垃圾对象。

    G1 允许用户指定期望的停顿时间,这使得垃圾回收可以根据不同的场景进行调节。从整体来看,G1 收集器采用的是标记 - 整理算法,而从 Region 之间的关系来看,又是基于标记 - 复制算法实现。总体来说,G1 收集器相比 CMS 等收集器而言更为复杂,这代表着 G1 收集器会存在很多额外开销,例如每个 Region 都需要维护一份卡表,从而导致记忆集整体占用过大等问题。

    但总体而言,G1 收集器还是相当优秀的,甚至在 Java9 及之后的版本中,都被设置为 HotSpot 虚拟机堆内存中默认的垃圾收集器。

Shenandoah

与其他垃圾收集器不同,Shenandoah 是由于 RedHat 公司开发的,而非由 Oracle 公司开发,并且在 OracleJDK 中也被排除在外,这种垃圾收集器只存在于 OpenJDK 中。

Shenandoah 收集器和 G1 类似,也是基于 Region 的堆内存布局进行垃圾回收,同样有这放大对象的区域,默认回收策略,也是优先回收有价值的 Region。但是他在内存回收上面和 G1 有很多不同。

  1. 支持并发的整理回收算法。

  2. 默认不使用分代集

  3. 不使用记忆集,改为连接矩阵。

Shenandoah 收集器的工作过程大致分为九个过程:

  1. 初始标记:与 G1 一样,都是简单标记和 GCroot 直接关联的对象。

  2. 并发标记:与 G1 一样,遍历对象图,标记全部可达对象,是和用户线程一起并发的。

  3. 最终标记:与 G1 一样,处理剩余的 STAB,并统计出回收价值最高的 Region 组成回收集。

  4. 并发清理:清理一个存活对象都没有的 Region。

  5. 并发回收:Shenandoah 收集器通过读屏障和转发指针解决并发回收问题,这是与其他收集器的核心差异。

  6. 初始引用更新:建立线程集合点,确保所有线程都已完成分配给对象的移动任务。

  7. 并发引用更新:回收阶段结束后,把对象的指针从旧对象改为新对象。

  8. 最终引用更新:修正 GcRoot 的引用。

  9. 并发清理:经历并发回收和引用更新后,Region 中在无存活对象,回收对应 Region 的内存空间。

转发指针(Brooks Pointer)是 Shenandoah 收集器支持并行整理的核心概念,它与句柄有些类似,都是一种简介的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象的头结点中。

转发指针必然会出现多线程竞争问题,Shenandoah 则是采用 CAS 操作来保证并发时对象的访问正确性的。

ZGC

ZGC(Z Garbage Collector)由 Oracle 公司开发的一种基于可伸缩的低延迟垃圾收集器,它和 Shenandoah 的目标高度相似,都希望在尽可能对吞吐量影响不太大的情况下,实现在任意堆内存大小下都可以把垃圾手机的停顿时间控制在十毫秒内的低延迟。

以下是 ZGC 垃圾收集器的主要特点:

  1. 低延迟:在处理大量内存时,ZGC 能够将收集停顿时间限制在 10ms 以内,并保证堆大小不会影响收集停顿时间。

  2. 可伸缩:ZGC 可以在多核处理器和内存大小方面进行优化,从而适应各种不同的工作负载。

  3. 不需要太多的堆空间预留:ZGC 可以在非常少的空间预留情况下运行,减少了垃圾回收对内存的占用。

  4. 高度并发:ZGC 对所有类别的垃圾回收(包括 Full GC)都进行高度并发处理。

想要了解更多 ZGC 相关知识,可以参考下面两篇文章:

Epsilon

Epsilon 收集器是一种完全不做垃圾收集的“垃圾收集器”,当堆内存中的对象达到了指定的阈值时,该收集器将直接终止程序并报告 OutOfMemoryError 错误,它只适用于那些在内存使用上有明确要求的场景,例如:

  1. 大部分数据已经被持久化到磁盘或者外部数据库中。

  2. 程序的内存占用几乎不会增长。

  3. 应用程序本身对 GC 的请求不高,在运行过程中不需要频繁进行 GC 操作。

Epsilon 收集器的主要作用是避免在 Java 应用程序的垃圾回收过程中产生任何额外的开销或延迟。它通过将整个 Java 堆空间视为一个整体,避免了对堆内存中对象进行实际的垃圾回收操作,从而使得应用程序的性能可以得到进一步提升。虽然 Epsilon 收集器并不适用于所有类型的 Java 应用程序,但在某些高度特化的场景下,它可以发挥很大的作用。

Epsilon 收集器是一种实验性的垃圾回收器,它不会对 Java 堆内存中的对象进行任何实际的垃圾回收操作,而是直接将整个堆空间都作为一个整体来处理。这种收集器适用于那些具有高度特化的场景,其中几乎不会产生任何垃圾对象。因为 Epsilon 收集器不执行实际的垃圾回收操作,所以它可以避免在运行时引入任何与垃圾回收相关的延迟或开销,从而提高应用程序的性能。

参考

版权声明

本文链接:https://www.chinmoku.cc/java/advanced/jvm-tutorial-1/

本博客中的所有内容,包括但不限于文字、图片、音频、视频、图表和其他可视化材料,均受版权法保护。未经本博客所有者书面授权许可,禁止在任何媒体、网站、社交平台或其他渠道上复制、传播、修改、发布、展示或以任何其他方式使用此博客中的任何内容。

Press ESC to close