Java内存相关设计浅析

目录

一、背景

二、Java对象

2.1 对象结构

2.2 内存创建流程

三、 JMM出现原因

四、JMM内存模型定义

五、JVM 运行时数据区

5.1 程序计数器 (Program Counter Register)

5.2 Java 虚拟机栈 (Java Virtual Machine Stacks)

5.3 本地方法栈 (Native Method Stack)

5.4 Java 堆 (Java Heap)

5.5 方法区 (Method Area)

5.6 运行时常量池 (Runtime Constant Pool)

5.7 直接内存 (Direct Memory)

六、JVM 运行时数据区和JMM内存模型的区别

七、GC

7.1 GC Roots

7.2 Serial Garbage Collector

7.3 Parallel Garbage Collector

7.4 CMS

7.5 G1


一、背景

理解Java内存模型是理解Java的基础,本文分析了Java对象结构、JMM定义以及意义、JVM运行时数据区、GC等Java内存相关知识,文章内容大量来源于deepseek,已校验无误,主要用于本人复习之用

二、Java对象

2.1 对象结构

平时我们new 一个对象,变量值是一个指针(类似内存地址),存储在栈帧或堆中,实际的对象是堆内存中的结构化的数据块,包含如下内容:

  • 对象头(Header)

  • 实例数据(Instance Data)

  • 对齐填充(Padding)

1. 对象头(Header)

对象头包含三个核心部分:

部分大小内容
Mark Word32位系统:4B
64位系统:8B
存储运行时数据:
- 哈希码
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID等
Klass Pointer32位系统:4B
64位系统:8B
(通常开启压缩为4B)
指向方法区中的类元数据
(即该对象的 Class 对象)
数组长度4B仅当对象是数组时存在

2. 实例数据(Instance Data)

  • 包含对象的所有实例字段

  • 字段排列顺序受虚拟机参数和字段类型影响

  • 基本类型直接存储值

  • 引用类型存储指针(指向实际对象)

3. 对齐填充(Padding)

  • 确保对象总大小是8字节的倍数

  • 提高内存访问效率(内存对齐)

1. 对象引用 vs 实际对象

  • 对象引用

  • 实际对象

2.2 内存创建流程

当执行 new MyClass() 时,JVM 执行以下操作:

1. 类加载检查

  • 检查类是否已加载

  • 如未加载,执行类加载过程(加载→验证→准备→解析→初始化)

2. 内存分配

  • 在堆中分配内存空间

  • 分配方式:

    • 指针碰撞(堆内存规整时)

    • 空闲列表(堆内存不规整时)

3. 内存空间初始化

  • 将所有实例字段初始化为默认值:

    • 数值类型:0

    • 布尔类型:false

    • 引用类型:null

4. 设置对象头

  • 写入关键的元数据信息(见下文详解)

5. 执行构造函数

  • 调用 <init> 方法

  • 执行显式初始化(字段赋值)

  • 执行构造器代码

三、 JMM出现原因

  1. 硬件内存架构的复杂性:

    • CPU 缓存: 现代处理器为了加速访问,每个核心通常都有自己的高速缓存(L1, L2, L3)。线程对变量的操作首先发生在自己的工作内存(通常映射到 CPU 缓存和寄存器),而不是直接操作主内存。

    • 缓存一致性协议: 虽然硬件(如 MESI 协议)努力保持各个 CPU 缓存之间以及缓存与主内存之间的一致性,但这需要时间,并且协议本身的行为在不同硬件上有差异。

    • 指令重排序: 为了提高性能,编译器和处理器会对指令进行重新排序(Compiler Reordering & CPU Instruction Reordering)。这种重排序在单线程环境下遵循 as-if-serial 语义(结果看起来和顺序执行一样),但在多线程环境下,其他线程可能观察到与代码编写顺序不一致的内存访问顺序。

  2. Java 的“一次编写,到处运行”目标:

    • Java 需要屏蔽底层硬件和操作系统在内存模型上的差异。

    • 需要为 Java 开发者和编译器、JVM 实现者提供一套明确的、平台无关的规范,规定多线程环境下:

      • 一个线程对共享变量的修改何时以及如何对其他线程可见

      • 哪些情况下指令的执行顺序可以被重排序,哪些情况下不能。

  3. 早期规范的不清晰:

    • Java 语言规范最初对内存可见性和指令重排序的约束不够精确和全面,导致不同 JVM 实现的行为不一致,开发者编写正确的并发程序非常困难且容易出错。

总结出现原因: 为了解决硬件内存架构差异(缓存、重排序)带来的内存可见性问题和指令顺序不确定性问题,并为 Java 提供一套平台无关的、强制的内存访问和交互规则,确保并发程序在所有符合规范的 JVM 实现上行为可预测且一致,Java 内存模型 (JMM) 应运而生。

四、JMM内存模型定义

JMM相当于是Java屏蔽了操作系统等的底层逻辑,给你抽象了一套内存模型,让你开发的时候按照该内存模型去开发,实际某些部分在真实的物理内存中不一定存在。一般来说JMM分为两块:

主内存: 所有共享变量都存储在主内存中。这是逻辑概念,底层一般是由C++ malloc分配的内存

工作内存: 每个线程都有自己的工作内存。它保存了该线程使用到的变量的主内存副本。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量。

实际工作内存一般位于CPU的多级缓存中,它主要是为了解释计算机中的一种逻辑,即:CPU是有多级缓存的,当修改数据的时候,CPU会先去缓存中去拿,能拿到即修改缓存中的数据,而不是直接修改物理内存中的数据,缓存中数据什么时候回写到真实内存看CPU的缓存一致性策略。

这个过程会导致可见性问题,所以JMM定义了一个工作内存,解决这个问题。

为了更精确的抽象主内存和工作内存间数据的交互过程,JMM还定义了8种原子操作:

lock: 作用于主内存变量,将其标识为被某个线程独占。

unlock: 作用于主内存变量,释放被锁定的变量。

read: 作用于主内存变量,将变量的值从主内存传输到线程的工作内存。

load: 作用于工作内存变量,把 read 操作得到的值放入工作内存的变量副本中。

use: 作用于工作内存变量,把工作内存中一个变量的值传递给执行引擎(每当虚拟机遇到一个需要使用变量值的字节码指令时会执行)。

assign: 作用于工作内存变量,把从执行引擎接收到的值赋给工作内存的变量(每当虚拟机遇到一个给变量赋值的字节码指令时执行)。

store: 作用于工作内存变量,把工作内存中一个变量的值传送到主内存中。

write: 作用于主内存变量,把 store 操作从工作内存得到的变量的值放入主内存的变量中。

规则: JMM 规定了这 8 种操作必须满足的规则(如 read 和 loadstore 和 write 必须成对按顺序出现,不允许 assign 操作没有发生就把工作内存同步回主内存等)。这些规则是实现 volatilesynchronizedfinal 等语义的基础。

什么是原子操作?要么成功,要么不成功,中间不会插入其它操作

比如对于i++,这不是一个原子操作,中间可能会插入其它操作

上面定义的都是原子操作,比如lock,中间不会插入其它内容,要不lock成功,要么失败

还有happens-before原则,这个博主平时基本没用过,不介绍了,感兴趣的自行查阅资料吧

五、JVM 运行时数据区

5.1 程序计数器 (Program Counter Register)

  • 功能: 每个线程私有的、非常小的内存区域。它可以看作是当前线程所执行的字节码指令的行号指示器。执行 Java 方法时,它记录正在执行的虚拟机字节码指令的地址;执行 Native 方法时,其值为空 (undefined)。

  • 生命周期: 与线程的生命周期相同。线程创建时创建,线程结束时销毁。

  • 异常: 此区域是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域。

  • 物理存储位置: CPU 寄存器 (Registers) 或 操作系统分配的线程栈内存。为了线程切换后能恢复到正确的执行位置,它需要非常快速的访问,因此最理想的情况是直接使用 CPU 寄存器。如果寄存器数量不足,JVM 实现可能会使用一小块位于线程栈顶部的内存来模拟。主要目标是速度

5.2 Java 虚拟机栈 (Java Virtual Machine Stacks)

  • 功能: 每个线程私有的内存区域。描述的是 Java 方法执行的内存模型:每个方法被执行时,JVM 都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法的调用对应着栈帧在虚拟机栈中的入栈,方法的返回对应着栈帧的出栈。

    • 局部变量表: 存放编译期可知的各种基本数据类型 (booleanbytecharshortintfloatlongdouble)、对象引用 (reference) 和 returnAddress 类型。

    • 操作数栈: 方法执行过程中进行算术运算或调用其它方法传递参数的工作区。

    • 动态链接: 指向运行时常量池中该栈帧所属方法的引用,用于支持方法调用过程中的动态连接。

    • 方法出口: 方法正常退出或异常退出的定义。

  • 生命周期: 与线程的生命周期相同。

  • 异常:

    • StackOverflowError: 线程请求的栈深度超过 JVM 所允许的深度(通常由无限递归或过深的方法调用引起)。

    • OutOfMemoryError: 如果 JVM 栈可以动态扩展(大部分 JVM 允许),但在扩展时无法申请到足够的内存。

  • 物理存储位置: 操作系统分配的虚拟地址空间中的线程栈内存。操作系统为每个线程预留一块连续的虚拟地址空间作为栈空间。这块内存最终映射到物理内存条 (RAM) 上。访问速度非常快(仅次于寄存器),因为栈顶区域通常被 CPU 缓存。

5.3 本地方法栈 (Native Method Stack)

  • 功能: 与 Java 虚拟机栈作用类似,但它是为 JVM 调用 Native 方法(用 C/C++ 等非 Java 语言编写的方法)服务的。JVM 规范允许本地方法栈的具体实现由虚拟机自由实现。

  • 生命周期: 与线程的生命周期相同。

  • 异常: 同样会抛出 StackOverflowError 和 OutOfMemoryError

  • 物理存储位置: 操作系统分配的虚拟地址空间中的线程栈内存。与 Java 虚拟机栈类似,操作系统为执行本地方法的线程分配栈空间,映射到物理 RAM。在 HotSpot 虚拟机中,本地方法栈和 Java 虚拟机栈是合二为一的,使用同一块内存区域。

5.4 Java 堆 (Java Heap)

  • 功能: 所有线程共享的最大的一块内存区域。在 JVM 启动时创建。其唯一目的就是存放对象实例以及数组(数组本质上也是对象)。几乎所有的对象实例以及数组都在这里分配内存(随着 JIT 编译技术和逃逸分析技术的成熟,栈上分配、标量替换优化使得“所有对象都在堆上分配”变得不那么绝对,但仍是主体)。堆是垃圾收集器 (Garbage Collector, GC) 管理的主要区域,因此常被称为“GC 堆”。

  • 生命周期: 从 JVM 启动到关闭。

  • 异常: OutOfMemoryError: 如果在堆中没有足够的内存完成实例分配,并且堆也无法再扩展时抛出。

  • 物理存储位置: 操作系统分配的虚拟地址空间中的堆内存。操作系统为 JVM 进程分配一大块连续的虚拟地址空间用于堆。这块空间由 JVM 的内存管理器(包括垃圾收集器)管理,最终映射到物理内存条 (RAM) 上。访问速度比栈慢,因为对象分配位置不确定且 GC 活动频繁,但仍然是内存访问。

5.5 方法区 (Method Area)

  • 功能: 所有线程共享的内存区域。它存储已被 JVM 加载的类型信息(类名、访问修饰符、常量、字段描述、方法描述等)、常量池 (Runtime Constant Pool)静态变量 (Static Variables)即时编译器编译后的代码缓存等数据。JVM 规范将其描述为堆的一个逻辑部分,但它有一个别名叫“非堆” (Non-Heap) 以与 Java 堆区分。

  • 生命周期: 从 JVM 启动到关闭。

  • 异常: OutOfMemoryError: 当方法区无法满足内存分配需求时抛出。

  • 物理存储位置 (演进历史重要!):

    • JDK 7 及之前 (HotSpot): 由 “永久代” (PermGen) 实现。永久代是 Java 堆的一部分(逻辑上是方法区,物理上在堆内)。静态变量和字符串常量池 (StringTable) 也在永久代。

    • JDK 8 及之后 (HotSpot): 永久代被完全移除。方法区由 “元空间” (Metaspace) 实现。元空间使用本地内存 (Native Memory),即操作系统分配给 JVM 进程的、位于 Java 堆之外的虚拟地址空间。静态变量被移到了 Java 堆中。字符串常量池 (StringTable) 也被移到了 Java 堆中。

  • 总结 (HotSpot): JDK 8+ 中,方法区(元空间)的物理存储是本地内存 (Native Memory),不再是 Java 堆的一部分。 访问速度通常比堆快,因为元数据相对固定。

5.6 运行时常量池 (Runtime Constant Pool)

  • 功能: 是方法区的一部分。它存储的是 Class 文件中 Constant_Pool 表的运行时表示形式。包含:

    • 编译期生成的各种字面量 (Literal: 如文本字符串、final 常量值)。

    • 编译期生成的符号引用 (Symbolic References: 如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。

    • 在运行时解析后得到的直接引用 (内存地址)。

    • 在运行时由 String.intern() 方法添加的字符串引用(指向 StringTable)。

  • 生命周期: 与类加载的生命周期相关。类被加载时,其常量池信息被加载到运行时常量池;类被卸载时,其对应的常量池信息被回收(如果该类加载器也被回收)。

  • 异常: OutOfMemoryError: 当常量池无法再申请到内存时抛出(属于方法区 OutOfMemoryError 的一部分)。

  • 物理存储位置 (HotSpot 演进):

    • JDK 7 及之前: 作为方法区的一部分,位于 永久代 (PermGen,堆内)

    • JDK 7 (部分): 字符串常量 (StringTable) 被移出运行时常量池,移到了 Java 堆中。

    • JDK 8 及之后: 运行时常量池(不含字符串常量)作为方法区的一部分,位于 元空间 (Metaspace,本地内存)字符串常量池 (StringTable) 明确在 Java 堆中。

5.7 直接内存 (Direct Memory)

  • 功能: 这不是 JVM 规范定义的标准运行时数据区! 但它是现代 Java 应用(特别是 NIO)中非常重要的内存区域。它允许 Java 代码通过 ByteBuffer.allocateDirect() 等方法直接在操作系统的用户空间分配内存。这块内存不受 Java 堆大小限制 (-Xmx),但受本机总内存和操作系统限制。

  • 生命周期: 由 ByteBuffer 对象管理。ByteBuffer 对象本身在堆上分配,但它包含一个指向直接内存地址的指针。当 ByteBuffer 对象被 GC 回收时,其关联的 Cleaner 对象(通过 PhantomReference)会触发释放直接内存(但依赖 finalize 或 sun.misc.Cleaner 机制,不绝对可靠)。

  • 异常: OutOfMemoryError: 当系统总内存(物理内存 + 交换空间)不足,或者进程配置的用户空间内存限制 (ulimit) 被达到时,分配直接内存可能失败。

  • 物理存储位置: 操作系统分配的虚拟地址空间中的用户空间内存。由 malloc 或 mmap 等系统调用分配,映射到物理内存条 (RAM) 上。访问速度非常快(避免了堆内存与本地内存之间的复制),常用于高性能 I/O (NIO)。

六、JVM 运行时数据区和JMM内存模型的区别

经过上面的讲解,其中大家对于这两个区别的心里也该有了想法

JVM 运行时数据区主要是定义存储在哪里

JMM内存模型主要讲数据在内存中流转时的可见性问题

七、GC

7.1 GC Roots

1. 虚拟机栈中的引用对象

  • 局部变量:当前线程栈帧中的局部变量表(如方法参数、局部变量)引用的对象。

2. 方法区中的静态变量引用

  • 类的静态成员(static 字段):全局静态变量引用的对象。

3. 方法区中的常量引用

  • 常量(final static):字符串常量池、基本类型常量等。

4. 本地方法栈中的 JNI 引用

  • Native 方法引用的对象:通过 JNI(Java Native Interface)调用的本地代码创建的对象。

5. Java 虚拟机内部引用

  • 系统类对象:如基本数据类型(Integer.TYPE)、异常类(NullPointerException)、类加载器等。

  • Class 对象:所有已加载类的 Class 对象本身。

  • 常驻异常对象:如 OutOfMemoryError 等

6. 被同步锁(Synchronized)持有的对象

  • Monitor 对象:被 synchronized 关键字锁定的对象。

7. JMXBean 等管理对象

  • JMX 框架:通过 Java 管理扩展(JMX)注册的 Bean 对象。

7.2 Serial Garbage Collector

  • 核心机制

    • 新生代:采用标记-复制算法。将内存划分为一个 Eden 区和两个 Survivor 区(From/To)。存活对象在 Eden 和 From 区间复制,最终晋升到老年代。

    • 老年代:采用标记-整理算法。标记存活对象后,将所有对象向一端移动,消除碎片。

  • 核心特点

    • 单线程工作:垃圾回收全程仅使用单个 CPU 核心。

    • 全程 STW:执行 GC 时,所有应用线程完全暂停

  • 适用场景

    • 单核处理器环境。

    • 客户端应用(如桌面 GUI 程序),堆内存较小(通常 < 100MB)。

    • 对停顿时间不敏感的后台小任务。

  • 优点:实现简单,额外内存开销极小(无并发数据结构)。

  • 缺点:STW 时间长,无法利用多核优势,不适用于现代服务器应用。

  • 启用参数-XX:+UseSerialGC

触发老年代GC:

新生代发生 Minor GC 时,存活对象需要晋升到老年代,但老年代剩余空间不足容纳这些对象。
此时会先触发一次老年代GC(标记-整理),尝试释放空间。若释放后空间足够,则晋升成功;否则触发 Full GC。

触发FullGC:

  1. 老年代空间不足

    • 老年代自身空间分配失败(对象直接分配在老年代,如大对象)。

    • 老年代GC后空间仍不足(晋升失败后触发)。

  2. System.gc() 调用
    显式触发 Full GC(可通过 -XX:+DisableExplicitGC 禁止)。

  3. 元空间(Metaspace)不足
    类元数据占用超过 -XX:MetaspaceSize 阈值。

  4. 空间分配担保失败
    Minor GC 前,JVM 检查老年代连续最大可用空间是否 > 历次晋升对象的平均大小

    • 若不足,则直接触发 Full GC(而非老年代GC)。

7.3 Parallel Garbage Collector

1. 新生代回收 (Minor GC / Young GC) - 使用 Parallel Scavenge 算法

  • 目标区域: Eden 区 + From Survivor 区 (S0)。

  • 触发条件: 当 Eden 区空间不足以分配新对象时触发。

  • 流程 (Stop-The-World - STW):

    1. 暂停所有应用线程 (STW): 垃圾回收开始时,JVM 会暂停所有正在执行的应用程序线程。

    2. 初始标记 (Initial Mark - 短暂 STW): 标记 GC Roots (如栈帧中的局部变量、静态变量、JNI 引用等) 直接引用的对象。这一步很快,但需要 STW。

    3. 并行标记/扫描 (Concurrent Marking): (注意:Parallel Scavenge 本身通常不强调并发标记阶段,其标记主要在 STW 下并行完成。更复杂的并发标记是 CMS/G1 的特点。标准的 Parallel Scavenge 流程更侧重下面的并行复制) 实际上,Parallel Scavenge 的核心是并行复制。标记工作通常也在这个 STW 暂停期间由多个 GC 线程并行完成,扫描 Eden 和 From Survivor 区,找出所有存活的对象。

    4. 并行复制/清除 (Parallel Copying/Scavenging - 核心并行阶段): 这是 Parallel Scavenge 的核心。

      • 多个 GC 线程 并行 地将 Eden 区和 From Survivor 区中所有 存活的对象 复制到 To Survivor 区 (S1)。

      • 在复制过程中,对象会根据其年龄(经过的 GC 次数)被放置在 To Survivor 区的不同位置。

      • 年龄达到一定阈值 (-XX:MaxTenuringThreshold 设定) 的对象会被直接 晋升 (Promote) 到老年代。

      • 如果一个 Survivor 区空间不足(无法容纳所有存活对象),或者对象过大(超过 -XX:PretenureSizeThreshold),也会直接晋升到老年代。

      • 复制完成后,Eden 区和原来的 From Survivor 区 (S0) 中剩下的都是垃圾对象(死对象),这些空间会被直接、整体地回收(清除)。

    5. 角色交换: 清空后的 Eden 区和原来的 From Survivor 区 (S0) 现在变为空闲状态。原来的 To Survivor 区 (S1) 现在成为新的 From Survivor 区 (S0),准备迎接下一次 GC。S0 和 S1 的角色在每次 Minor GC 后都会互换。

    6. 恢复应用线程: 复制和清理完成后,恢复所有暂停的应用程序线程。

2. 老年代回收 (Major GC / Full GC) - 使用 Parallel Old 算法

  • 目标区域: 整个堆,包括新生代、老年代、元空间(方法区)。(严格来说,Parallel Old 主要处理老年代,但 Full GC 会涉及整个堆)

  • 流程 (Stop-The-World - STW):

    1. 暂停所有应用线程 (STW): 垃圾回收开始时,JVM 会暂停所有正在执行的应用程序线程。Full GC 的 STW 暂停时间通常比 Minor GC 长得多。

    2. 标记阶段 (Marking - 并行):

      • 多个 GC 线程 并行 地从 GC Roots 出发,递归遍历对象图,标记出堆中(包括新生代、老年代、元空间)所有 存活的对象

      • 这个阶段需要扫描整个堆,耗时较长。

    3. 计算/整理阶段 (Summary / Compaction Preparation - 可选并行): (Parallel Old 特有) 在标记完成后,GC 线程会并行地计算堆中各个区域的存活对象密度和最佳整理方案,为后续的滑动整理做准备。

    4. 并行压缩/整理 (Parallel Compaction - 核心并行阶段): 这是 Parallel Old 的核心,解决了其前身 Serial Old 串行压缩的瓶颈。

      • 多个 GC 线程 并行 地执行 滑动压缩 (Sliding Compaction)

      • 所有存活的对象被 移动(复制) 到堆的一端(通常是起始地址端),紧密排列在一起。

      • 移动完成后,存活对象区域之外的所有空间(即垃圾对象占用的空间和碎片空间)被一次性、整体地回收,变成连续的大块空闲空间。

    5. 更新引用 (Reference Updating - 并行): 由于对象被移动了位置,所有指向这些被移动对象的引用(指针)都需要更新到新的地址。多个 GC 线程并行地遍历修改栈、寄存器、其他对象中的引用。

    6. 恢复应用线程: 所有工作完成后,恢复所有暂停的应用程序线程。经过整理,堆变得规整,碎片大大减少。

Parallel GC 的关键特点总结

  1. 并行 (Parallel): 新生代 (Parallel Scavenge) 和老年代 (Parallel Old) 的垃圾回收工作都利用多个线程并行执行,充分利用多核 CPU 资源,显著缩短了 STW 时间(相对于 Serial GC)。

  2. 高吞吐量 (High Throughput): 核心设计目标。通过减少 GC 本身占用的时间(尽管 STW 时间可能不是最短的),让应用程序线程获得更多的 CPU 时间片来执行业务逻辑。适合后台运算、批处理等对延迟不敏感但需要最大化 CPU 利用率的场景。

  3. Stop-The-World (STW): 整个 GC 过程(包括标记、复制、压缩)都需要暂停应用线程。虽然并行缩短了暂停时间,但在 GC 期间应用程序是完全无响应的。Parallel Old 的 Full GC 暂停时间可能较长。

  4. 复制算法 (新生代): 新生代使用高效的复制算法 (Scavenge),配合两个 Survivor 区。

  5. 标记-整理算法 (老年代): 老年代使用标记-整理算法,特别是 并行滑动压缩 (Parallel Sliding Compaction),有效解决内存碎片问题。

  6. 可预测的停顿时间 (Adaptive Sizing): Parallel Scavenge 收集器提供 -XX:+UseAdaptiveSizePolicy 选项(默认开启)。JVM 会根据运行时收集到的性能监控数据(如 GC 停顿时间、吞吐量、晋升大小等),动态自动调整新生代大小 (-Xmn)、Eden/Survivor 比例 (-XX:SurvivorRatio)、晋升年龄阈值 (-XX:MaxTenuringThreshold) 等参数,以尽量达到用户设定的目标(最大 GC 停顿时间 -XX:MaxGCPauseMillis 或吞吐量 -XX:GCTimeRatio)。

与 CMS/G1/ZGC/Shenandoah 的主要区别

  • STW 性质: Parallel GC 在整个 GC 周期(尤其是老年代回收)都是 STW 的。而 CMS、G1、ZGC、Shenandoah 等收集器在标记阶段(甚至部分或全部回收阶段)是并发的(与应用线程交替运行),大大减少了 STW 时间,更适合对延迟敏感的应用。

  • 目标: Parallel GC 首要目标是吞吐量。CMS/G1/ZGC/Shenandoah 等更侧重于降低停顿时间 (Low Latency)

  • 碎片处理: Parallel Old 的压缩能有效避免碎片。CMS 使用标记-清除算法,会产生碎片,最终可能触发 Serial Old 压缩。G1/ZGC/Shenandoah 也各自有避免或处理碎片的机制(如 G1 的 Region 复制/压缩, ZGC/Shenandoah 的并发复制/压缩)。

何时选择 Parallel GC?

  • 应用程序运行在多核服务器上。

  • 主要目标是最大化应用程序吞吐量(例如:批处理作业、科学计算、后台服务)。

  • 对 GC 停顿时间 (延迟) 不敏感,可以容忍相对较长的、但频次较低的 STW 暂停(尤其是 Full GC)。

  • 需要一种简单、稳定、成熟且调优目标(吞吐量)明确的垃圾收集器。

触发老年代GC条件:

晋升失败(Promotion Failed)
机制与 Serial GC 相同,但老年代GC由多线程并行执行(标记-整理)

触发FullGC条件:

  1. 老年代空间不足

    • 直接分配失败 或 老年代GC后空间仍不足。

  2. System.gc() 调用

  3. 元空间不足

  4. 空间分配担保失败

7.4 CMS

CMS(Concurrent Mark-Sweep)收集器是 Java 虚拟机(JVM)中一种以最小化停顿时间为目标的老年代垃圾收集器。它通过将耗时最长的“标记”阶段与用户线程并发执行来实现这一目标。以下是 CMS 收集器的工作流程,主要分为四个阶段:

  1. 初始标记 (Initial Mark)

    • 目标: 标记所有 GC Roots 直接可达的老年代对象(即直接被活动线程栈、静态变量等引用的对象)。同时,如果新生代有对象引用了老年代对象,这些老年代对象也需要被标记(通过写屏障记录这些引用关系)。

    • 状态: Stop-The-World (STW)。虽然需要暂停所有应用线程,但这个阶段非常快,因为它只扫描直接关联。

  2. 并发标记 (Concurrent Mark)

    • 目标: 从“初始标记”阶段标记的对象出发,遍历整个老年代对象图,递归地标记所有存活对象

    • 状态: 并发执行。垃圾收集线程与用户应用线程同时运行。这是 CMS 收集器最耗时的阶段,但应用线程可以继续工作。

    • 关键点:

      • “浮动垃圾”产生: 由于标记过程中应用线程同时运行,可能会产生新的垃圾对象(在标记开始后变得不可达)或者原本标记为存活的对象变成垃圾。这些垃圾不会在本轮收集中被清除,称为“浮动垃圾”。

      • 写屏障: 为了处理并发标记期间应用线程修改对象引用关系(可能导致漏标或错标),CMS 使用写屏障技术。当应用线程修改一个对象的引用字段时,写屏障会将被修改前引用指向的对象(旧引用指向的对象)记录下来(通常记录在一个名为 Modification Remark Set 或类似结构里),供后续“重新标记”阶段处理。这确保了标记的准确性。常见的是 Snapshot-At-The-Beginning (SATB) 或 Incremental Update 策略。

  3. 重新标记 (Remark)

    • 目标: 修正在“并发标记”阶段由于应用线程继续运行而可能发生变动的那部分对象图标记状态。主要处理写屏障记录下来的引用变更

    • 状态: Stop-The-World (STW)。需要再次暂停所有应用线程。虽然需要暂停,但通常比“初始标记”长,但远短于“并发标记”,并且只处理并发期间变更的部分,而不是整个堆。

    • 关键点: 此阶段结束后,JVM 就拥有了老年代中所有存活对象的准确集合。

  4. 并发清除 (Concurrent Sweep)

    • 目标: 根据“重新标记”阶段确定的存活对象信息,回收不再使用的对象(垃圾)所占用的内存空间

    • 状态: 并发执行。垃圾收集线程与用户应用线程同时运行。应用线程可以继续分配新对象。

    • 关键点:

      • 不压缩: CMS 在这个阶段只清除垃圾对象,不对存活对象进行压缩整理。这导致回收后会产生内存碎片

      • 分配策略: 由于存在碎片,老年代对象分配通常使用空闲列表 (Free List) 来管理可用的内存块。

      • 浮动垃圾: 此阶段产生的垃圾(或“并发标记”阶段产生的浮动垃圾)不会被回收,需要等到下一次 GC。

CMS 收集器的关键特点与注意事项:

  1. 优点:

    • 低停顿时间: 最耗时的标记和清除阶段与应用线程并发执行,显著减少了 STW 时间,尤其是对于需要快速响应的应用(如 Web 服务)。

  2. 缺点:

    • 内存碎片: 并发清除不压缩内存,长期运行后可能导致严重的内存碎片问题,最终可能触发昂贵的 Full GC(通常是 Serial Old GC) 来进行压缩整理。

    • CPU 资源敏感: 在并发阶段,收集器线程与应用线程竞争 CPU 资源,可能会降低应用的总吞吐量(尤其是在 CPU 资源紧张时)。

    • 浮动垃圾: 无法回收当次 GC 过程中新产生的垃圾,可能导致老年代空间占用率不能降得很低。需要预留足够空间容纳这些浮动垃圾(-XX:CMSInitiatingOccupancyFraction 参数设置触发阈值,如 70%)。

    • 并发模式失败 (Concurrent Mode Failure): 如果在并发收集完成之前,老年代空间就被应用线程分配的对象填满了(可能是由于浮动垃圾太多,或者对象分配速率过快),JVM 会中止并发收集,转而触发一次 Full GC (通常是 Serial Old GC)。这会导致长时间的 STW 停顿,是 CMS 需要极力避免的情况。预留更多空间、优化对象分配速率、增加 CMS 收集线程数(-XX:ConcGCThreads)有助于减少失败。

    • 无法处理巨型对象: CMS 对在年轻代分配失败需要直接进入老年代的大对象处理不友好。

    • 复杂度: 实现复杂,对堆大小、对象生命周期分布等较敏感,调优相对复杂。

  3. 适用场景:

    • 延迟敏感的应用(如 Web 服务器、GUI 应用)。

    • 机器有多个 CPU 核心,能提供足够的并发资源。

    • 应用能容忍周期性的 Full GC(由碎片或并发失败引起)或能通过调优有效避免它们。

    • 老年代空间不是特别巨大(否则并发标记时间过长,重新标记阶段 STW 也可能过长)。

一般与ParNew配合使用:

ParNew 收集器在执行每次 Minor GC(年轻代垃圾回收)时,都会发生 Stop-The-World (STW) 停顿。 这是由其并行标记-复制算法的本质决定的。

具体来说,STW 发生在 ParNew 收集器工作的整个核心阶段

  1. 触发时刻:

    • 当 Eden 区空间不足,无法为新创建的对象分配内存时,JVM 就会触发一次 Minor GC。

  2. STW 开始:

    • 一旦 Minor GC 被触发,JVM 立即暂停(Stop)所有应用线程(The World)。这是 STW 的开始。

  3. ParNew 并行工作(在 STW 状态下):

    • 在应用线程全部暂停的情况下,多个 ParNew GC 线程被激活并开始并行工作。它们执行以下关键任务:

      • 标记 (Mark): 从 GC Roots(线程栈、静态变量、JNI 引用、老年代中记录的跨代引用 - 通过卡表)出发,并行地扫描 Eden 区和当前使用的 Survivor 区(如 From 区),找出所有存活的对象

      • 复制 (Copy): 将所有标记出来的存活对象,并行地从 Eden 区和 From 区复制另一个空闲的 Survivor 区(如 To 区)。在这个过程中,对象的年龄(Age)会增加 1。年龄达到阈值 (-XX:MaxTenuringThreshold) 或 Survivor 区空间不足的对象会被直接复制(晋升)到老年代

      • 清理/指针重置 (Clear / Reset Pointers): 理论上,在复制完成后,Eden 区和 From 区中剩下的都是垃圾对象。ParNew 会并行地快速清理(或更准确地说,是通过移动指针来逻辑上清空)Eden 区和 From 区,使其变为完全空闲状态。同时,From 区和 To 区的角色发生互换(原来的 To 区成为新的 From 区,原来的 From 区成为新的空闲 To 区,等待下次 GC)。

  4. STW 结束:

    • 当所有 ParNew GC 线程完成上述标记、复制和清理工作后,JVM 恢复所有被暂停的应用线程。这是 STW 的结束。应用线程可以继续运行,新创建的对象可以分配到已被清空的 Eden 区。

触发老年代GC:

  1. 老年代空间占用率 > 阈值
    -XX:CMSInitiatingOccupancyFraction(默认 68%)。

  2. 元空间扩容失败

  3. 显式调用jcmd GC.run 或 JMX)。

触发FullGC:

CMS 的设计目标是避免 Full GC,但以下情况仍会触发:

  1. 并发模式失败(Concurrent Mode Failure)

    • 并发周期完成前,老年代空间已满(应用线程继续分配对象)。

    • 此时会 STW 并触发 Serial Old GC(单线程 Full GC)。

  2. 晋升失败(Promotion Failed)
    Minor GC 时,老年代碎片过多导致无法容纳晋升对象(即使总空间足够)。

  3. 元空间不足

  4. 显式 System.gc() 调用(可通过 -XX:+ExplicitGCInvokesConcurrent 转为 CMS 并发周期)。

7.5 G1

Java 的 G1 (Garbage-First) 收集器是一款面向服务端应用、以低延迟高吞吐量为目标设计的垃圾收集器。它的核心思想是将堆划分为多个大小相等的 Region,并优先回收垃圾最多的 Region(Garbage-First 名称的由来),同时尽量满足用户设定的停顿时间目标 (-XX:MaxGCPauseMillis)

G1 的回收流程主要分为以下几个阶段,其中 Young GC 和 Mixed GC 是主要的活动类型,Full GC 则是在特定情况下触发的兜底操作,Young GC 和 Mixed GC采用都是标记-复制算法

  • G1将堆划分为多个大小相等的Region,默认约 1MB ~ 32MB

  • 在运行时,这些Region会被动态分配扮演不同的角色:

    • Eden Regions: 用于分配新创建的对象。

    • Survivor Regions: 用于存放从Young GC中存活下来的对象。

    • Old Regions: 用于存放存活足够长时间(经历多次Young GC)的对象。

    • Humongous Regions: 用于存放特别大的对象。

  • 关键点: 一个Region在某个时刻只扮演一种角色(Eden, Survivor, Old, Humongous)。并且,这些角色是动态变化的:

    • 一次Young GC后,被清空的Eden Region可能在下一次分配中被重新用作Eden或Survivor。

    • 一个Survivor Region中的对象存活足够久后,整个Region(或其中的对象)会被晋升为Old Region。

    • 一个Old Region在Mixed GC中被回收后,会变成空闲Region,之后可以重新分配为任何角色(通常是Eden或Survivor)。

Young GC :

  • Eden Regions被填满时,G1会触发一次Young GC

  • 这次GC的目标是回收所有Eden RegionsSurvivor Regions(即所有逻辑上的“年轻代”Region)。

  • Young GC的过程是STW的,使用复制算法:将Eden和Survivor Regions中存活的对象复制到新的Survivor Regions(或者直接晋升到Old Regions),然后回收清空原来的Eden和Survivor Regions。

触发Mixed GC

  1. 初始标记 (Initial Mark - STW):

    • 目的: 标记所有从 GC Roots 直接可达 的对象 (GC Roots 包括线程栈局部变量、静态变量、JNI 引用等)。

    • 特点: 需要 Stop-The-World (STW),但停顿时间非常短,因为它只标记直接可达对象,不进行完整的追踪。

    • 关联: 这个阶段通常借用一次 Young GC 的 STW 暂停来执行,可以说是“搭便车”。

  2. 并发标记 (Concurrent Marking):

    • 目的: 从 初始标记 阶段标记的那些直接可达对象出发,并发地(与应用线程一起运行)遍历整个对象图,标记出所有存活的对象。

    • 特点:

      • 并发执行,大部分工作与应用线程并行,不会导致应用停顿

      • 处理并发标记期间应用线程修改对象引用关系导致的“错标”问题,使用 SATB (Snapshot-At-The-Beginning) 算法:

        • 在并发标记开始时,G1 对堆内存中的对象关系做一个逻辑快照

        • 应用线程在并发标记期间对引用关系的修改(主要是删除引用,即把一个对象变成垃圾)会被记录在线程的 SATB 缓冲区中。

        • 这些记录的引用变更会在后续阶段处理,确保标记的准确性(不会漏标在快照时刻存活的对象)。

    • 可中断: 如果 Young GC 发生,并发标记会被暂时中断,等 Young GC 完成后再继续。

  3. 最终标记 (Remark - STW):

    • 目的:

      1. 处理在 并发标记 阶段结束时剩余的 SATB 缓冲区记录,确保所有在快照时刻存活的对象都被正确标记。

      2. 执行一些必要的最终处理(如类卸载的清理、弱引用处理等)。

    • 特点: 需要 STW 暂停,但这个暂停通常比初始标记长一些,比 CMS 的重新标记阶段短,因为它处理的是缓冲区积累的变更,而不是整个堆的重新扫描。

  4. 清理 (Cleanup - STW & Concurrent):

    • 目的:

      1. (STW 部分):

        • 统计每个 Region 中存活对象的比例和大小,并根据用户设定的停顿时间目标 (MaxGCPauseMillis) 对 Region 进行回收价值排序(垃圾最多、回收耗时最短的 Region 优先)。

        • 识别出完全空闲的 Region (Immediately Reclaimable Regions),直接回收它们的内存。

        • 为下个阶段(Mixed GC)选择一组包含最多可回收垃圾的 Region 作为候选集合(Collection Set, CSet)。

      2. (并发部分):

        • 执行 RSet 的清理和细化工作。

        • 回收完全空闲的 Region(如果还有)。

    • 特点: 包含一个短暂的 STW 暂停用于统计和选择 CSet,以及一些并发的清理工作。

触发FullGC:

  1. 晋升失败 (Promotion Failure):

    • 场景: 在 Young GC 或 Mixed GC 的复制/疏散阶段,需要将存活对象晋升到老年代(例如,对象年龄达到阈值或 Survivor 空间不足)。

    • 原因: 老年代中没有足够的 连续可用空间 来容纳这些待晋升的对象。

    • 结果: G1 无法完成晋升操作,被迫中止当前的 Young/Mixed GC,并触发一次 Full GC。这是最常见的触发 Full GC 的原因之一。

  2. 疏散失败 (Evacuation Failure):

    • 场景: 在 Young GC 或 Mixed GC 的复制/疏散阶段,需要将 CSet (回收集合) 中的存活对象复制到新的空闲 Region (Survivor 或 Old)。

    • 原因: 空闲 Region 池中没有足够的 Region 来容纳所有需要复制的存活对象。这通常发生在:

      • 堆内存总体紧张,空闲 Region 耗尽。

      • 巨型对象分配消耗了大量连续 Region。

      • 并发标记周期尚未完成或 Mixed GC 回收力度不够,老年代垃圾释放的空间不足以满足需求。

    • 结果: G1 无法完成对象的复制,被迫中止当前 GC,触发 Full GC。

  3. 并发模式失败 (Concurrent Mode Failure) / 堆内存耗尽:

    • 场景: 在并发标记周期(主要是并发标记阶段)运行期间,应用程序继续分配新对象。

    • 原因: 新对象分配过快,或者在并发标记结束前,堆空间(尤其是年轻代 Eden 或老年代)被完全填满

    • 结果: G1 没有机会启动 Mixed GC 来回收老年代空间以腾出内存,此时会触发 Full GC。这本质上是并发收集跟不上分配速率导致的失败。

  4. 巨型对象分配失败 (Humongous Object Allocation Failure):

    • 场景: 应用程序尝试分配一个巨型对象(大小超过单个 Region 50% 的对象)。

    • 原因: G1 无法在堆中找到连续的、数量足够的空闲 Region 来存放这个巨型对象。

    • 结果: 即使触发 Young GC 或期望通过 Mixed GC 回收空间,也可能无法及时释放出足够的连续空间。最终导致 Full GC 被触发,试图通过内存整理来获得连续空间。

  5. 显式调用 System.gc():

    • 场景: 应用程序代码或某些库/框架直接调用了 System.gc() 或 Runtime.getRuntime().gc()

    • 原因: 默认情况下(除非设置了 -XX:+DisableExplicitGC),这会强烈建议 JVM 执行一次 Full GC。

    • 结果: G1 通常会响应这个“建议”,执行一次 Full GC。强烈建议在生产环境中禁用显式 GC (-XX:+DisableExplicitGC)

  6. 元空间 (Metaspace) 耗尽:

    • 场景: 加载的类过多(如热部署频繁的应用、大量使用反射/动态代理等),或者 -XX:MaxMetaspaceSize 设置过小。

    • 原因: 元空间内存不足,无法分配新的类元数据。

    • 结果: 会触发一次 Full GC 来尝试卸载不再使用的类并回收元空间内存。如果 Full GC 后仍然无法满足分配需求,则抛出 OutOfMemoryError: Metaspace

  7. 其他系统资源或 JVM 内部原因:

    • 例如,处理某些引用(软引用、弱引用、虚引用、Finalizer)时达到特定阈值或条件(虽然通常不会直接导致 Full GC,但可能在相关处理过程中间接影响)。

G1 Full GC 的特点

  • 收集算法: G1 的 Full GC 回退到单线程的 Serial Old 收集器的算法。

  • 过程: 执行一次 STW 的、单线程的 “标记-整理” (Mark-Compact) 过程:

    1. 标记 (Mark - STW): 暂停所有应用线程,从 GC Roots 出发,标记出堆中所有存活的对象。

    2. 整理 (Compact - STW): 将所有存活对象向堆的一端移动,紧密排列。更新所有指向这些对象的引用。

    3. 清除: 回收掉存活对象边界之外的所有内存(即所有垃圾对象占用的空间)。

  • 目的: 最大程度地回收内存消除内存碎片,为后续对象分配提供连续空间。

  • 代价: STW 停顿时间非常长!因为它需要扫描和移动整个堆。这与 G1 设计的低延迟目标背道而驰。

Region动态调整原则:

如果 Young GC 的实际停顿时间小于目标,G1 可能会在下一次尝试增加 Eden Regions 的数量(让 Eden 更大,从而减少 GC 频率,提高吞吐量)。如果实际停顿时间超过目标,G1 会减少 Eden Regions 的数量(让 Eden 更小,导致 GC 更频繁,但每次停顿更短)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值