Jvm资料整理

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. 特性

  • 线程私有 ,行为与虚拟机栈类似,可能抛出StackOverflowErrorOutOfMemoryError

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.Stringjava.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为例)
  1. 委托父类:应用程序类加载器收到请求后,不自己加载,而是委托给父类(扩展类加载器)。
  2. 逐层上抛:扩展类加载器再委托给父类(启动类加载器)。
  3. 父类尝试加载 :启动类加载器在自己的加载范围(JAVA_HOME/lib)中查找com.example.MyClass,未找到(非核心类),返回失败。
  4. 父类失败,子类加载 :扩展类加载器在自己的范围(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/classesWEB-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)找到字节码文件,读取数据并在内存中生成初步结构。

具体步骤:
  1. 定位字节码

    类加载器根据全限定名查找字节码来源,常见来源包括:

    • 本地文件系统(.class 文件);
    • 网络(如 Applet 小程序);
    • 内存(如动态生成的字节码,CGLib 动态代理);
    • 归档文件(如 JARWAR 包)。
  2. 读取字节流

    将字节码文件的二进制数据读入内存(如通过 ClassLoader.findClass() 方法读取)。

  3. 生成运行时数据结构

    将字节流转换为 JVM 可识别的内部数据结构(存储在方法区,包含类的版本、字段、方法、接口等元信息)。

  4. 创建 Class 对象

    在堆内存中生成一个代表该类的 java.lang.Class 对象,作为方法区中类元数据的"访问入口"(后续通过该对象操作类信息)。

二、验证(Verification):确保字节码安全合法

核心任务:校验字节码是否符合 JVM 规范,防止恶意或错误的字节码危害 JVM 运行。

验证内容(四阶段):
  1. 文件格式验证

    检查字节流是否符合 .class 文件格式规范,例如:

    • 魔数开头(0xCAFEBABE,JVM 识别 .class 文件的标识);
    • 主版本号、次版本号是否在当前 JVM 支持范围内(如 JDK 8 支持版本号 52.0 及以下);
    • 常量池格式是否合法(如常量类型、索引是否有效)。
  2. 元数据验证

    校验类的元数据(语义)是否合法,例如:

    • 类是否有父类(除 java.lang.Object 外,所有类必须有父类);
    • 类是否继承了不允许被继承的类(如被 final 修饰的类);
    • 字段、方法是否与父类冲突(如覆盖父类的 final 方法)。
  3. 字节码验证

    最复杂的阶段,校验方法体的字节码指令是否符合逻辑,例如:

    • 指令操作数类型是否匹配(如对 int 类型执行 long 运算);
    • 跳转指令是否指向合理的代码位置(不越界);
    • 方法体是否有正确的返回(如非 void 方法是否有返回值)。
  4. 符号引用验证

    校验常量池中的符号引用(如类名、方法名)是否可被解析,例如:

    • 符号引用指向的类、字段、方法是否存在;
    • 当前类是否有访问符号引用的权限(如访问 private 成员)。

三、准备(Preparation):为静态变量分配内存并设默认值

核心任务 :为类的 静态变量(static 变量) 分配内存,并设置 默认初始值(非显式赋值)。

关键细节:
  1. 分配内存的对象

    仅针对 静态变量(属于类级别,存储在方法区),实例变量(对象成员变量)的内存分配在对象实例化时(在堆中)进行。

  2. 默认初始值规则

    基本数据类型按"零值"初始化,引用类型为 null

    类型 默认值 示例(static int a = 10;
    int 0 准备阶段 a = 010 在初始化阶段赋值)
    boolean false static boolean flag;false
    Object null static User user;null
  3. 特殊情况:static final 常量

    如果静态变量被 final 修饰(常量),则在准备阶段直接赋 显式值(编译期已确定):

    java 复制代码
    public static final int MAX = 100; // 准备阶段直接赋值 100,而非 0

四、解析(Resolution):将符号引用转为直接引用

核心任务 :将常量池中的 符号引用 替换为 直接引用(内存地址),建立字节码与内存数据的关联。

关键概念:
  • 符号引用 :编译期生成的字符串标识,如类名 com.example.User、方法名 toString、字段名 name(不依赖内存布局,仅表示"谁")。
  • 直接引用:指向内存中实际位置的指针或偏移量(如对象的内存地址、方法在方法区的偏移量,依赖内存布局)。
解析内容:
  1. 类或接口解析 :将类的符号引用(如 Lcom/example/User;)转为直接引用(指向方法区中该类的元数据地址)。
  2. 字段解析:找到字段所属的类,将字段名转为该字段在类元数据中的偏移量。
  3. 方法解析:找到方法所属的类或接口,将方法名转为该方法在方法区的内存地址。
  4. 接口方法解析:类似方法解析,但针对接口方法。
解析时机:

JVM 允许 "按需延迟解析":即不是在解析阶段一次性完成所有符号引用的转换,而是在首次使用某个符号引用时才解析(如首次调用方法时解析该方法的引用)。

五、初始化(Initialization):执行类的初始化代码

核心任务 :执行类的 初始化逻辑,包括静态变量显式赋值和静态代码块执行,是类加载过程的最后一步。

触发条件(主动使用):

只有当类被"主动使用"时,才会触发初始化(被动使用不会,如仅通过子类引用父类的静态字段)。主动使用场景包括:

  1. 创建类的实例(new User());
  2. 调用类的静态方法(User.print());
  3. 访问类的静态字段(User.countfinal 常量除外);
  4. 反射调用(Class.forName("com.example.User"));
  5. 初始化子类时,父类未初始化则先初始化父类;
  6. JVM 启动时,执行 main 方法的类(启动类)。
执行逻辑:
  1. 静态变量显式赋值 :执行静态变量的赋值语句(如 static int a = 10; 中的 a = 10)。

  2. 静态代码块执行 :按代码编写顺序执行 static {} 中的语句。

    示例:

    java 复制代码
    public class InitDemo {
        static int a = 10; // 显式赋值(初始化阶段执行)
        static {
            System.out.println("静态代码块执行"); // 初始化阶段执行
            a = 20;
        }
    }
    // 初始化后,a 的值为 20(静态代码块后执行,覆盖之前的 10)
  3. 线程安全性 :JVM 保证类的初始化在多线程环境下是 线程安全的(仅一个线程执行初始化,其他线程阻塞等待)。

总结:类加载流程的核心特点

  1. 顺序性:加载、验证、准备、初始化四个阶段按顺序执行(解析阶段可能在初始化后延迟执行)。
  2. 唯一性:一个类仅被加载一次(由类加载器和全限定名共同确定唯一性)。
  3. 安全性:验证阶段确保字节码合法,双亲委派机制(类加载器)防止核心类被篡改。

理解这五个阶段,能清晰掌握类从字节码到内存可用状态的完整过程,也是分析类加载相关问题(如静态变量初始化顺序、类冲突)的基础。

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直接关联的对象(根对象),不遍历对象图;
  • 执行逻辑
    1. 暂停所有用户线程(STW),耗时极短(通常10-50ms);
    2. 扫描GC Roots(如虚拟机栈引用、方法区静态属性引用、本地方法栈引用);
    3. 标记根对象直接指向的老年代对象(如A是根对象,A→B,仅标记B);
  • 依赖机制:OopMap(JVM维护的对象引用映射表),快速定位GC Roots位置,避免全堆扫描。
阶段2:并发标记(Concurrent Mark)- 并发执行
  • 目标:从初始标记的根对象出发,遍历整个老年代对象图,标记所有存活对象;
  • 执行逻辑
    1. 恢复用户线程,与GC线程并发执行;
    2. 从初始标记的对象(如B)开始,递归遍历其引用的对象(B→C→D...),标记所有可达对象;
    3. 处理"引用变化":采用增量更新(Incremental Update) 机制------当用户线程修改对象引用(如C→D改为C→E)时,标记E为"待重新扫描",确保E不会被漏标;
  • 特点:耗时最长(通常几百ms到几秒),但不阻塞用户线程,对业务响应影响小。
阶段3:重新标记(Remark)- STW
  • 目标:修正并发标记阶段因用户线程运行导致的标记偏差,确保标记准确性;
  • 执行逻辑
    1. 再次暂停用户线程(STW),停顿时间比初始标记长(通常50-200ms),但远短于并发标记;
    2. 扫描并发标记期间记录的"脏卡(Dirty Card)"(卡表中标记为"修改过"的项)和"增量更新记录";
    3. 重新标记被遗漏的存活对象(如并发阶段新增的E对象);
  • 优化手段:多线程并行标记(默认线程数=CPU核心数+3)/2,加速重新标记过程。
阶段4:并发清除(Concurrent Sweep)- 并发执行
  • 目标:清除老年代中未被标记的死亡对象,释放内存空间;
  • 执行逻辑
    1. 恢复用户线程,与GC线程并发执行;
    2. 遍历老年代内存区域,删除所有未标记的对象;
    3. 仅记录可用内存块的起始地址和大小(维护"空闲内存链表"),不整理内存;
  • 局限性
    • 产生大量内存碎片(空闲内存块分散),后续大对象分配可能因找不到连续内存触发Full GC;
    • 无法处理"浮动垃圾"(并发清除阶段用户线程新生成的垃圾),需留足内存空间(通常预留10%-20%老年代内存),否则触发"Concurrent Mode Failure"(CMF),降级为Serial Old收集器执行Full GC(停顿时间极长)。

2. G1收集器工作流程(基于"标记-整理+复制"混合算法)

G1的核心是"区域化筛选回收",通过动态选择回收收益最高的Region,平衡停顿时间与吞吐量,流程分为4个阶段:

阶段1:初始标记(Initial Mark)- STW
  • 目标:标记GC Roots直接关联的对象,同时标记新生代Region中的存活对象;
  • 执行逻辑
    1. 暂停用户线程(STW),耗时极短(通常<10ms);
    2. 扫描GC Roots,标记根对象直接指向的Eden/Survivor/Old Region对象;
    3. 通常与"新生代Minor GC"同步执行(借Minor GC的STW窗口完成初始标记),避免额外停顿;
  • 依赖机制:OopMap+Remembered Set(RS),快速定位跨Region引用的根对象。
阶段2:并发标记(Concurrent Mark)- 并发执行
  • 目标:遍历全域对象图,标记所有存活对象,计算每个Region的"存活密度"(存活对象占比);
  • 执行逻辑
    1. 恢复用户线程,与GC线程并发执行;
    2. 从初始标记的对象出发,递归遍历所有Region的对象引用,标记存活对象;
    3. 处理"引用变化":采用SATB(Snapshot-At-The-Beginning) 机制------
      • 并发标记开始时,创建"对象引用快照"(记录所有可达对象的初始状态);
      • 当用户线程删除对象引用(如C→D改为C→null)时,通过写屏障(Write Barrier)记录旧引用D,确保D不会被误标为垃圾;
    4. 实时计算每个Region的"存活对象大小"和"回收所需时间",为后续筛选回收做准备;
  • 特点:耗时较长(取决于堆大小),但CPU利用率更高,比CMS的并发标记更高效。
阶段3:最终标记(Final Mark)- STW
  • 目标:处理并发标记遗留的SATB记录和RS更新,生成Region回收优先级列表;
  • 执行逻辑
    1. 暂停用户线程(STW),停顿时间通常<100ms,支持多线程并行处理;
    2. 扫描并发标记期间积累的SATB记录,重新标记被遗漏的存活对象;
    3. 更新所有Region的Remembered Set,确保跨Region引用记录准确;
    4. 计算每个Region的"回收收益"(回收内存大小/回收耗时),按收益降序排序,生成"回收候选列表";
  • 核心输出:确定哪些Region(通常是Eden+高收益Old Region)会被纳入下阶段回收。
阶段4:筛选回收(Evacuation)- STW
  • 目标:根据预期停顿时间,选择收益最高的Region进行回收,同时整理内存(消除碎片);
  • 执行逻辑
    1. 暂停用户线程(STW),停顿时间由-XX:MaxGCPauseMillis控制(默认200ms);
    2. 从"回收候选列表"中选择Region(数量动态调整,确保不超预期停顿时间),通常包含:
      • 所有Eden Region(Minor GC逻辑);
      • 部分高收益Old Region(Mixed GC逻辑,当老年代占比达阈值时触发);
    3. 采用复制算法回收:将选中Region中的存活对象复制到新的空Region(如Eden的存活对象复制到Survivor,Old的存活对象复制到新Old Region);
    4. 回收完成后,原Region标记为"空闲",可重新分配对象;
  • 核心优势:复制算法天然消除内存碎片,且通过"收益优先"策略确保在有限停顿时间内回收最多内存。

三、核心机制(Remembered Set等)

两款收集器均依赖特殊机制解决"跨区域引用跟踪"和"并发一致性"问题,其中Remembered Set(RS)是核心。

1. CMS的核心机制

(1)卡表(Card Table)- 简化版RS

CMS通过卡表跟踪"新生代→老年代"的跨代引用,避免老年代GC时扫描整个新生代:

  • 原理:将堆内存按512字节划分为一个"卡页(Card Page)",卡表是一个字节数组(每个元素对应一个卡页);
  • 工作逻辑
    1. 当新生代对象引用老年代对象时(如Eden中的A→Old中的B),JVM通过"写屏障"将A所在卡页对应的卡表项标记为"脏"(值设为1);
    2. 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中的卡页列表";
  • 工作逻辑
    1. 当Region X中的对象引用Region Y中的对象时(X和Y可任意角色,如Eden→Old、Old→Eden),通过写屏障记录"X的卡页→Y的RS";
    2. G1执行GC时,扫描Region Y的RS,即可找到所有跨Region引用Y的对象,无需扫描全域;
  • 优势
    • 支持"任意Region间"的双向引用跟踪(CMS仅支持新生代→老年代);
    • 卡页粒度(512字节)+ Region级过滤,扫描效率远高于CMS。
(2)SATB(Snapshot-At-The-Beginning)

解决并发标记阶段"引用删除"导致的误标问题:

  • 场景:并发标记期间,用户线程删除引用(如C→D改为C→null,D是存活对象);
  • 机制
    1. 并发标记开始时,创建"对象引用快照"(记录所有可达对象的初始状态);
    2. 当引用删除时,通过写屏障记录"旧引用D",确保并发标记会将D视为存活对象(即使引用已删除);
    3. 最终标记阶段,结合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控制);
  • 处理逻辑
    1. 大对象直接分配到连续的Humongous Region(避免跨Region引用);
    2. 回收时,若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。

相关推荐
如果丶可以坑3 小时前
maven无法获取依赖问题
java·maven·1024程序员节
Arlene3 小时前
JVM 的垃圾回收机制
jvm
羊村里的大灰狼3 小时前
Windows下载安装配置rabbitmq
1024程序员节
B站计算机毕业设计之家3 小时前
Python手势识别检测系统 基于MediaPipe的改进SSD算法 opencv+mediapipe 深度学习 大数据 (建议收藏)✅
python·深度学习·opencv·计算机视觉·1024程序员节
兜兜风d'3 小时前
RabbitMQ 持久性详解
spring boot·分布式·rabbitmq·1024程序员节
MeowKnight9584 小时前
【Linux】常见的系统调用 函数和功能简单总结
linux·1024程序员节
游戏开发爱好者84 小时前
iOS 26 App 开发阶段性能优化 从多工具协作到数据驱动的实战体系
android·ios·小程序·uni-app·iphone·webview·1024程序员节
爱隐身的官人4 小时前
Ubuntu安装开源堡垒机JumpServer
linux·ubuntu·堡垒机·1024程序员节
杨筱毅4 小时前
【底层机制】Linux内核4.10版本的完整存储栈架构讲解--用户空间到物理设备完整IO路径
linux·架构·1024程序员节·底层机制