JVM(以HotSpot为例)的运行时内存区域(Runtime Data Area)根据《Java虚拟机规范》划分为多个模块,各区域承担不同职责,且通过JVM参数可精确控制其大小和行为。以下是各内存区域的详细解析,包括存放内容、默认配置及核心控制参数:
jvm运行时内存区
一、程序计数器(Program Counter Register)
1. 存放内容
- 当前线程执行的字节码指令地址(如正在执行的方法的行号)。
- 若线程执行的是本地方法(Native Method) ,计数器值为
Undefined。
2. 特性
- 线程私有:每个线程拥有独立的程序计数器,互不干扰(避免线程切换时指令混乱)。
- 唯一不会OOM的区域:内存占用极小,《Java虚拟机规范》未规定OutOfMemoryError场景。
3. 控制参数
- 无直接控制参数:其大小由JVM内部管理,与硬件架构(如CPU位数)相关,用户无法配置。
二、Java虚拟机栈(Java Virtual Machine Stacks)
1. 存放内容
- 栈帧(Stack Frame) :每个方法调用时创建一个栈帧,包含:
- 局部变量表:存放方法参数、局部变量(基本数据类型、对象引用、returnAddress类型)。
- 操作数栈:方法执行过程中的临时数据栈(如算术运算、对象字段访问的中间结果)。
- 动态链接:指向运行时常量池的方法引用(支持多态)。
- 方法返回地址:方法执行完毕后返回的调用者位置。
2. 特性
- 线程私有:与线程生命周期一致,线程创建时分配,销毁时释放。
- 栈深度限制 :方法嵌套调用过深会导致
StackOverflowError(如递归无终止条件)。 - 动态扩展 :栈容量可动态增长,若扩展时无法申请到内存,抛出
OutOfMemoryError。
3. 控制参数
-Xss<size>:设置每个线程的虚拟机栈大小(默认值:Linux/x64下JDK8为1MB,客户端JVM可能更小)。
例:-Xss512k表示每个线程栈大小为512KB。- 注意:减小该值可创建更多线程(总栈内存 = 线程数 × Xss),但可能增加
StackOverflowError风险。
三、本地方法栈(Native Method Stacks)
1. 存放内容
- 为本地方法(Native Method,如C/C++实现的方法) 提供内存空间,具体结构由虚拟机实现定义(如HotSpot将其与Java虚拟机栈合并管理)。
2. 特性
- 线程私有 ,行为与虚拟机栈类似,可能抛出
StackOverflowError或OutOfMemoryError。
3. 控制参数
- 无独立参数 :HotSpot中通过
-Xss同时控制虚拟机栈和本地方法栈大小(两者共享该参数)。
四、Java堆(Java Heap)
1. 存放内容
- 所有对象实例及数组(是垃圾回收的核心区域,"GC堆")。
- 现代JVM中,字符串常量池、类实例数据等也存储于堆中(JDK7+将字符串常量池从方法区移至堆)。
2. 特性
- 线程共享:所有线程共享Java堆,需通过同步机制保证线程安全。
- 动态分配 :对象内存通过
new关键字分配,由GC自动回收。 - 分代管理 :为优化GC效率,堆通常划分为新生代 (Young Generation)和老年代 (Old Generation):
- 新生代:存放短期存活对象(Eden区 + 2个Survivor区)。
- 老年代:存放长期存活对象或大对象。
3. 控制参数
(1)堆总大小控制
-Xms<size>:初始堆大小(默认值:物理内存的1/64,且≥1MB)。-Xmx<size>:最大堆大小(默认值:物理内存的1/4,且≤1GB)。
最佳实践:-Xms与-Xmx设置为相同值,避免堆动态扩展的性能开销(如-Xms2g -Xmx2g)。
(2)新生代与老年代比例
-XX:NewRatio=<n>:老年代与新生代的容量比(默认2,即老年代:新生代=2:1)。
例:-XX:NewRatio=3表示老年代占3/4,新生代占1/4(总堆=新生代+老年代)。-XX:NewSize=<size>:新生代初始大小。-XX:MaxNewSize=<size>:新生代最大大小。
(3)新生代内部分区(Eden与Survivor)
-XX:SurvivorRatio=<n>:Eden区与单个Survivor区的比例(默认8,即Eden:From:To=8:1:1)。
例:-XX:SurvivorRatio=4表示Eden占4/6,From和To各占1/6。-XX:MaxTenuringThreshold=<n>:对象晋升老年代的最大年龄阈值(默认15,仅对传统分代GC有效)。
五、方法区(Method Area)
1. 存放内容
- 类元数据:类的结构信息(如类名、父类、接口、字段、方法、构造函数)。
- 运行时常量池 :编译期生成的常量(如
final变量)、符号引用(如类名、方法名的字符串)、动态生成的常量(如String.intern()结果)。 - 静态变量 (
static修饰的变量):JDK8前存于方法区,JDK8后移至堆中的"元空间"(Metaspace)。 - 即时编译器(JIT)生成的代码缓存(部分虚拟机将其单独划分,如HotSpot的Code Cache)。
2. 特性
- 线程共享:所有线程共享方法区,类加载后元数据全局可见。
- 内存回收效率低:主要回收"无用类"(满足类卸载条件),回收频率低。
- JDK版本差异 :
- JDK7及以前:方法区基于"永久代(PermGen)"实现。
- JDK8及以后:取消永久代,改用"元空间(Metaspace)",元数据存储于本地内存(Native Memory)。
3. 控制参数(分版本)
(1)JDK7及以前(永久代)
-XX:PermSize=<size>:永久代初始大小(默认值:JDK7为16MB)。-XX:MaxPermSize=<size>:永久代最大大小(默认值:JDK7为64MB/128MB,超过会抛出OutOfMemoryError: PermGen space)。
(2)JDK8及以后(元空间)
-XX:MetaspaceSize=<size>:元空间初始大小(默认值:约21MB,达到该值时触发元空间GC)。-XX:MaxMetaspaceSize=<size>:元空间最大大小(默认无上限,受本地内存限制,建议显式设置,如-XX:MaxMetaspaceSize=256m)。-XX:MinMetaspaceFreeRatio=<n>:GC后元空间最小空闲比例(默认40%,低于该值则扩容)。-XX:MaxMetaspaceFreeRatio=<n>:GC后元空间最大空闲比例(默认70%,高于该值则缩容)。
以下从类加载器的基础概念、双亲委派模型的原理,到打破双亲委派的具体方法,进行系统性详解,涵盖核心逻辑与实战场景:
类加载器(ClassLoader):定义与分类
类加载器是JVM中负责将字节码(.class文件)加载到内存,并生成java.lang.Class对象的组件。它的核心作用是通过类的全限定名定位字节码,并完成类的加载与定义,同时通过"命名空间"实现类的隔离(不同类加载器加载的同名类视为不同类)。
1. JDK默认类加载器(按层次划分)
| 类加载器类型 | 实现方式 | 加载范围(核心职责) | 父加载器 |
|---|---|---|---|
| 启动类加载器 | C/C++实现(非Java类) | 加载JAVA_HOME/lib目录下的核心类库(如java.lang.String、java.util.*),或-Xbootclasspath指定路径的类。 |
无(getParent()返回null) |
| 扩展类加载器 | Java类(ExtClassLoader) |
加载JAVA_HOME/lib/ext目录下的扩展类库,或java.ext.dirs系统变量指定路径的类。 |
启动类加载器 |
| 应用程序类加载器 | Java类(AppClassLoader) |
加载应用程序classpath(-classpath)下的类(用户自定义类),是默认类加载器。 |
扩展类加载器 |
| 自定义类加载器 | 继承ClassLoader实现 |
自定义加载逻辑(如加载加密类、网络类、热部署类等),灵活度高。 | 通常为应用程序类加载器 |
2. 类加载器的核心方法
loadClass(String name):加载类的入口方法,默认实现遵循双亲委派模型。findClass(String name):在loadClass中,父类加载失败后,子类加载器调用此方法查找并加载类(自定义类加载器通常重写此方法)。defineClass(byte[] b, int off, int len):将字节码转换为Class对象(最终调用JVM底层方法完成类的定义)。
二、双亲委派模型(Parent Delegation Model):原理与作用
双亲委派是类加载器的默认加载规则,核心是**"先委托父类加载,父类失败再自己加载"**,目的是保证类加载的安全性和唯一性。
1. 核心流程(以应用程序类加载器加载com.example.MyClass为例)
- 委托父类:应用程序类加载器收到请求后,不自己加载,而是委托给父类(扩展类加载器)。
- 逐层上抛:扩展类加载器再委托给父类(启动类加载器)。
- 父类尝试加载 :启动类加载器在自己的加载范围(
JAVA_HOME/lib)中查找com.example.MyClass,未找到(非核心类),返回失败。 - 父类失败,子类加载 :扩展类加载器在自己的范围(
lib/ext)中查找,未找到;应用程序类加载器在classpath中找到并加载该类。
2. 源码体现(ClassLoader.loadClass方法)
java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已加载过该类
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2. 委托父类加载器加载
c = parent.loadClass(name, false);
} else {
// 3. 父类为null(启动类加载器),尝试启动类加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父类加载器加载失败(正常流程)
}
if (c == null) {
// 4. 父类加载失败,自己加载(调用findClass)
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
3. 核心作用
- 保证类的唯一性:同一类(全限定名相同)只能被一个类加载器加载(父类加载后,子类不再加载),避免类重复。
- 保护核心类 :核心类(如
java.lang.String)只能由启动类加载器加载,防止用户自定义同名类篡改核心逻辑(如自定义java.lang.String会被双亲委派机制拦截,无法加载)。
三、打破双亲委派的方法(实战场景与实现)
双亲委派是默认规则,但某些场景(如类隔离、SPI机制)需要打破它。核心是改变"先父后子"的加载顺序,让子类加载器优先加载,或实现"逆向委派"。
方法1:重写loadClass方法(自定义类加载器)
默认loadClass严格遵循双亲委派,重写该方法可改变加载顺序(如"先自己加载,失败再委托父类")。
实现示例:优先加载自定义路径的类
java
public class BreakDelegateClassLoader extends ClassLoader {
private String customPath; // 自定义类路径(如./myclasses)
public BreakDelegateClassLoader(String customPath) {
super(); // 父加载器默认为AppClassLoader
this.customPath = customPath;
}
// 重写loadClass,打破双亲委派
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 1. 检查是否已加载
Class<?> clazz = findLoadedClass(name);
if (clazz != null) {
return clazz;
}
// 2. 核心类(java.lang.*)仍委托父类加载(避免JVM安全检查报错)
if (name.startsWith("java.lang.")) {
return super.loadClass(name); // 走双亲委派流程
}
// 3. 非核心类:先自己加载,失败再委托父类
try {
byte[] classData = loadClassData(name); // 从自定义路径读取字节码
clazz = defineClass(name, classData, 0, classData.length); // 定义类
return clazz;
} catch (Exception e) {
// 自己加载失败,委托父类
return super.loadClass(name);
}
}
// 从自定义路径读取.class文件的字节数组
private byte[] loadClassData(String className) throws IOException {
String path = customPath + "/" + className.replace(".", "/") + ".class";
try (InputStream is = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
}
}
// 测试:加载自定义类
public static void main(String[] args) throws Exception {
BreakDelegateClassLoader cl = new BreakDelegateClassLoader("./myclasses");
Class<?> clazz = cl.loadClass("com.example.Test");
System.out.println("加载器:" + clazz.getClassLoader()); // 输出BreakDelegateClassLoader
}
}
关键逻辑:
- 对核心类(
java.lang.*)仍委托父类加载(否则JVM会抛出SecurityException,禁止自定义核心类)。 - 非核心类优先从自定义路径加载,打破"先父后子"的默认顺序。
方法2:利用线程上下文类加载器(Thread Context ClassLoader)
解决"父类加载器无法加载子类路径类"的问题(如SPI机制),通过线程绑定的类加载器实现"逆向委派"。
背景:SPI机制的困境
- SPI接口(如
java.sql.Driver)由启动类加载器加载(在rt.jar中)。 - SPI实现(如MySQL的
com.mysql.cj.jdbc.Driver)在classpath下,由应用程序类加载器加载。 - 按双亲委派,启动类加载器(父)无法委托应用程序类加载器(子)加载类,导致接口找不到实现。
解决方案:线程上下文类加载器
- 每个线程有一个
contextClassLoader(默认是应用程序类加载器),可通过Thread.setContextClassLoader()设置。 - 父类加载器(如启动类加载器)通过线程上下文类加载器,委托子类加载器(应用程序类加载器)加载SPI实现。
示例:JDBC中的实现
java
// DriverManager(由启动类加载器加载)加载SPI实现的逻辑
public class DriverManager {
static {
loadInitialDrivers();
}
private static void loadInitialDrivers() {
// 1. 获取线程上下文类加载器(默认是AppClassLoader)
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 2. 通过ServiceLoader加载SPI实现(使用cl加载classpath下的Driver实现)
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class, cl);
// 3. 实例化所有Driver实现
Iterator<Driver> driversIterator = loadedDrivers.iterator();
while (driversIterator.hasNext()) {
driversIterator.next(); // 触发类加载
}
}
}
- 启动类加载器通过线程上下文类加载器(子加载器)加载了
classpath下的实现类,打破了双亲委派的单向委托流程。
方法3:OSGi框架的平级类加载器
OSGi是模块化框架,每个模块有独立的类加载器,加载规则由模块依赖决定(非父子关系),完全打破双亲委派。
- 模块A依赖模块B时,A的类加载器可直接加载B的类,无需委托父类。
- 支持类的热部署(替换模块时,销毁旧类加载器,创建新类加载器)。
方法4:Tomcat的类加载器隔离
Tomcat为每个Web应用创建独立的WebAppClassLoader,优先加载应用内的类(WEB-INF/classes、WEB-INF/lib),避免不同应用的类冲突:
- 重写
loadClass,对应用内的类先自己加载,再委托父类。 - 核心类(如
java.*)仍委托父类加载,保证安全。
总结
| 概念/方法 | 核心逻辑 | 典型场景 |
|---|---|---|
| 类加载器 | 加载字节码并生成Class对象,通过命名空间隔离类。 |
所有Java类的加载 |
| 双亲委派模型 | 先委托父类加载,父类失败再自己加载,保证类的唯一性和安全性。 | 核心类加载、默认类加载流程 |
打破方式1:重写loadClass |
非核心类优先自己加载,失败再委托父类。 | 自定义类加载、类隔离 |
| 打破方式2:线程上下文类加载器 | 父类加载器通过线程绑定的子类加载器加载类,解决SPI实现加载问题。 | JDBC、JNDI等SPI机制 |
| 打破方式3:OSGi/Tomcat | 平级类加载器或应用隔离,按需加载类,支持热部署和模块化。 | Web容器、模块化框架 |
理解这些内容,既能掌握JVM类加载的基础原理,也能应对面试中关于类隔离、SPI机制等深度问题。
类加载流程
类加载是将 .class 字节码文件加载到 JVM 内存并生成 java.lang.Class 对象的过程,是类生命周期的第一步。完整流程遵循 "加载 → 验证 → 准备 → 解析 → 初始化" 五个阶段,其中后四个阶段由 JVM 自动完成,"加载"阶段由类加载器负责。以下是每个阶段的详细拆解:
一、加载(Loading):获取字节码并生成初步数据结构
核心任务 :通过类的全限定名(如 com.example.User)找到字节码文件,读取数据并在内存中生成初步结构。
具体步骤:
-
定位字节码
类加载器根据全限定名查找字节码来源,常见来源包括:
- 本地文件系统(
.class文件); - 网络(如
Applet小程序); - 内存(如动态生成的字节码,
CGLib动态代理); - 归档文件(如
JAR、WAR包)。
- 本地文件系统(
-
读取字节流
将字节码文件的二进制数据读入内存(如通过
ClassLoader.findClass()方法读取)。 -
生成运行时数据结构
将字节流转换为 JVM 可识别的内部数据结构(存储在方法区,包含类的版本、字段、方法、接口等元信息)。
-
创建
Class对象在堆内存中生成一个代表该类的
java.lang.Class对象,作为方法区中类元数据的"访问入口"(后续通过该对象操作类信息)。
二、验证(Verification):确保字节码安全合法
核心任务:校验字节码是否符合 JVM 规范,防止恶意或错误的字节码危害 JVM 运行。
验证内容(四阶段):
-
文件格式验证
检查字节流是否符合
.class文件格式规范,例如:- 魔数开头(
0xCAFEBABE,JVM 识别.class文件的标识); - 主版本号、次版本号是否在当前 JVM 支持范围内(如 JDK 8 支持版本号 52.0 及以下);
- 常量池格式是否合法(如常量类型、索引是否有效)。
- 魔数开头(
-
元数据验证
校验类的元数据(语义)是否合法,例如:
- 类是否有父类(除
java.lang.Object外,所有类必须有父类); - 类是否继承了不允许被继承的类(如被
final修饰的类); - 字段、方法是否与父类冲突(如覆盖父类的
final方法)。
- 类是否有父类(除
-
字节码验证
最复杂的阶段,校验方法体的字节码指令是否符合逻辑,例如:
- 指令操作数类型是否匹配(如对
int类型执行long运算); - 跳转指令是否指向合理的代码位置(不越界);
- 方法体是否有正确的返回(如非
void方法是否有返回值)。
- 指令操作数类型是否匹配(如对
-
符号引用验证
校验常量池中的符号引用(如类名、方法名)是否可被解析,例如:
- 符号引用指向的类、字段、方法是否存在;
- 当前类是否有访问符号引用的权限(如访问 private 成员)。
三、准备(Preparation):为静态变量分配内存并设默认值
核心任务 :为类的 静态变量(static 变量) 分配内存,并设置 默认初始值(非显式赋值)。
关键细节:
-
分配内存的对象
仅针对 静态变量(属于类级别,存储在方法区),实例变量(对象成员变量)的内存分配在对象实例化时(在堆中)进行。
-
默认初始值规则
基本数据类型按"零值"初始化,引用类型为
null:类型 默认值 示例( static int a = 10;)int0准备阶段 a = 0(10在初始化阶段赋值)booleanfalsestatic boolean flag;→falseObjectnullstatic User user;→null -
特殊情况:
static final常量如果静态变量被
final修饰(常量),则在准备阶段直接赋 显式值(编译期已确定):javapublic static final int MAX = 100; // 准备阶段直接赋值 100,而非 0
四、解析(Resolution):将符号引用转为直接引用
核心任务 :将常量池中的 符号引用 替换为 直接引用(内存地址),建立字节码与内存数据的关联。
关键概念:
- 符号引用 :编译期生成的字符串标识,如类名
com.example.User、方法名toString、字段名name(不依赖内存布局,仅表示"谁")。 - 直接引用:指向内存中实际位置的指针或偏移量(如对象的内存地址、方法在方法区的偏移量,依赖内存布局)。
解析内容:
- 类或接口解析 :将类的符号引用(如
Lcom/example/User;)转为直接引用(指向方法区中该类的元数据地址)。 - 字段解析:找到字段所属的类,将字段名转为该字段在类元数据中的偏移量。
- 方法解析:找到方法所属的类或接口,将方法名转为该方法在方法区的内存地址。
- 接口方法解析:类似方法解析,但针对接口方法。
解析时机:
JVM 允许 "按需延迟解析":即不是在解析阶段一次性完成所有符号引用的转换,而是在首次使用某个符号引用时才解析(如首次调用方法时解析该方法的引用)。
五、初始化(Initialization):执行类的初始化代码
核心任务 :执行类的 初始化逻辑,包括静态变量显式赋值和静态代码块执行,是类加载过程的最后一步。
触发条件(主动使用):
只有当类被"主动使用"时,才会触发初始化(被动使用不会,如仅通过子类引用父类的静态字段)。主动使用场景包括:
- 创建类的实例(
new User()); - 调用类的静态方法(
User.print()); - 访问类的静态字段(
User.count,final常量除外); - 反射调用(
Class.forName("com.example.User")); - 初始化子类时,父类未初始化则先初始化父类;
- JVM 启动时,执行
main方法的类(启动类)。
执行逻辑:
-
静态变量显式赋值 :执行静态变量的赋值语句(如
static int a = 10;中的a = 10)。 -
静态代码块执行 :按代码编写顺序执行
static {}中的语句。示例:
javapublic class InitDemo { static int a = 10; // 显式赋值(初始化阶段执行) static { System.out.println("静态代码块执行"); // 初始化阶段执行 a = 20; } } // 初始化后,a 的值为 20(静态代码块后执行,覆盖之前的 10) -
线程安全性 :JVM 保证类的初始化在多线程环境下是 线程安全的(仅一个线程执行初始化,其他线程阻塞等待)。
总结:类加载流程的核心特点
- 顺序性:加载、验证、准备、初始化四个阶段按顺序执行(解析阶段可能在初始化后延迟执行)。
- 唯一性:一个类仅被加载一次(由类加载器和全限定名共同确定唯一性)。
- 安全性:验证阶段确保字节码合法,双亲委派机制(类加载器)防止核心类被篡改。
理解这五个阶段,能清晰掌握类从字节码到内存可用状态的完整过程,也是分析类加载相关问题(如静态变量初始化顺序、类冲突)的基础。
CMS与G1收集器全方位深度对比
CMS(Concurrent Mark Sweep)和G1(Garbage-First)是JVM中两款经典的垃圾收集器,二者在工作区域、回收对象、执行流程、核心机制、参数配置及GC执行特性上存在显著差异。以下从多个维度进行详细拆解,覆盖底层原理与实践配置。
一、工作区域与回收对象
工作区域决定了收集器的内存管理范围,回收对象则明确了其处理的垃圾类型,是理解两款收集器的基础。
1. CMS收集器
工作区域
CMS是老年代专属收集器 ,自身仅负责老年代的垃圾回收,无法处理新生代垃圾。其依赖其他收集器(如Serial、ParNew)处理新生代,形成"新生代收集器+CMS"的组合模式(JDK 5-8默认搭配ParNew+CMS)。
内存布局采用固定分代模型:
- 新生代:分为Eden区和2个Survivor区(From/To),遵循"复制算法";
- 老年代:独立内存区域,采用"标记-清除"算法,是CMS的核心工作区域;
- 永久代(JDK 7及之前)/元空间(JDK 8+):不参与CMS回收。
回收对象
CMS仅回收老年代中的死亡对象,具体包括:
- 新生代晋升至老年代后存活到期的对象;
- 老年代中直接分配的大对象(超过新生代阈值的对象);
- 长期存活于老年代、引用计数为0或可达性分析判定为不可达的对象。
- 注意:CMS并发清除阶段产生的"浮动垃圾"(用户线程新生成的垃圾)无法被当前GC回收,需等待下一次GC。
2. G1收集器
工作区域
G1打破了传统"固定分代"模型,采用Region(区域)化内存布局 ,是"全域性收集器"(可同时处理新生代和老年代)。
内存布局核心特性:
- 堆内存划分为2048个大小相等的Region (大小通过
-XX:G1HeapRegionSize配置,范围1-32MB,需为2的幂); - 每个Region动态扮演不同角色(逻辑分代,物理不分),可通过JVM参数监控工具(如JVisualVM)观察角色切换:
- Eden Region:新生代Eden区,存储新创建的对象;
- Survivor Region:新生代Survivor区,存储Eden区回收后存活的对象;
- Old Region:老年代区域,存储Survivor区晋升或直接分配的大对象;
- Humongous Region:存储超大对象(大小超过Region的50%),由连续多个Region组成,避免跨Region引用开销;
- 永久代/元空间:不参与G1回收。
回收对象
G1可回收全域内的死亡对象,包括:
- Eden Region中所有死亡对象(Minor GC阶段);
- Survivor Region中存活超过阈值的对象(晋升至Old Region)及死亡对象;
- Old Region中可达性分析判定为不可达的对象(Mixed GC阶段);
- Humongous Region中无引用的大对象(单独回收或随Mixed GC回收)。
二、详细工作流程
两款收集器的工作流程均包含"标记"和"回收"核心阶段,但因算法和目标不同,阶段划分与执行逻辑差异极大。
1. CMS收集器工作流程(基于"标记-清除"算法)
CMS的核心是"并发标记+并发清除",仅在关键节点触发短时间STW(Stop The World),流程分为4个阶段:
阶段1:初始标记(Initial Mark)- STW
- 目标:快速标记GC Roots直接关联的对象(根对象),不遍历对象图;
- 执行逻辑 :
- 暂停所有用户线程(STW),耗时极短(通常10-50ms);
- 扫描GC Roots(如虚拟机栈引用、方法区静态属性引用、本地方法栈引用);
- 标记根对象直接指向的老年代对象(如A是根对象,A→B,仅标记B);
- 依赖机制:OopMap(JVM维护的对象引用映射表),快速定位GC Roots位置,避免全堆扫描。
阶段2:并发标记(Concurrent Mark)- 并发执行
- 目标:从初始标记的根对象出发,遍历整个老年代对象图,标记所有存活对象;
- 执行逻辑 :
- 恢复用户线程,与GC线程并发执行;
- 从初始标记的对象(如B)开始,递归遍历其引用的对象(B→C→D...),标记所有可达对象;
- 处理"引用变化":采用增量更新(Incremental Update) 机制------当用户线程修改对象引用(如C→D改为C→E)时,标记E为"待重新扫描",确保E不会被漏标;
- 特点:耗时最长(通常几百ms到几秒),但不阻塞用户线程,对业务响应影响小。
阶段3:重新标记(Remark)- STW
- 目标:修正并发标记阶段因用户线程运行导致的标记偏差,确保标记准确性;
- 执行逻辑 :
- 再次暂停用户线程(STW),停顿时间比初始标记长(通常50-200ms),但远短于并发标记;
- 扫描并发标记期间记录的"脏卡(Dirty Card)"(卡表中标记为"修改过"的项)和"增量更新记录";
- 重新标记被遗漏的存活对象(如并发阶段新增的E对象);
- 优化手段:多线程并行标记(默认线程数=CPU核心数+3)/2,加速重新标记过程。
阶段4:并发清除(Concurrent Sweep)- 并发执行
- 目标:清除老年代中未被标记的死亡对象,释放内存空间;
- 执行逻辑 :
- 恢复用户线程,与GC线程并发执行;
- 遍历老年代内存区域,删除所有未标记的对象;
- 仅记录可用内存块的起始地址和大小(维护"空闲内存链表"),不整理内存;
- 局限性 :
- 产生大量内存碎片(空闲内存块分散),后续大对象分配可能因找不到连续内存触发Full GC;
- 无法处理"浮动垃圾"(并发清除阶段用户线程新生成的垃圾),需留足内存空间(通常预留10%-20%老年代内存),否则触发"Concurrent Mode Failure"(CMF),降级为Serial Old收集器执行Full GC(停顿时间极长)。
2. G1收集器工作流程(基于"标记-整理+复制"混合算法)
G1的核心是"区域化筛选回收",通过动态选择回收收益最高的Region,平衡停顿时间与吞吐量,流程分为4个阶段:
阶段1:初始标记(Initial Mark)- STW
- 目标:标记GC Roots直接关联的对象,同时标记新生代Region中的存活对象;
- 执行逻辑 :
- 暂停用户线程(STW),耗时极短(通常<10ms);
- 扫描GC Roots,标记根对象直接指向的Eden/Survivor/Old Region对象;
- 通常与"新生代Minor GC"同步执行(借Minor GC的STW窗口完成初始标记),避免额外停顿;
- 依赖机制:OopMap+Remembered Set(RS),快速定位跨Region引用的根对象。
阶段2:并发标记(Concurrent Mark)- 并发执行
- 目标:遍历全域对象图,标记所有存活对象,计算每个Region的"存活密度"(存活对象占比);
- 执行逻辑 :
- 恢复用户线程,与GC线程并发执行;
- 从初始标记的对象出发,递归遍历所有Region的对象引用,标记存活对象;
- 处理"引用变化":采用SATB(Snapshot-At-The-Beginning) 机制------
- 并发标记开始时,创建"对象引用快照"(记录所有可达对象的初始状态);
- 当用户线程删除对象引用(如C→D改为C→null)时,通过写屏障(Write Barrier)记录旧引用D,确保D不会被误标为垃圾;
- 实时计算每个Region的"存活对象大小"和"回收所需时间",为后续筛选回收做准备;
- 特点:耗时较长(取决于堆大小),但CPU利用率更高,比CMS的并发标记更高效。
阶段3:最终标记(Final Mark)- STW
- 目标:处理并发标记遗留的SATB记录和RS更新,生成Region回收优先级列表;
- 执行逻辑 :
- 暂停用户线程(STW),停顿时间通常<100ms,支持多线程并行处理;
- 扫描并发标记期间积累的SATB记录,重新标记被遗漏的存活对象;
- 更新所有Region的Remembered Set,确保跨Region引用记录准确;
- 计算每个Region的"回收收益"(回收内存大小/回收耗时),按收益降序排序,生成"回收候选列表";
- 核心输出:确定哪些Region(通常是Eden+高收益Old Region)会被纳入下阶段回收。
阶段4:筛选回收(Evacuation)- STW
- 目标:根据预期停顿时间,选择收益最高的Region进行回收,同时整理内存(消除碎片);
- 执行逻辑 :
- 暂停用户线程(STW),停顿时间由
-XX:MaxGCPauseMillis控制(默认200ms); - 从"回收候选列表"中选择Region(数量动态调整,确保不超预期停顿时间),通常包含:
- 所有Eden Region(Minor GC逻辑);
- 部分高收益Old Region(Mixed GC逻辑,当老年代占比达阈值时触发);
- 采用复制算法回收:将选中Region中的存活对象复制到新的空Region(如Eden的存活对象复制到Survivor,Old的存活对象复制到新Old Region);
- 回收完成后,原Region标记为"空闲",可重新分配对象;
- 暂停用户线程(STW),停顿时间由
- 核心优势:复制算法天然消除内存碎片,且通过"收益优先"策略确保在有限停顿时间内回收最多内存。
三、核心机制(Remembered Set等)
两款收集器均依赖特殊机制解决"跨区域引用跟踪"和"并发一致性"问题,其中Remembered Set(RS)是核心。
1. CMS的核心机制
(1)卡表(Card Table)- 简化版RS
CMS通过卡表跟踪"新生代→老年代"的跨代引用,避免老年代GC时扫描整个新生代:
- 原理:将堆内存按512字节划分为一个"卡页(Card Page)",卡表是一个字节数组(每个元素对应一个卡页);
- 工作逻辑 :
- 当新生代对象引用老年代对象时(如Eden中的A→Old中的B),JVM通过"写屏障"将A所在卡页对应的卡表项标记为"脏"(值设为1);
- CMS执行老年代GC时,仅扫描卡表中"脏"的卡页,无需扫描整个新生代,大幅减少扫描范围;
- 局限性:仅支持"新生代→老年代"单向引用跟踪,且卡页粒度较粗(512字节),可能存在"假阳性"扫描(卡页内部分对象无跨代引用,但仍需扫描)。
(2)增量更新(Incremental Update)
解决并发标记阶段"引用新增"导致的漏标问题:
- 场景:并发标记期间,用户线程新增引用(如C→E,E是未被标记的存活对象);
- 机制:当引用新增时,通过写屏障标记E为"待重新扫描",确保重新标记阶段会扫描E及其引用链,避免E被漏标;
- 特点:偏向"确保存活对象不被漏标",可能导致少量垃圾被误标为存活(后续GC再回收)。
(3)Concurrent Mode Failure(CMF)处理
CMS的致命缺陷机制:
- 触发条件:并发清除阶段,老年代内存不足(无法容纳用户线程新生成的对象或浮动垃圾);
- 后果 :CMS停止并发回收,降级为Serial Old收集器(单线程"标记-整理")执行Full GC,停顿时间极长(秒级);
- 规避手段 :通过
-XX:CMSInitiatingOccupancyFraction提前触发CMS(如设为70,表示老年代占用70%时触发),预留内存空间。
2. G1的核心机制
(1)Remembered Set(RS)- 复杂版跨Region引用跟踪
G1通过RS解决"跨Region引用"问题(每个Region都有一个RS),比CMS的卡表更精细:
- 原理:每个Region的RS是一个"哈希表",键为"引用源Region的ID",值为"引用源Region中的卡页列表";
- 工作逻辑 :
- 当Region X中的对象引用Region Y中的对象时(X和Y可任意角色,如Eden→Old、Old→Eden),通过写屏障记录"X的卡页→Y的RS";
- G1执行GC时,扫描Region Y的RS,即可找到所有跨Region引用Y的对象,无需扫描全域;
- 优势 :
- 支持"任意Region间"的双向引用跟踪(CMS仅支持新生代→老年代);
- 卡页粒度(512字节)+ Region级过滤,扫描效率远高于CMS。
(2)SATB(Snapshot-At-The-Beginning)
解决并发标记阶段"引用删除"导致的误标问题:
- 场景:并发标记期间,用户线程删除引用(如C→D改为C→null,D是存活对象);
- 机制 :
- 并发标记开始时,创建"对象引用快照"(记录所有可达对象的初始状态);
- 当引用删除时,通过写屏障记录"旧引用D",确保并发标记会将D视为存活对象(即使引用已删除);
- 最终标记阶段,结合RS和SATB记录,确认D是否真的存活;
- 优势:相比CMS的增量更新,SATB减少了重新标记阶段的工作量(无需扫描新增引用链),更适合大堆场景。
(3)Mixed GC触发机制
G1的"全域回收"核心机制,区别于CMS的"仅老年代回收":
- 触发条件 :老年代Region占比达到
-XX:InitiatingHeapOccupancyPercent(默认45%); - 逻辑:筛选回收阶段同时回收"所有Eden Region + 部分高收益Old Region",避免传统Full GC;
- 优势:逐步回收老年代垃圾,避免一次性Full GC的长停顿,是G1支持大堆的关键。
(4)Humongous Region处理
G1专门为大对象设计的机制:
- 触发条件 :对象大小超过Region的50%(通过
-XX:G1HeapRegionSize控制); - 处理逻辑 :
- 大对象直接分配到连续的Humongous Region(避免跨Region引用);
- 回收时,若Humongous Region无引用,可单独回收(无需等待Mixed GC);
- 优势:解决了CMS中大对象直接进入老年代导致的碎片问题。
四、虚拟机参数配置
两款收集器的参数配置差异显著,直接影响GC性能,以下为核心参数(基于JDK 8/11):
1. CMS核心参数
| 参数名称 | 含义 | 默认值 | 推荐配置 |
|---|---|---|---|
-XX:+UseConcMarkSweepGC |
启用CMS收集器(需配合新生代收集器) | 禁用 | 必须显式开启 |
-XX:+UseParNewGC |
启用ParNew作为新生代收集器(与CMS搭配) | 禁用(开启CMS时自动启用) | 开启CMS后无需额外配置 |
-XX:CMSInitiatingOccupancyFraction |
老年代占用率达到多少时触发CMS | 92% | 70-80%(避免CMF) |
-XX:+CMSFullGCsBeforeCompaction |
执行多少次CMS后触发一次内存整理(解决碎片) | 0(每次CMS后都整理) | 3-5(减少整理频率) |
-XX:CMSMaxAbortablePrecleanTime |
并发预清理阶段的最大耗时(避免停顿过长) | 5000ms | 1000-2000ms |
-XX:+CMSScavengeBeforeRemark |
重新标记前先执行一次Minor GC(减少重新标记范围) | 禁用 | 启用(减少STW时间) |
-XX:ParallelCMSThreads |
CMS并发线程数 | (CPU核心数+3)/2 | 建议不超过CPU核心数的50%(避免抢占业务线程CPU) |
2. G1核心参数
| 参数名称 | 含义 | 默认值 | 推荐配置 |
|---|---|---|---|
-XX:+UseG1GC |
启用G1收集器 | JDK 9+默认,JDK 8需显式开启 | JDK 8需显式开启 |
-XX:G1HeapRegionSize |
Region大小(需为2的幂,范围1-32MB) | 自动计算(堆大小/2048) | 堆<8GB设为1-2MB,堆>16GB设为4-8MB |
-XX:MaxGCPauseMillis |
G1的预期最大停顿时间 | 200ms | 根据业务需求调整(如50ms、100ms,不可过小) |
-XX:G1NewSizePercent |
新生代最小占比 | 5% | 10-15%(保证新生代有足够空间) |
-XX:G1MaxNewSizePercent |
新生代最大占比 | 60% | 40-50%(避免新生代过大导致停顿时间超预期) |
-XX:InitiatingHeapOccupancyPercent |
触发Mixed GC的老年代占比阈值 | 45% | 40-50%(堆大时可适当提高) |
-XX:G1MixedGCCountTarget |
一次Mixed GC周期内回收的Old Region次数 | 8 | 4-8(控制Mixed GC频率) |
-XX:G1HeapWastePercent |
允许的堆内存浪费百分比(用于筛选回收) | 5% | 5-10%(平衡回收收益与停顿时间) |
五、GC执行时间与方式
GC执行时间(停顿时间、总耗时)和方式(STW/并发、单线程/多线程)直接决定应用的响应性和吞吐量。
1. CMS的GC执行特性
| GC类型 | 执行方式 | 停顿时间(STW) | 总耗时 | 触发场景 |
|---|---|---|---|---|
| 初始标记 | 多线程STW | 10-50ms | 同停顿时间 | CMS开始时 |
| 并发标记 | 多线程并发(与用户线程并行) | 0ms | 几百ms-几秒(取决于老年代大小) | 初始标记后 |
| 重新标记 | 多线程STW | 50-200ms | 同停顿时间 | 并发标记后 |
| 并发清除 | 多线程并发 | 0ms | 几百ms-几秒 | 重新标记后 |
| Full GC(CMF触发) | 单线程STW(Serial Old) | 秒级(如1-10秒) | 同停顿时间 | 并发清除阶段内存不足 |
关键结论:
- CMS的并发阶段总耗时较长(占GC周期的80%以上),但无停顿;
- STW总时间较短(通常<300ms),适合低延迟场景;
- 致命风险是Full GC停顿极长,需通过参数严格规避。
2. G1的GC执行特性
| GC类型 | 执行方式 | 停顿时间(STW) | 总耗时 | 触发场景 |
|---|---|---|---|---|
| 初始标记 | 多线程STW(与Minor GC同步) | <10ms | 同停顿时间 | Minor GC或Mixed GC开始时 |
| 并发标记 | 多线程并发 | 0ms | 几秒-几十秒(取决于堆大小) | 初始标记后 |
| 最终标记 | 多线程STW | 50-100ms | 同停顿时间 | 并发标记后 |
| 筛选回收(Minor GC) | 多线程STW | <50ms(受MaxGCPauseMillis控制) | 同停顿时间 | Eden Region满 |
| 筛选回收(Mixed GC) | 多线程STW | <200ms(受MaxGCPauseMillis控制) | 同停顿时间 | 老年代占比达阈值 |
| Full GC(极端情况) | 多线程STW(G1的Full GC是"标记-整理") | 秒级(比CMS的Full GC短) | 同停顿时间 | 堆内存耗尽(如内存泄漏) |
关键结论:
- G1的停顿时间可控(通过MaxGCPauseMillis),实际停顿通常接近预期值;
- 无"并发清除"阶段(筛选回收阶段同步完成回收),避免浮动垃圾导致的内存不足;
- 即使触发Full GC,停顿时间也比CMS短(多线程执行),风险更低;
- 大堆场景下(>16GB),总GC耗时比CMS更短(RS机制提升扫描效率)。
六、核心差异总结
| 维度 | CMS | G1 |
|---|---|---|
| 工作区域 | 仅老年代(依赖其他收集器处理新生代) | 全域Region(可同时处理新生代+老年代) |
| 回收对象 | 仅老年代死亡对象 | 全域死亡对象(Eden/Survivor/Old/Humongous) |
| 核心算法 | 标记-清除(产生碎片) | 标记-整理+复制(无碎片) |
| 关键机制 | 卡表+增量更新+CMF | RS+SATB+Mixed GC+Humongous处理 |
| 停顿时间 | 不可控(STW总时间<300ms,但可能触发长Full GC) | 可控(通过MaxGCPauseMillis,通常<200ms) |
| 堆大小支持 | 适合小堆(<4GB) | 适合大堆(>4GB,最高支持数TB) |
| 内存碎片 | 严重(需定期Full GC整理) | 几乎无(复制算法天然整理) |
| 适用场景 | 低延迟优先(如金融交易、实时接口) | 吞吐量与延迟平衡(如电商、企业级应用) |
| 发展趋势 | JDK 9后被G1取代(逐步废弃) | JDK 9+默认收集器(主流选择) |
最终建议:JDK 8及以上版本,优先选择G1;仅在JDK 7及以下、小堆(<4GB)且对延迟要求极高的场景,才考虑CMS。