8gu-JVM

目录

JVM结构

JVM结构图

Java双亲委派机制与SPI详解

双亲委派机制

什么是双亲委派机制

双亲委派机制(Parent Delegation Model)是Java虚拟机加载类时采用的一种工作机制。它规定了类加载器在加载类时的委派关系和加载顺序。

类加载器层次结构

Java中有三个主要的类加载器,形成层次结构:

Bootstrap ClassLoader (启动类加载器)
    ↑
Extension ClassLoader (扩展类加载器)
    ↑
Application ClassLoader (应用程序类加载器)
    ↑
Custom ClassLoader (自定义类加载器)

1. Bootstrap ClassLoader(启动类加载器)

  • 实现:由C++实现,不是Java类
  • 加载路径$JAVA_HOME/jre/lib目录下的核心类库
  • 加载内容rt.jarresources.jar等核心类
  • 例子java.lang.Objectjava.lang.String

2. Extension ClassLoader(扩展类加载器)

  • 实现sun.misc.Launcher$ExtClassLoader
  • 加载路径$JAVA_HOME/jre/lib/ext目录
  • 加载内容:Java扩展类库
  • 父加载器:Bootstrap ClassLoader

3. Application ClassLoader(应用程序类加载器)

  • 实现sun.misc.Launcher$AppClassLoader
  • 加载路径:classpath指定的路径
  • 加载内容:用户自定义的类和第三方库
  • 父加载器:Extension ClassLoader

双亲委派的工作流程

当一个类加载器收到类加载请求时,遵循以下步骤:

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 {
                // 2. 委派给父类加载器
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父类加载器无法加载
            }

            if (c == null) {
                // 3. 父类加载器无法加载,自己尝试加载
                long t1 = System.nanoTime();
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

详细流程:

  1. 检查缓存:首先检查该类是否已经被当前类加载器加载过
  2. 向上委派:如果没有加载过,委派给父类加载器
  3. 递归委派:父类加载器重复这个过程,直到Bootstrap ClassLoader
  4. 尝试加载:Bootstrap ClassLoader尝试加载,如果找不到类文件,返回失败
  5. 向下尝试:父类加载器加载失败后,子类加载器尝试自己加载
  6. 抛出异常:如果所有类加载器都无法加载,抛出ClassNotFoundException

双亲委派的优势

1. 避免类的重复加载

// 同一个类只会被加载一次
String str1 = new String("hello");
String str2 = new String("world");
// str1.getClass() == str2.getClass() 返回true

2. 保证核心类库的安全性

// 即使用户自定义了java.lang.String类,也不会被加载
// 因为Bootstrap ClassLoader会优先加载核心库中的String类
public class String {
    // 这个类永远不会被加载
}

3. 保证类的唯一性

  • 同一个类文件,被同一个类加载器加载,在JVM中是唯一的
  • 类的唯一性由类加载器和类的全限定名共同确定

示例演示

public class ClassLoaderDemo {
    public static void main(String[] args) {
        // 获取当前类的类加载器
        ClassLoader classLoader = ClassLoaderDemo.class.getClassLoader();
        System.out.println("当前类的类加载器: " + classLoader);

        // 获取父类加载器
        ClassLoader parent = classLoader.getParent();
        System.out.println("父类加载器: " + parent);

        // 获取祖父类加载器
        ClassLoader grandParent = parent.getParent();
        System.out.println("祖父类加载器: " + grandParent); // null,表示Bootstrap ClassLoader

        // 核心类的类加载器
        ClassLoader stringLoader = String.class.getClassLoader();
        System.out.println("String类的类加载器: " + stringLoader); // null
    }
}

输出结果:

当前类的类加载器: sun.misc.Launcher$AppClassLoader@18b4aac2
父类加载器: sun.misc.Launcher$ExtClassLoader@1540e19d
祖父类加载器: null
String类的类加载器: null

SPI机制详解

SPI机制的背景问题

双亲委派的限制

在双亲委派机制下,类加载遵循"向上委派,向下加载"的原则:

Bootstrap ClassLoader (加载核心类库)
    ↓ 只能向下调用
Extension ClassLoader
    ↓ 只能向下调用
Application ClassLoader (加载应用类)

关键问题:父类加载器加载的类无法直接访问子类加载器加载的类!

SPI场景的具体矛盾

以JDBC为例说明问题

  1. 核心接口在上层
// 这些类由Bootstrap ClassLoader加载,位于rt.jar中
java.sql.Driver          // 接口
java.sql.DriverManager   // 管理类
java.sql.Connection      // 接口
  1. 具体实现在下层
// 这些类由Application ClassLoader加载,位于应用的classpath中
com.mysql.cj.jdbc.Driver           // MySQL实现
oracle.jdbc.driver.OracleDriver    // Oracle实现
  1. 问题出现
public class DriverManager {
    // 这个类由Bootstrap ClassLoader加载
    // 但它需要加载MySQL驱动类,而MySQL驱动在应用classpath中
    // Bootstrap ClassLoader无法看到Application ClassLoader加载的类!

    public static Connection getConnection(String url) {
        // 如何加载com.mysql.cj.jdbc.Driver?
        // Bootstrap ClassLoader找不到这个类!
    }
}

传统双亲委派无法解决的原因

类加载器的可见性规则
public class ClassLoaderVisibility {
    public static void main(String[] args) throws Exception {
        // Bootstrap ClassLoader (null)
        ClassLoader bootstrap = String.class.getClassLoader();

        // Application ClassLoader
        ClassLoader app = ClassLoaderVisibility.class.getClassLoader();

        // Bootstrap ClassLoader无法看到Application ClassLoader加载的类
        try {
            // 这会失败!Bootstrap ClassLoader找不到用户类
            Class.forName("com.mysql.cj.jdbc.Driver", true, bootstrap);
        } catch (ClassNotFoundException e) {
            System.out.println("Bootstrap ClassLoader无法加载MySQL驱动");
        }

        // Application ClassLoader可以看到Bootstrap ClassLoader加载的类
        Class.forName("java.lang.String", true, app); // 这会成功
    }
}

SPI如何打破双亲委派

1. 线程上下文类加载器(Thread Context ClassLoader)

Java引入了线程上下文类加载器来解决这个问题:

public class ThreadContextClassLoaderDemo {
    public static void main(String[] args) {
        // 获取当前线程的上下文类加载器
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        System.out.println("线程上下文类加载器: " + contextClassLoader);

        // 通常是Application ClassLoader
        // 这样Bootstrap ClassLoader加载的类就可以通过这个引用
        // 访问到Application ClassLoader加载的类了
    }
}

2. DriverManager的实际实现

让我们看看DriverManager是如何实现的:

public class DriverManager {
    // 静态初始化块
    static {
        loadInitialDrivers();
    }

    private static void loadInitialDrivers() {
        // 关键:使用ServiceLoader和线程上下文类加载器
        ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
        Iterator<Driver> driversIterator = loadedDrivers.iterator();

        try {
            while(driversIterator.hasNext()) {
                driversIterator.next(); // 这里会触发驱动类的加载
            }
        } catch(Throwable t) {
            // 处理异常
        }
    }
}

3. ServiceLoader的核心实现

public final class ServiceLoader<S> implements Iterable<S> {

    public static <S> ServiceLoader<S> load(Class<S> service) {
        // 关键:获取线程上下文类加载器
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }

    public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) {
        return new ServiceLoader<>(service, loader);
    }

    private ServiceLoader(Class<S> svc, ClassLoader cl) {
        service = Objects.requireNonNull(svc, "Service interface cannot be null");
        // 使用传入的类加载器(通常是Application ClassLoader)
        loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
        reload();
    }

    // 加载服务实现
    private boolean hasNextService() {
        if (nextName != null) {
            return true;
        }
        if (configs == null) {
            try {
                // 在META-INF/services/目录下查找服务配置文件
                String fullName = PREFIX + service.getName();
                if (loader == null)
                    configs = ClassLoader.getSystemResources(fullName);
                else
                    configs = loader.getResources(fullName); // 使用指定的类加载器
            } catch (IOException x) {
                fail(service, "Error locating configuration files", x);
            }
        }
        // ... 解析配置文件,获取实现类名称
    }

    private S nextService() {
        if (!hasNextService())
            throw new NoSuchElementException();
        String cn = nextName;
        nextName = null;
        Class<?> c = null;
        try {
            // 关键:使用指定的类加载器加载实现类
            c = Class.forName(cn, false, loader);
        } catch (ClassNotFoundException x) {
            fail(service, "Provider " + cn + " not found");
        }
        // ... 创建实例并返回
    }
}

完整的SPI工作流程

1. 配置文件

# 文件位置:META-INF/services/java.sql.Driver
# 文件内容:
com.mysql.cj.jdbc.Driver
oracle.jdbc.driver.OracleDriver

2. 加载过程

// 步骤1:DriverManager由Bootstrap ClassLoader加载
// 步骤2:DriverManager.loadInitialDrivers()被调用
// 步骤3:ServiceLoader.load(Driver.class)被调用
// 步骤4:ServiceLoader获取线程上下文类加载器(Application ClassLoader)
// 步骤5:使用Application ClassLoader读取META-INF/services/java.sql.Driver
// 步骤6:使用Application ClassLoader加载com.mysql.cj.jdbc.Driver
// 步骤7:创建驱动实例并注册到DriverManager

为什么这打破了双亲委派

正常的双亲委派流程:
请求加载类 → 委派给父类加载器 → 父类加载器尝试加载 → 失败后子类加载器加载
SPI的加载流程:
Bootstrap ClassLoader加载的DriverManager
    ↓
直接使用Application ClassLoader加载驱动实现类
    ↓
绕过了Extension ClassLoader,违反了"向上委派"的原则

其他SPI示例

1. JNDI服务
// javax.naming.spi.NamingManager (Bootstrap ClassLoader加载)
// 需要加载应用提供的InitialContextFactory实现
2. XML解析器
// javax.xml.parsers.DocumentBuilderFactory (Bootstrap ClassLoader加载)
// 需要加载具体的XML解析器实现(如Xerces)
3. 日志框架
// java.util.logging.LogManager (Bootstrap ClassLoader加载)
// 需要加载应用提供的日志实现

其他打破双亲委派的场景

1. 热部署和热替换

  • 场景:应用服务器需要在不重启的情况下更新类
  • 实现:自定义类加载器,绕过父类加载器直接加载新版本的类
  • 例子:Tomcat、JBoss等应用服务器的热部署功能

2. 代码热替换

  • 场景:开发工具需要动态替换正在运行的代码
  • 实现:创建新的类加载器实例加载修改后的类
  • 例子:IDE的调试模式、JRebel等工具

3. 模块化系统

  • 场景:不同模块需要加载不同版本的同一个类
  • 实现:每个模块使用独立的类加载器
  • 例子:OSGi框架、Java 9的模块系统

4. 应用服务器的类隔离

  • 场景:同一个服务器上部署多个应用,需要类隔离
  • 实现:为每个应用创建独立的类加载器
  • 例子:Tomcat的WebApp ClassLoader

5. 自定义类加载需求

  • 场景:从数据库、网络等特殊位置加载类
  • 实现:重写findClass()方法而不是loadClass()方法
  • 例子:从加密文件中加载类、网络类加载器

实现方式

打破双亲委派通常通过以下方式:

  1. 重写loadClass()方法:改变委派逻辑
  2. 使用线程上下文类加载器:让父类加载器使用子类加载器
  3. 创建平行的类加载器层次:不遵循严格的父子关系

注意事项

  • 打破双亲委派可能导致类型转换异常
  • 需要仔细处理类的唯一性和一致性
  • 可能影响JVM的安全性和稳定性

总结

SPI机制打破双亲委派的本质原因是:

  1. 架构需要:核心接口在上层,具体实现在下层
  2. 可见性限制:父类加载器无法看到子类加载器加载的类
  3. 解决方案:通过线程上下文类加载器,让上层代码能够访问下层实现
  4. 打破方式:绕过正常的委派链,直接使用指定的类加载器

这种设计虽然打破了双亲委派,但解决了Java核心库与应用代码之间的解耦问题,是一种必要的妥协。双亲委派机制是Java安全模型的重要组成部分,确保了Java程序的稳定性和安全性,而在特定场景下的打破也是为了满足实际的技术需求。

GC回收器

CMS与G1回收时许对比图

好的,我们来详细介绍 ZGC 垃圾回收器,并提供其结构示意和与 G1、CMS 的区别。

ZGC 详解:下一代低延迟垃圾回收器

ZGC (The Z Garbage Collector) 是一款可扩展的低延迟垃圾回收器,于 JDK 11 中作为实验性功能引入,并在 JDK 15 中正式宣布为生产就绪。 ZGC 的核心设计目标是在尽可能不影响系统吞吐量的前提下,实现垃圾收集的停顿时间不超过10毫秒,并且这个停顿时间不会随着堆内存或存活对象大小的增加而增加。 这使得 ZGC 非常适合需要低延迟和处理海量内存(TB级别)的应用场景。

ZGC 的核心特性与技术

ZGC 的卓越性能主要得益于其几项关键技术和设计:

  • 并发处理 (Concurrent): ZGC 将所有耗时的工作都并发执行,这意味着垃圾回收的大部分任务都是在应用线程继续运行时完成的,从而极大地减少了“Stop-The-World”(STW) 的停顿时间。
  • 基于 Region 的内存布局: 与 G1 类似,ZGC 也将堆内存划分为一个个称为“Region”或“Page”的区域。但 ZGC 的 Region 具有动态性,其容量大小是动态变化的,分为小型、中型和大型三类,用于存放不同大小的对象。
    • 小型 Region (Small Region): 固定容量为 2MB,用于存放小于 256KB 的对象。
    • 中型 Region (Medium Region): 固定容量为 32MB,用于存放 256KB 到 4MB 之间的对象。
    • 大型 Region (Large Region): 容量不固定,为 2MB 的整数倍,用于存放 4MB 及以上的大对象,且一个大型 Region 只存放一个大对象。
  • 染色指针 (Colored Pointers): 这是 ZGC 的标志性设计。传统 GC 将对象的标记信息(如三色标记)存储在对象头中,而 ZGC 将这些信息直接存储在对象引用的指针上。 在 64 位系统中,指针的某些位并未用于寻址,ZGC 利用这些位来存储 Marked0、Marked1、Remapped 等标志位。
    • 优势: 访问标记信息时无需访问对象本身,直接在寄存器层面操作指针即可,速度更快。同时,当一个 Region 内的对象被移动后,该 Region 可以被立即重用,无需等待所有指向它的引用被修正。
  • 读屏障 (Load Barriers): ZGC 使用读屏障技术来实现并发的对象移动。 当应用线程从堆中读取一个对象引用时,会触发一小段被称为“读屏障”的代码。这段代码会检查指针的染色位,判断对象是否已经被移动。如果对象已被移动,读屏障会负责更新引用到新的地址,并让指针“自愈”(Self-healing),确保应用始终访问到正确的对象。
  • 支持 NUMA (Non-Uniform Memory Access): ZGC 能够感知 NUMA 架构,在分配内存时会优先从当前处理器所在的本地内存分配,以提升内存访问性能。

ZGC 结构与工作流程

由于无法直接生成图片,以下将通过文字和流程描述来展示 ZGC 的结构和工作周期。

内存结构示意

ZGC 的堆内存由动态的 Region(Page)组成,不严格区分新生代和老年代(注:从 JDK 21 开始,ZGC 引入了分代支持,但其核心设计仍围绕并发)。其核心在于利用虚拟内存的多重映射技术,将同一份物理内存映射到三个不同的虚拟地址空间上,这是实现染色指针和并发转移的关键。

+------------------------------------------------------+
|                   64-bit Address Space               |
+------------------------------------------------------+
|  ...  |  Remapped View  |  Marked1 View  |  Marked0 View  |
+------------------------------------------------------+
         ^                ^                ^
         |                |                |
         +----------------+----------------+
                          |
                  +-------+-------+
                  | Physical Memory | (Backed by ZPages/Regions)
                  +---------------+

这三个视图(View)对应指针上的不同标志位,使得 ZGC 可以在不移动物理数据的情况下,通过改变指针的视图来完成对象的标记和重定位。

垃圾回收周期

ZGC 的回收过程几乎完全是并发的,仅包含三个非常短暂的 STW 暂停阶段。

  1. 暂停:初始标记 (Pause Mark Start)

    • STW 阶段:这个阶段非常短暂,仅标记从 GC Roots 直接可达的对象。其停顿时间只和 GC Roots 的数量有关,与堆大小无关。
  2. 并发标记 (Concurrent Mark)

    • 并发阶段:遍历整个对象图进行可达性分析。这个过程与应用线程并行执行。它会更新染色指针中的 Marked0 或 Marked1 标志位。
  3. 暂停:再标记 (Pause Remark)

    • STW 阶段:处理在并发标记阶段发生变化的对象引用,修正标记。这个阶段通常非常快,目标是控制在 1ms 以内。
  4. 并发预备重分配 (Concurrent Prepare for Relocate)

    • 并发阶段:根据特定规则选择需要被清理和压缩的 Region,组成重分配集 (Relocation Set)。
  5. 暂停:初始转移 (Pause Relocate Start)

    • STW 阶段:转移重分配集中由 GC Roots 直接引用的对象,并重写这些指针。
  6. 并发转移 (Concurrent Relocate)

    • 并发阶段:这是 ZGC 的核心步骤。它并发地将重分配集中的存活对象复制到新的 Region 中。当应用线程访问旧地址的对象时,读屏障会介入,将访问重定向到新地址,并完成引用的“自愈”。
  7. 并发重映射 (Concurrent Remap)

    • 并发阶段:修正堆中所有指向被转移对象的旧引用。这个阶段会作为下一次 GC 周期并发标记阶段的一部分顺带完成,以减少开销。

ZGC 与 G1、CMS 的区别

首先需要明确,G1 (Garbage-First) 是为了替代 CMS (Concurrent Mark Sweep) 而设计的,而 ZGC 则是为了解决 G1 在超大堆和低延迟场景下的不足。

特性 ZGC G1 (Garbage-First) CMS (Concurrent Mark Sweep)
核心目标 极低的延迟(<10ms),处理超大堆 可预测的停顿时间模型,平衡延迟和吞吐量 最小化应用停顿时间
垃圾回收算法 并发的标记-复制/整理 标记-复制 (年轻代) + 标记-整理 (混合回收) 标记-清除 (Mark-Sweep)
内存碎片 ,因为采用复制/整理算法。 基本无,通过整理来解决碎片问题。 ,标记-清除算法会产生内存碎片,可能触发 Full GC。
停顿时间 (STW) 极短且稳定,暂停只与 GC Roots 数量有关,与堆大小无关。 相对较长且可预测,转移阶段是 STW 的,停顿时间会受存活对象数量影响。 较短但不稳定,并发失败时会触发长时间的 Full GC。
并发程度 非常高,标记、转移、重定位几乎所有阶段都是并发的。 较高,标记阶段并发,但对象转移(Evacuation)是 STW 的。 较高,标记和清除阶段并发,但有初始标记和重新标记两个 STW 阶段。
核心技术 染色指针、读屏障、内存多重映射。 卡表 (Card Table)、记忆集 (RSet)、写屏障 (Write Barrier)。 三色标记法、写屏障。
堆大小支持 非常大,支持 8MB 到 16TB 的堆。 较大,通常建议在 4GB 以上的堆中使用。 中等,对于非常大的堆,性能可能不佳。
吞吐量 优先保证低延迟,可能会牺牲约 15% 的吞吐量。 吞吐量和延迟之间的良好平衡。 吞吐量受处理器资源影响较大。
分代 早期为单代收集器,JDK 21 后支持分代。 分代收集器,将堆划分为年轻代和老年代 Region。 主要用于老年代的回收,需配合新生代收集器使用。

总结

  • CMS vs G1: G1 通过引入 Region 和标记-整理算法,解决了 CMS 的内存碎片问题,并提供了更可预测的停顿时间模型,从而在 JDK 9 之后取代 CMS 成为默认垃圾回收器。
  • G1 vs ZGC: ZGC 是对 G1 的一次巨大飞跃。G1 虽然优化了停顿,但在对象转移阶段仍需 STW。ZGC 通过染色指针和读屏障等创新技术,将对象转移也变成了并发操作,从而将停顿时间从几十或几百毫秒级别压缩到了10毫秒以内,真正实现了超低延迟,尤其是在TB级内存的场景下优势巨大。

因此,在选择垃圾回收器时:

  • 对于需要平衡吞吐量和延迟的绝大多数现代应用,G1 是一个非常优秀且稳健的选择。
  • 对于延迟要求极为苛刻,例如金融交易、实时竞价、大型在线游戏等,并且拥有大内存的系统,ZGC 是未来的方向和理想选择。

好的,我们来详细探讨 Java 中 GC 的可设置参数,以及在不同场景下如何进行配置和调优。


GC 参数通用设置

这些参数通常适用于多种垃圾回收器,是进行 GC 调优的基础。

参数 作用 场景与设置建议
-Xms<size> 设置 JVM 初始堆大小。 生产环境建议:将 -Xms-Xmx 设置为相同的值。这可以避免 JVM 在运行时动态扩展堆内存,减少不必要的性能开销和 GC 触发。例如:-Xms4g -Xmx4g
-Xmx<size> 设置 JVM 最大堆大小。 生产环境建议:与 -Xms 相同。大小应根据应用内存需求和服务器物理内存来定,通常不超过物理内存的 70%-80%。
-XX:+PrintGCDetails 打印详细的 GC 日志信息。 必备参数:在开发、测试和生产环境中都应开启。这是分析 GC 性能、排查内存问题的最重要依据。
-Xlog:gc*:file=<path> (JDK 9+) 统一日志框架,用于记录 GC 日志。 推荐使用:这是 -XX:+PrintGCDetails 的现代化替代方案,功能更强大,可以设置日志级别、轮转等。例如:-Xlog:gc*:file=gc.log:time,level,tags:filecount=5,filesize=10m
-XX:+HeapDumpOnOutOfMemoryError 在发生 OOM 错误时自动生成堆转储 (Heap Dump) 文件。 强烈建议开启:这是排查内存泄漏等 OOM 问题的关键。它能让你在问题发生时捕获到内存快照,以便后续使用 MAT 等工具进行分析。
-XX:HeapDumpPath=<path> 指定 Heap Dump 文件的生成路径。 按需设置:指定一个磁盘空间充足的目录,避免因磁盘空间不足导致 Heap Dump 失败。

不同 GC 的核心参数与场景化设置

选择哪种 GC 并如何调优,完全取决于你的应用特性:是追求高吞t量(单位时间内处理更多任务),还是追求低延迟(每次请求响应更快)。

1. G1 GC (Garbage-First) - 平衡型选手

G1 是 JDK 9 之后的默认垃圾回收器,致力于在可预测的停顿时间内,实现较高的吞吐量,是目前应用最广泛的 GC。

适用场景:

  • 绝大多数 Web 应用、微服务。
  • 堆内存较大(4GB 以上)。
  • 希望避免因 GC 产生过长(秒级)的停顿。

核心参数:

参数 作用 场景与设置建议
-XX:+UseG1GC 明确启用 G1 垃圾回收器。 在 JDK 9 之前需要手动开启。
-XX:MaxGCPauseMillis=<time> 设置目标最大停顿时间(单位:毫秒)。 核心调优参数。G1 会尽力达成这个目标,但它是一个“软目标”,不是绝对保证。
- 低延迟要求: 设置一个较低的值,如 -XX:MaxGCPauseMillis=200 (默认值)。
- 高吞吐量要求: 可以适当放宽该值,让 G1 有更多时间去回收,减少 GC 频率。
-XX:G1HeapRegionSize=<size> 设置 G1 Region 的大小 (1MB 到 32MB 之间,必须是 2 的幂)。 JVM 启动时会根据堆大小自动计算一个合理值。通常不需要手动设置,除非你发现 Humongous Object(巨型对象)分配非常频繁,导致 GC 压力大。这时可以适当调大 Region Size,让更多原本被判定为 Humongous 的对象能正常在 Region 内分配。
-XX:InitiatingHeapOccupancyPercent=<percent> 设置触发并发标记周期的堆占用率阈值 (0-100)。 默认值是 45%。
- GC 过于频繁: 如果发现并发 GC 过于频繁,可以适当调高此值,如 -XX:InitiatingHeapOccupancyPercent=50
- Full GC 风险: 如果在并发周期完成前,老年代空间就被耗尽,导致 Full GC,则应调低此值,让 GC 更早启动。
-XX:G1NewSizePercent=<percent> (不推荐) 新生代最小占比。 G1 的一大优势就是动态调整新生代大小,通常不建议手动干预,让 G1 自适应。
-XX:G1MaxNewSizePercent=<percent> (不推荐) 新生代最大占比。 同上,让 G1 自适应。

调优示例:

  • 场景:一个标准的 Spring Boot 微服务,部署在 8G 内存的容器中,要求响应时间平稳。
  • 配置:
    java -Xms4g -Xmx4g \
         -XX:+UseG1GC \
         -XX:MaxGCPauseMillis=200 \
         -XX:+HeapDumpOnOutOfMemoryError \
         -Xlog:gc*:file=/logs/gc.log:time:filecount=5,filesize=50m \
         -jar my-application.jar
    

2. ZGC (The Z Garbage Collector) - 低延迟王者

ZGC 的目标是实现任意堆大小下,停顿时间都不超过 10 毫秒,是处理超大堆(TB 级别)和对延迟极度敏感应用的终极武器。

适用场景:

  • 金融交易、实时竞价、搜索引擎索引等对延迟极度敏感的服务。
  • 需要使用非常大堆内存(几十 G 到几 T)的应用。
  • 追求极致平滑的用户体验。

核心参数:

参数 作用 场景与设置建议
-XX:+UseZGC 启用 ZGC。 必须手动开启。
-Xmx<size> 设置最大堆大小。 ZGC 的关键。ZGC 需要足够的空间来进行并发复制,因此堆内存要设置得相对充裕。官方建议至少比应用的实际存活对象大小多 15%-25%。
-XX:ConcGCThreads=<number> 设置并发 GC 的线程数。 默认值是自动计算的。如果 CPU 资源非常充足,可以适当调高此值来加快并发回收速度。如果 CPU 资源紧张,可以适当调低,避免 GC 线程与应用线程抢占 CPU。
-XX:+ZGenerational (JDK 21+) 启用分代 ZGC。 强烈建议开启。分代 ZGC 可以显著降低 GC 开销和 CPU 使用率,同时保持极低的延迟。对于大多数应用,分代 ZGC 的性能表现会更好。

调优示例:

  • 场景:一个处理海量数据的实时分析系统,部署在 128G 内存的服务器上,要求任何时候的响应延迟都不能有大的抖动。
  • 配置 (JDK 21+ 为例):
    java -Xms100g -Xmx100g \
         -XX:+UseZGC \
         -XX:+ZGenerational \ # 启用分代支持
         -XX:+HeapDumpOnOutOfMemoryError \
         -Xlog:gc*:file=/logs/gc.log:time:filecount=10,filesize=100m \
         -jar big-data-app.jar
    
  • 注意:ZGC 以牺牲部分吞吐量为代价换取低延迟。如果你的应用是离线批处理等吞吐量优先的场景,ZGC 可能不是最佳选择。

3. Parallel GC (吞吐量优先)

Parallel GC 是 JDK 8 及之前的默认 GC,也被称为“吞吐量收集器”。它的目标是最大化应用程序的吞吐量,为此可以容忍较长时间的 STW(Stop-The-World)。

适用场景:

  • 后台批处理、科学计算等不需要实时交互的任务。
  • 对吞吐量要求极高,可以接受较长 GC 停顿的场景。
  • 运行在多核 CPU 服务器上。

核心参数:

参数 作用 场景与设置建议
-XX:+UseParallelGC 启用 Parallel GC。 在 JDK 9 之后需要手动开启。
-XX:ParallelGCThreads=<number> 设置并行 GC 的线程数。 通常建议设置为与 CPU 核心数相同。
-XX:MaxGCPauseMillis=<time> 设置最大停顿时间目标。 与 G1 类似,但 Parallel GC 更侧重于吞吐量,这个目标优先级较低。
-XX:GCTimeRatio=<ratio> 设置 GC 时间占总时间的比例。 计算公式为 1 / (1 + <ratio>)。例如,-XX:GCTimeRatio=19 表示 GC 时间最多占用 1 / (1 + 19) = 5% 的时间。这是调节吞吐量的重要参数。

调优示例:

  • 场景:一个离线的数据处理任务,需要尽快处理完大量数据,中间停顿几秒钟没有关系。
  • 配置:
    java -Xms16g -Xmx16g \
         -XX:+UseParallelGC \
         -XX:ParallelGCThreads=8 \ # 假设是 8 核 CPU
         -XX:GCTimeRatio=19 \
         -Xlog:gc*:file=/logs/gc.log \
         -jar data-processing-job.jar
    

总结与调优步骤

  1. 明确场景,选择合适的 GC:首先判断你的应用是延迟敏感型还是吞吐量优先型,并根据堆大小选择 G1、ZGC 或 Parallel GC。
  2. 设置基础参数:将 -Xms-Xmx 设为相同值,并开启 GC 日志和 OOM Heap Dump。
  3. 设定核心目标
    • 对于 G1,主要调整 -XX:MaxGCPauseMillis
    • 对于 ZGC,主要是确保 -Xmx 足够大,并考虑是否开启分代。
    • 对于 Parallel GC,主要调整 -XX:GCTimeRatio
  4. 监控与分析:运行应用并持续监控 GC 日志。关注 GC 频率、平均停顿时间、最大停顿时间以及堆内存使用情况。
  5. 迭代调优:根据监控结果进行微调。例如,如果 G1 停顿时间不达标,可能需要增加堆内存;如果 ZGC CPU 使用率过高,可以尝试开启分代或调整并发线程数。切记,GC 调优是一个基于数据和度量的迭代过程,没有一劳永逸的配置。
posted @ 2025-08-15 19:57  tokirin994  阅读(9)  评论(0)    收藏  举报