JVM 从入门到精通教程

Cosolar 28 阅读 后端

本教程系统讲解 Java 虚拟机的核心原理、内存模型、垃圾回收、类加载机制、执行引擎,并结合真实生产场景讲解调优方法与问题排查套路。所有知识点均以 Oracle HotSpot JVM(JDK 8 / JDK 11 / JDK 17 为主)为参照基准。

一、JVM 概述

1.1 什么是 JVM

JVM(Java Virtual Machine)是一个虚构出来的计算机,通过在实际的计算机上仿真模拟各种计算机功能。它是 Java 程序运行的核心运行环境,负责将字节码(Bytecode)解释或编译为机器码执行,同时管理内存分配与回收、线程调度等底层工作。

JVM 最核心的价值在于一次编写,到处运行(Write Once, Run Anywhere)。Java 源码经 javac 编译生成与平台无关的 .class 字节码文件,再由各平台对应的 JVM 实现将其翻译为本地机器指令执行。

1.2 JDK / JRE / JVM 的关系

组成 包含内容 作用
JVM 字节码执行引擎、运行时数据区 运行字节码
JRE JVM + 核心类库(rt.jar 等) 提供 Java 程序运行所需的最小环境
JDK JRE + 开发工具(javacjavadocjstack 等) 提供 Java 程序开发与运行所需的完整环境

从 JDK 9 开始采用模块化系统(JEP 200),rt.jar 被拆分为多个模块,JDK 内部结构发生了重大变化,但三者的包含关系未变。

1.3 主流 JVM 实现

实现 开发方 特点
HotSpot Oracle / OpenJDK 最主流的 JVM 实现,默认使用
OpenJ9 Eclipse(原 IBM J9) 启动快、内存占用低
GraalVM Oracle 支持多语言运行,提供 AOT 编译(Native Image)
Zing Azul Systems 基于 HotSpot 深度定制,低延迟 GC(C4)
Dalvik / ART Google Android 平台使用的虚拟机

本教程以 HotSpot JVM 为基准进行讲解。

二、JVM 整体架构

JVM 架构可分为三大子系统:

graph LR
    subgraph 类加载子系统
        A1[加载]
        A2[链接]
        A3[初始化]
        A1 --> A2 --> A3
        A2a[验证] -.-> A2
        A2b[准备] -.-> A2
        A2c[解析] -.-> A2
    end

    subgraph 运行时数据区
        B1[方法区]
        B2[堆]
        B3[虚拟机栈]
        B4[本地方法栈]
        B5[程序计数器]
    end

    subgraph 执行引擎
        C1[解释器]
        C2[JIT 编译器]
        C3[本地方法接口]
        C4[GC 垃圾回收]
    end

    类加载子系统 --> 运行时数据区 --> 执行引擎
  • 类加载子系统:负责从文件系统、网络等来源加载 .class 文件,完成加载、链接、初始化全过程。
  • 运行时数据区:JVM 在执行期间管理的内存区域,部分区域线程共享,部分区域线程私有。
  • 执行引擎:负责执行字节码,包括解释执行、JIT 编译执行,以及垃圾回收与本地方法调用。

三、类加载机制

3.1 类加载的生命周期

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括七个阶段:

graph LR
    L[加载] --> V[验证] --> P[准备] --> R[解析] --> I[初始化] --> U[使用] --> U2[卸载]
    V -.->|链接阶段| P
    P -.->|链接阶段| R

其中加载、验证、准备、初始化四个阶段的开始时机有明确约束(解析在某些情况下可在初始化之后再开始),这五个阶段统称为类加载过程。

3.1.1 加载

加载阶段 JVM 需要完成三件事:

  1. 通过类的全限定名获取定义此类的二进制字节流(可从 ZIP/JAR、网络、动态生成等来源)。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

3.1.2 验证

验证是链接阶段的第一步,目的是确保 Class 文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证包括:

  • 文件格式验证:是否以魔数 0xCAFEBABE 开头、主次版本号是否在当前 JVM 处理范围等。
  • 元数据验证:类的语义分析,如是否有父类、是否继承了 final 类等。
  • 字节码验证:方法体的语义分析,确保指令不会危害 JVM。
  • 符号引用验证:确保解析阶段能正常执行。

3.1.3 准备

准备阶段为类的静态变量分配内存并设置默认初始值(零值),此时不会执行任何 Java 代码。

public static int value = 123;

在准备阶段后 value 的值为 0 而非 123123 的赋值发生在初始化阶段的 <clinit> 方法中。

例外:如果变量被 final 修饰(常量),则在准备阶段就会被赋值为实际值:

public static final int CONSTANT = 123; // 准备阶段后值为 123

3.1.4 解析

解析阶段是将常量池内的符号引用替换为直接引用的过程。

  • 符号引用:用一组符号来描述所引用的目标,与虚拟机内存布局无关。
  • 直接引用:可以直接指向目标的指针、相对偏移量或间接定位到目标的句柄。

解析动作主要针对类/接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符等 7 类符号引用。

3.1.5 初始化

初始化阶段执行类构造器 <clinit>() 方法。<clinit>() 是由编译器自动收集类中所有静态变量的赋值动作静态代码块static {})合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。

JVM 会保证子类的 <clinit>() 执行前,父类的 <clinit>() 已经执行完毕。因此 java.lang.Object<clinit>() 最先执行。

<clinit>() 方法对于类和接口来说不是必需的,如果一个类中没有静态代码块,也没有对静态变量的赋值操作,编译器可以不为该类生成 <clinit>() 方法。

JVM 保证一个类的 <clinit>() 方法在多线程环境下被正确地加锁和同步。这意味着如果多个线程同时去初始化一个类,只会有一个线程执行该类的 <clinit>() 方法,其他线程都需要阻塞等待——这一特性可用于实现线程安全的单例。

3.2 类加载器

JVM 通过类加载器来实现「通过类的全限定名获取二进制字节流」这个动作。不同的类加载器加载同一个 .class 文件会得到不同的 Class 对象。

3.2.1 三层类加载器(JDK 8 及之前)

类加载器 加载范围 实现语言
Bootstrap ClassLoader(启动类加载器) JAVA_HOME/lib 目录下的核心类库(如 rt.jar C++ 实现,无法在 Java 代码中直接获取
Extension ClassLoader(扩展类加载器) JAVA_HOME/lib/ext 目录 Java 实现(ExtClassLoader
Application ClassLoader(应用类加载器) 用户类路径(ClassPath) Java 实现(AppClassLoader

JDK 9+ 的变化

从 JDK 9 开始引入模块化系统,扩展类加载器被重命名为平台类加载器(Platform ClassLoader),加载方式也改为基于模块系统。rt.jar 不再存在,取而代之的是 JMOD 格式的模块文件。

3.3 双亲委派模型

3.3.1 工作原理

类加载器收到类加载请求时,不会自己先尝试加载,而是将请求委派给父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

graph TD
    B["Bootstrap ClassLoader<br/>(加载核心类库)"]
    E["Extension / Platform ClassLoader<br/>(加载扩展类库)"]
    A["Application ClassLoader<br/>(加载应用类)"]
    C["自定义 ClassLoader<br/>(加载自定义路径类)"]

    B -->|父加载器| E
    E -->|父加载器| A
    A -->|父加载器| C

    C -.->|1 委派请求| A
    A -.->|2 委派请求| E
    E -.->|3 委派请求| B
    B -.->|4 尝试加载<br/>成功则返回| E
    E -.->|5 无法加载则子级尝试| A
    A -.->|6 尝试加载| C

3.3.2 双亲委派的意义

双亲委派模型确保了 Java 核心类库的类型安全性和一致性:

  • 安全性:用户无法伪造一个 java.lang.String 类来替换系统核心类——用户自定义的 String 会被 Bootstrap ClassLoader 先加载的真正的 String 覆盖。
  • 唯一性:同一个类只会被加载一次。对于任意一个类,都需要由加载它的类加载器和这个类本身共同确立其在 JVM 中的唯一性。

3.3.3 双亲委派的代码实现

java.lang.ClassLoaderloadClass 方法源码逻辑如下:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查类是否已被加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                // 2. 委派给父加载器
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器无法加载
            }
            if (c == null) {
                // 3. 父加载器无法加载,自己尝试加载
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

3.4 破坏双亲委派模型

双亲委派模型并非强制约束,在以下场景中会被「破坏」:

  1. SPI 机制(如 JDBC、JNDI):核心类由 Bootstrap 加载,但具体实现类(如 com.mysql.cj.jdbc.Driver)在 ClassPath 下,Bootstrap 无法加载。解决方案是引入线程上下文类加载器(Thread Context ClassLoader),由它来加载 SPI 实现类。

  2. Tomcat:Web 容器中每个 Web 应用需要独立隔离,Tomcat 的 WebappClassLoader 会优先加载自己目录下的类,而不是先委派给父加载器,从而实现不同 Web 应用之间的类隔离。

  3. OSGi:采用网状的类加载模型,每个模块(Bundle)有自己的类加载器,模块间可定义复杂的类加载委托关系。

  4. 热部署/热加载:通过创建新的自定义类加载器重新加载已修改的类,实现不停机更新代码。

3.5 自定义类加载器

继承 java.lang.ClassLoader 并重写 findClass 方法即可实现自定义类加载器:

public class CustomClassLoader extends ClassLoader {

    private String classPath;

    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        }
        return defineClass(name, classData, 0, classData.length);
    }

    private byte[] loadClassData(String name) {
        String fileName = classPath + File.separatorChar
            + name.replace('.', File.separatorChar) + ".class";
        try (InputStream is = new FileInputStream(fileName);
             ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
            return bos.toByteArray();
        } catch (IOException e) {
            return null;
        }
    }
}

重写 findClass 而非 loadClass 可以保留双亲委派逻辑。若要完全打破双亲委派,则需重写 loadClass 方法。

四、运行时数据区

JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。根据各区域的线程共享情况可分为两类:

graph TB
    subgraph 线程共享区域
        S1[方法区]
        S2[堆]
    end

    subgraph 线程私有区域
        T1[虚拟机栈]
        T2[本地方法栈]
        T3[程序计数器]
    end

    DM[直接内存<br/>非 JVM 运行时数据区]

    线程共享区域 --> DM
    线程私有区域 --> DM

4.1 程序计数器(PC Register)

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器

  • 线程私有:每个线程都有一个独立的程序计数器,互不影响。
  • 不会 OOM:在 JVM 规范中,程序计数器是唯一没有规定任何 OOM 情况的区域。
  • 如果线程正在执行的是 Java 方法,计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是 Native 方法,计数器值为空(Undefined)。

4.2 Java 虚拟机栈

Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

  • 线程私有:与线程的生命周期相同。
  • 局部变量表:存放编译期可知的各种基本数据类型(booleanbytecharshortintfloatlongdouble)、对象引用(reference 类型,不等同于对象本身)和 returnAddress 类型。以变量槽(Slot)为最小单位,64 位的 longdouble 占用两个 Slot。
  • 操作数栈:方法执行过程中,用于存放中间计算结果的栈结构。

异常情况

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。
  • 如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存,将抛出 OutOfMemoryError 异常。

在 HotSpot 虚拟机中,虚拟机栈容量由 -Xss 参数设置,默认值通常为 512KB ~ 1MB(取决于操作系统和 JDK 版本)。

4.3 本地方法栈

本地方法栈与虚拟机栈的作用非常相似,区别在于虚拟机栈为虚拟机执行 Java 方法(字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

HotSpot 虚拟机中直接将虚拟机栈和本地方法栈合二为一。与虚拟机栈一样,本地方法栈也会抛出 StackOverflowErrorOutOfMemoryError 异常。

4.4 Java 堆

Java 堆是 JVM 所管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

  • 线程共享
  • GC 主战场:堆是垃圾收集器管理的主要区域,因此也常被称为「GC 堆」。
  • 可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。

堆的分代结构

在大多数 JVM 实现中,堆被划分为不同代以优化 GC 性能:

graph LR
    subgraph Java堆
        subgraph 老年代 Old
            O1[老年代空间]
        end
        subgraph 新生代 Young
            E["Eden<br/>(8/10)"]
            S1["Survivor From<br/>(1/10)"]
            S2["Survivor To<br/>(1/10)"]
        end
    end
  • 新生代(Young Generation):分为一个 Eden 区和两个 Survivor 区(通常称为 From 和 To,或 S0 和 S1)。Eden : Survivor : Survivor = 8 : 1 : 1(由 -XX:SurvivorRatio 控制)。大多数对象在 Eden 区分配,经过一次 Minor GC 后存活的对象进入 Survivor 区,每经历一次 Minor GC 年龄加 1,达到阈值(默认 15,由 -XX:MaxTenuringThreshold 控制)后进入老年代。
  • 老年代(Old Generation):存放经过多次 GC 仍然存活的对象和大对象。

JDK 8 中堆的默认大小在未指定时由 JVM 根据物理内存自动计算:初始堆大小为物理内存的 1/64,最大堆大小为物理内存的 1/4(具体数值受多种参数影响)。

异常情况

堆中没有内存完成实例分配,且堆也无法再扩展时,将抛出 OutOfMemoryError 异常。

4.5 方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。

永久代 vs 元空间

方法区是一个 JVM 规范定义的逻辑区域,不同 JVM 版本的实现方式不同:

JDK 版本 方法区实现 位置 大小限制
JDK 7 及之前 永久代(Permanent Generation) JVM 进程内存 -XX:MaxPermSize,有上限
JDK 8 元空间(Metaspace) 本地内存(Native Memory) -XX:MaxMetaspaceSize,默认无上限
JDK 11+ 元空间(Metaspace) 本地内存 同 JDK 8

JDK 8 将永久代移至本地内存并改名为元空间,主要原因是永久代大小固定且难以调优,容易出现 java.lang.OutOfMemoryError: PermGen space 错误。元空间使用本地内存,容量上限受限于机器物理内存。

字符串常量池在 JDK 7 中就已经从永久代移到了堆中,JDK 8 完成了永久代到元空间的迁移。

4.6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于 Class 文件常量池的另一个重要特征是具备动态性——运行期间也可以将新的常量放入池中,比如 String.intern() 方法。

4.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是 JVM 规范中定义的内存区域,但这部分内存也被频繁使用,且可能导致 OOM。

  • NIO(New Input/Output)引入的 DirectByteBuffer 类允许 Java 程序直接分配堆外内存,通过零拷贝技术提升 I/O 性能。
  • 直接内存的分配不会受到 Java 堆大小的限制,但会受到机器物理内存的限制。
  • -XX:MaxDirectMemorySize 参数指定,如果不指定,默认与最大堆内存(-Xmx)值一致。

五、对象探秘

5.1 对象的创建过程

在 Java 语言层面,创建对象通常使用 new 关键字。在 JVM 层面,对象的创建过程如下:

  1. 类加载检查:当虚拟机遇到一条 new 指令时,首先检查该指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,先执行相应的类加载过程。

  2. 分配内存:在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可完全确定。分配方式有两种:

    • 指针碰撞(Bump the Pointer):假设 Java 堆中内存是绝对规整的,用过的内存在一边,空闲的内存在另一边,中间放一个指针作为分界点。分配内存就是把指针向空闲空间挪动一段与对象大小相等的距离。
    • 空闲列表(Free List):如果 Java 堆中的内存不是规整的,虚拟机需要维护一个列表,记录哪些内存块是可用的,分配时从列表中找到一块足够大的空间划分给对象实例。

    选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。Serial、ParNew 等带压缩过程的收集器使用指针碰撞;CMS 等基于标记-清除的收集器使用空闲列表。

  3. 内存空间初始化:将分配到的内存空间(不包括对象头)初始化为零值。这一步保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用。

  4. 设置对象头:设置对象头信息,包括对象自身的运行时数据(如哈希码、GC 分代年龄、锁状态标志等)和类型指针(指向类元数据,确定该对象是哪个类的实例)。

  5. 执行 <init> 方法:执行构造函数,即 <init>() 方法,为对象的字段赋值。此时一个真正可用的对象才算完全产生。

5.2 对象的内存布局

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为三个区域:对象头实例数据对齐填充

graph TB
    subgraph 对象头 Header
        MW["Mark Word(运行时数据)<br/>哈希码 / GC分代年龄 / 锁状态标志 / 线程持有的锁 / 偏向线程ID"]
        KP[类型指针 Klass Pointer]
        AL[数组长度 — 仅数组对象有]
    end
    ID["实例数据 Instance Data<br/>父类继承的字段 / 自身定义的字段"]
    PD["对齐填充 Padding<br/>保证对象大小是 8 字节的整数倍"]

    MW --> KP --> AL --> ID --> PD
  • Mark Word:在 64 位 JVM 中占 64 bit,存储对象自身的运行时数据。在锁升级过程中,Mark Word 会被复用来存储锁信息(偏向锁、轻量级锁、重量级锁的状态)。
  • 类型指针:指向类元数据,确定该对象是哪个类的实例。在开启指针压缩的情况下(-XX:+UseCompressedOops,默认开启),64 位 JVM 中占 4 字节。
  • 实例数据:对象真正存储的有效信息,包括自身定义的字段和从父类继承的字段。
  • 对齐填充:HotSpot 要求对象起始地址必须是 8 字节的整数倍,如果对象大小不是 8 的整数倍,需要填充对齐。

5.3 对象的访问定位

Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。主流的访问方式有两种:

句柄访问

graph LR
    R["reference<br/>(Java栈)"]
    H["句柄(句柄池)<br/>· 实例数据指针<br/>· 类型数据指针"]
    I["对象实例数据<br/>(实例池)"]
    T["类型数据<br/>(方法区)"]

    R -->|1| H
    H -->|2| I
    H -->|3| T

优点:reference 中存储的是稳定的句柄地址,对象被移动(GC 时很常见)时只会改变句柄中的实例数据指针,reference 本身不需要修改。

直接指针访问(HotSpot 采用)

graph LR
    R["reference<br/>(Java栈)"]
    O["对象头(含类型指针)+ 实例数据<br/>(堆)"]
    T["类型数据<br/>(方法区)"]

    R -->|1 直接访问| O
    O -->|2 类型指针| T

优点:速度更快,节省了一次指针定位的开销。HotSpot 使用的是直接指针访问方式。

六、执行引擎

执行引擎是 JVM 的核心组件之一,负责执行字节码指令。与物理机的执行引擎不同,JVM 的执行引擎是软件实现的。

6.1 解释器与 JIT 编译器

JVM 执行字节码有两种方式:

  • 解释器:逐条解释字节码并执行。优点是启动快(无需编译),缺点是执行速度慢。
  • JIT 编译器(Just-In-Time Compiler):将热点代码编译为本地机器码后执行。优点是执行速度快,缺点是需要编译开销且有预热时间。

HotSpot 虚拟机采用解释器 + JIT 编译器的混合模式(Mixed Mode),两者配合工作:

  • 程序启动时,解释器先快速执行,避免编译开销导致的启动延迟。
  • 运行过程中,JIT 编译器监控执行频率,将热点代码(Hot Spot,这也是虚拟机名称的由来)编译为机器码。

热点探测

HotSpot 采用基于计数器的热点探测(Counter-Based Hot Spot Detection)。每个方法有两个计数器:

  • 方法调用计数器(Invocation Counter):统计方法被调用的次数。
  • 回边计数器(Back Edge Counter):统计方法中循环体执行的次数。

当计数器之和超过阈值(默认 10000,由 -XX:CompileThreshold 设置)时,会触发 JIT 编译。触发编译请求后会稍微降低计数器的值,以便继续收集执行信息(称为「热度衰减」)。

在分层编译(Tiered Compilation,JDK 8 起默认开启)模式下,-XX:CompileThreshold 的设置不再直接生效,因为分层编译使用了一套不同的阈值体系。

6.2 JIT 编译器

HotSpot 虚拟机内置了两个 JIT 编译器:

  • C1 编译器(Client Compiler):注重编译速度,优化较简单,适合桌面应用。
  • C2 编译器(Server Compiler):注重编译质量,进行大量优化,适合服务端应用。

从 JDK 10 开始,默认开启分层编译-XX:+TieredCompilation),结合 C1 和 C2 的优势:

层级 编译器 特点
0 解释器 纯解释执行,收集 profiling 信息
1 C1 轻量编译,无 profiling
2 C1 轻量编译,带方法调用和回边计数
3 C1 完整 profiling
4 C2 重量级编译,最高性能

执行路径通常是:解释器(0层)→ C1 带完整 profiling(3层)→ C2(4层)。

6.3 编译优化技术

JIT 编译器会对热点代码进行多种优化,以下列举几种重要的优化技术:

6.3.1 方法内联

方法内联是最重要的优化之一,将被调用方法的代码直接嵌入到调用处,消除方法调用的开销,并为其他优化创造条件。

// 优化前
public int add(int a, int b) { return a + b; }
public int calculate() { int x = 1; int y = 2; return add(x, y); }

// 内联后
public int calculate() { int x = 1; int y = 2; return x + y; }

内联条件由方法大小和调用频率决定,-XX:MaxInlineSize(默认 35 字节)控制普通方法内联上限,-XX:FreqInlineSize 控制热点方法内联上限。

6.3.2 逃逸分析

逃逸分析(Escape Analysis)分析对象的动态作用域,判断对象是否可能被外部访问。如果一个对象不会逃逸到方法或线程之外,JIT 编译器可以对其进行优化:

  • 标量替换:将对象拆解为基本类型变量,直接在栈上分配,无需在堆上创建对象。
  • 栈上分配:对象分配在栈上,方法结束后自动回收,减轻 GC 压力。
  • 同步消除:如果对象不会逃逸出线程,对其的同步操作可以被消除。
// 原始代码:sb 对象不会逃逸出方法
public String concat(String a, String b) {
    StringBuilder sb = new StringBuilder();
    sb.append(a);
    sb.append(b);
    return sb.toString();
}

// 标量替换后:sb 被拆解,不再创建对象
public String concat(String a, String b) {
    char[] value = ...; // 直接操作基本类型
    int count = ...;
    // 不再在堆上创建 StringBuilder 对象
    ...
}

注意:HotSpot 虚拟机实际上并没有真正实现「栈上分配」,而是通过标量替换来达到类似的效果。标量替换是逃逸分析的一种应用。

6.3.3 其他优化技术

  • 公共子表达式消除:如果一个表达式已经计算过,且期间变量未变化,则直接使用之前的结果。
  • 数组边界检查消除:在编译期判断数组访问不会越界时,消除运行时的边界检查。
  • 锁消除:通过逃逸分析,对不会逃逸出线程的对象的同步操作进行消除。
  • 锁粗化:将多个连续的同步块合并为一个,减少锁的获取和释放次数。

6.4 AOT 编译

从 JDK 9 开始引入了 AOT(Ahead-Of-Time)编译器 jaotc(JEP 295),可以在程序运行前将字节码编译为本地机器码。但该特性在 JDK 17 中被移除(JEP 410),原因是维护成本高且收益有限。目前主流的 AOT 方案是 GraalVM 的 Native Image,它将整个 Java 应用提前编译为独立可执行文件,无需 JVM 即可运行,启动时间可达毫秒级,内存占用大幅降低,适合云原生和 Serverless 场景。

OpenJDK 社区通过 Project Leyden 正在推进将 AOT 能力引入标准 JDK,目前已有 AOT 代码缓存等草案提案(JEP draft)。此外,JDK 23 起实验性集成了 Graal JIT 编译器作为 C2 的替代选项(JEP 482)。

AOT 编译的代价是丧失了部分动态特性(如运行时类加载、反射需要额外配置),以及放弃了 JIT 的运行时 profiling 优化。

七、垃圾回收机制

垃圾回收(Garbage Collection,GC)是 JVM 自动内存管理的核心机制,负责自动回收程序中不再使用的对象所占用的内存。

7.1 判断对象存活

7.1.1 引用计数法

给对象添加一个引用计数器,每被引用一次计数器加 1,引用失效时计数器减 1,计数器为 0 的对象就是可回收的。

缺点:无法解决循环引用问题——对象 A 引用 B,B 引用 A,两者的计数器都不为 0,但实际已不可达。因此主流 JVM 都不采用此算法。

7.1.2 可达性分析算法(HotSpot 采用)

通过一系列称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时(即从 GC Roots 到这个对象不可达),则证明此对象是不可用的。

graph TD
    ROOT["GC Roots"]

    ROOT --> A[Object A]
    ROOT --> C[Object C]
    A --> B[Object B]

    D[Object D]
    E[Object E]
    D -.->|引用| E

    D -->|不可达<br/>将被回收| X((回收))
    E -->|不可达<br/>将被回收| X

GC Roots 的对象类型

可作为 GC Roots 的对象包括:

  1. 虚拟机栈中引用的对象(局部变量表中的引用)
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI(即 Native 方法)引用的对象
  5. Java 虚拟机内部的引用(如基本类型对应的 Class 对象、常驻的异常对象、系统类加载器)
  6. 被同步锁(synchronized 关键字)持有的对象
  7. JMXBean、JVMTI 中注册的回调、本地代码缓存等(取决于具体 JVM 实现)

7.2 引用类型

JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为四种:

引用类型 特点 回收时机 典型应用
强引用(Strong Reference) 普通赋值,如 Object obj = new Object() 永不回收(只要可达) 日常编程
软引用(Soft Reference) SoftReference<Object> 内存不足时回收 内存敏感的缓存
弱引用(Weak Reference) WeakReference<Object> 下次 GC 时回收 WeakHashMapThreadLocal
虚引用(Phantom Reference) PhantomReference<Object> 随时可能被回收,无法通过它获取对象实例 跟踪对象被 GC 的过程,配合 ReferenceQueue 使用

7.3 回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量无用的类

判定一个类是否「无用」需要同时满足以下三个条件:

  1. 该类所有的实例都已被回收(Java 堆中不存在该类的任何实例)。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

HotSpot 虚拟机提供了 -Xnoclassgc 参数控制是否对类进行回收。在大量使用反射、动态代理、CGLib 等 bytecode 框架的场景中,通常需要 JVM 具备类卸载的功能,以防止方法区(元空间)溢出。

7.4 GC 算法

7.4.1 标记-清除算法(Mark-Sweep)

分为「标记」和「清除」两个阶段:首先标记出所有需要回收的对象,然后统一清除。

graph LR
    subgraph 标记前
        direction LR
        M1[A] ~~~ M2["×B"] ~~~ M3[C] ~~~ M4["×D"] ~~~ M5[E]
    end
    subgraph 标记后
        direction LR
        N1[A] ~~~ N2["×B(标记)"] ~~~ N3[C] ~~~ N4["×D(标记)"] ~~~ N5[E]
    end
    subgraph 清除后
        direction LR
        O1[A] ~~~ O2["空"] ~~~ O3[C] ~~~ O4["空"] ~~~ O5[E]
    end
    标记前 --> 标记后 --> 清除后

清除后产生内存碎片(空闲块不连续)。

  • 缺点:产生大量不连续的内存碎片,可能导致后续分配大对象时找不到足够大的连续空间而提前触发 GC。

7.4.2 复制算法(Copying)

将内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块用完了,就将还存活着的对象复制到另一块上面,然后把已使用过的内存空间一次清理掉。

graph LR
    subgraph 步骤1_使用区满
        direction LR
        U1[A] ~~~ U2["×B"] ~~~ U3[C] ~~~ U4["×D"] ~~~ U5[E]
        F1["[空闲]"] ~~~ F2["[空闲]"] ~~~ F3["[空闲]"] ~~~ F4["[空闲]"] ~~~ F5["[空闲]"]
    end
    subgraph 步骤2_复制存活对象到空闲区
        direction LR
        U6["[空]"] ~~~ U7["[空]"] ~~~ U8["[空]"] ~~~ U9["[空]"] ~~~ U10["[空]"]
        F6[A] ~~~ F7[C] ~~~ F8[E] ~~~ F9["[空闲]"] ~~~ F10["[空闲]"]
    end
    subgraph 步骤3_交换两区
        direction LR
        R1[A] ~~~ R2[C] ~~~ R3[E] ~~~ R4["[空闲]"] ~~~ R5["[空闲]"]
    end
    步骤1_使用区满 -->|复制存活对象| 步骤2_复制存活对象到空闲区
    步骤2_复制存活对象到空闲区 -->|交换| 步骤3_交换两区
  • 优点:实现简单,运行高效,不会产生内存碎片。
  • 缺点:可用内存缩小为原来的一半,空间利用率低。

新生代普遍采用复制算法。因为新生代中的对象朝生夕灭,存活率低,不需要按照 1:1 来划分内存,而是分为 Eden 区和两个 Survivor 区(8:1:1),实际只浪费 10% 的空间。

7.4.3 标记-整理算法(Mark-Compact)

标记过程与标记-清除算法一样,但后续步骤不是直接清理可回收对象,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

graph LR
    subgraph 整理前
        direction LR
        M1[A] ~~~ M2["×B"] ~~~ M3[C] ~~~ M4["×D"] ~~~ M5[E]
    end
    subgraph 整理后
        direction LR
        O1[A] ~~~ O2[C] ~~~ O3[E] ~~~ O4["连续空闲空间"]
    end
    整理前 -->|移动存活对象到一端| 整理后

整理后无内存碎片,但需要移动存活对象。

  • 优点:不会产生内存碎片。
  • 缺点:需要移动大量对象,效率较低(尤其是老年代中存活对象多时)。

老年代一般采用标记-整理算法或标记-清除算法(CMS 采用的是标记-清除)。

7.4.4 分代收集算法

当前主流 JVM 都采用分代收集策略,根据对象存活周期的不同将内存划分为几块,不同代采用不同的算法:

  • 新生代:每次 GC 都有大量对象死去,少量存活 → 复制算法
  • 老年代:对象存活率高 → 标记-清除标记-整理算法。

7.5 GC 的分类

GC 类型 描述 触发条件
Minor GC / Young GC 回收新生代 Eden 区空间不足
Major GC 回收老年代(概念模糊,常与 Full GC 混用) 老年代空间不足
Full GC 回收整个堆和方法区 多种情况(见下文)
Mixed GC 回收整个新生代和部分老年代(G1 特有) G1 的触发条件

Full GC 的触发条件包括:

  1. 调用 System.gc()(只是建议,不保证立即执行)
  2. 老年代空间不足
  3. 方法区(元空间)空间不足
  4. 通过分配担保失败(Minor GC 前检查老年代连续空间是否足够容纳所有新生代对象)
  5. CMS GC 的 Concurrent Mode Failure(并发标记阶段老年代空间不够,退化为 Serial Old 做 Full GC)

7.6 垃圾收集器详解

垃圾收集器是 GC 算法的具体实现。不同收集器各有特点,适用于不同场景。

graph LR
    subgraph 新生代
        SE[Serial]
        PN[ParNew]
        PS[Parallel Scavenge]
    end
    subgraph 老年代
        SO[Serial Old]
        CMS[CMS]
        PO[Parallel Old]
    end

    SE --- SO
    PN --- CMS
    PS --- PO

    G1["G1(统一管理新生代和老年代)"]
    ZS["ZGC / Shenandoah<br/>(Region-based,全堆回收)"]

7.6.1 Serial 收集器

最古老的单线程收集器,进行垃圾收集时必须暂停所有工作线程(Stop The World,STW)。

  • 新生代采用复制算法,老年代(Serial Old)采用标记-整理算法。
  • 简单高效(单线程没有线程交互开销),适合客户端模式或小内存场景。
sequenceDiagram
    participant 用户线程
    participant GC线程

    用户线程->>用户线程: 运行 ████
    Note over 用户线程: STW 暂停
    GC线程->>GC线程: 垃圾收集 ██
    GC线程-->>用户线程: 恢复
    用户线程->>用户线程: 运行 ████
    Note over 用户线程: STW 暂停
    GC线程->>GC线程: 垃圾收集 ██
    GC线程-->>用户线程: 恢复
    用户线程->>用户线程: 运行 ████

7.6.2 ParNew 收集器

Serial 收集器的多线程版本,使用多个线程进行垃圾收集。

  • 新生代采用复制算法,多线程并行收集。
  • 是 Server 模式下首选的新生代收集器,可与 CMS 配合使用。
  • -XX:ParallelGCThreads 参数限制垃圾收集的线程数。

7.6.3 Parallel Scavenge 收集器

新生代收集器,使用复制算法,并行多线程收集。

与 ParNew 的区别在于它的关注点不同:

  • ParNew / CMS:关注降低停顿时间(响应速度优先)。
  • Parallel Scavenge:关注吞吐量(CPU 利用率优先),即用户代码运行时间 / (用户代码运行时间 + GC 时间)。

关键参数:

  • -XX:MaxGCPauseMillis:设置最大 GC 停顿时间(不保证一定达到)。
  • -XX:GCTimeRatio:设置吞吐量大小(0-100,默认 99,即允许 1% 的 GC 时间)。
  • -XX:+UseAdaptiveSizePolicy:开启自适应调节策略,JVM 动态调整新生代、Eden、Survivor 的大小。

JDK 8 默认使用 Parallel Scavenge + Parallel Old 组合。

7.6.4 Parallel Old 收集器

Parallel Scavenge 的老年代版本,使用多线程和标记-整理算法。在 JDK 6 中开始提供。

7.6.5 CMS 收集器(Concurrent Mark Sweep)

CMS 是以获取最短回收停顿时间为目标的收集器,适合互联网网站或 B/S 系统服务端。分为四个阶段:

sequenceDiagram
    participant 用户线程
    participant GC线程

    Note over 用户线程: 阶段1: 初始标记 (STW)
    用户线程->>用户线程: 暂停
    GC线程->>GC线程: 初始标记(速度很快)
    GC线程-->>用户线程: 恢复

    Note over 用户线程,GC线程: 阶段2: 并发标记(与用户线程并发)
    用户线程->>用户线程: 继续运行
    GC线程->>GC线程: 并发标记

    Note over 用户线程: 阶段3: 重新标记 (STW)
    用户线程->>用户线程: 暂停
    GC线程->>GC线程: 重新标记(修正变动)
    GC线程-->>用户线程: 恢复

    Note over 用户线程,GC线程: 阶段4: 并发清除(与用户线程并发)
    用户线程->>用户线程: 继续运行
    GC线程->>GC线程: 并发清除

优点:并发收集、低停顿。

缺点

  1. CPU 资源敏感:并发阶段占用一部分线程,降低吞吐量。
  2. 浮动垃圾:并发清除阶段用户线程仍在运行,会产生新的垃圾,只能下次 GC 回收。
  3. 标记-清除算法产生碎片:可能导致大对象分配失败,提前触发 Full GC。

CMS 在 JDK 9 被标记为废弃(JEP 291),在 JDK 14 中被移除(JEP 363)。推荐使用 G1 替代。

7.6.6 G1 收集器(Garbage First)

G1 是面向服务端应用的垃圾收集器,JDK 9 开始成为默认收集器(JEP 248)。

核心设计

  • 将整个 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 可以是 Eden、Survivor、Old 或 Humongous(大对象专用)。
  • G1 跟踪各个 Region 中的垃圾价值(回收所获得的空间大小和回收所需时间的经验值),维护一个优先列表,每次根据允许的收集停顿时间优先回收价值最大的 Region(这就是 “Garbage First” 名称的由来)。
graph LR
    R1[E] ~~~ R2[E] ~~~ R3[S] ~~~ R4[O] ~~~ R5[O]
    R6[H] ~~~ R7[H] ~~~ R8[E] ~~~ R9[O] ~~~ R10[S]
    R11[O] ~~~ R12[E]

    E = Eden
    S = Survivor
    O = Old
    H = Humongous

GC 过程(G1 的 Mixed GC):

  1. 初始标记(Initial Mark):STW,标记 GC Roots 能直接关联到的对象,并触发一次 Minor GC(在 JDK 8u40 之前是 Young GC,之后整合为一次)。
  2. 并发标记(Concurrent Mark):从 GC Roots 开始对堆中对象进行可达性分析,找出存活对象,与用户线程并发执行。
  3. 最终标记(Final Mark / Remark):STW,处理并发阶段产生的变更(使用 SATB — Snapshot-At-The-Beginning 算法)。
  4. 筛选回收(Live Data Counting and Evacuation):STW,对各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间制定回收计划,复制存活对象到空 Region。

优点

  • 可预测的停顿时间(-XX:MaxGCPauseMillis 设置目标停顿时间,默认 200ms)。
  • 无内存碎片(采用复制算法,Region 间复制)。
  • 适合大堆内存(6GB+)。

关键参数

  • -XX:MaxGCPauseMillis:目标停顿时间(默认 200ms)。
  • -XX:G1HeapRegionSize:Region 大小(1MB~32MB,默认根据堆大小自动计算)。
  • -XX:InitiatingHeapOccupancyPercent:触发并发标记的堆占用阈值(默认 45%)。

G1 在 JDK 8 中可用(-XX:+UseG1GC),从 JDK 9 开始成为默认收集器。

7.6.7 ZGC(Z Garbage Collector)

ZGC 是 JDK 11 中引入的低延迟垃圾收集器(实验性),JDK 15 成为正式特性(JEP 377)。核心目标是将 GC 停顿时间控制在 10ms 以内(在 JDK 16 后进一步优化,平均停顿可达亚毫秒级),且停顿时间不会随堆内存增大而增加。

核心技术

  • 染色指针(Colored Pointer):在 64 位指针中利用高位存储 GC 元数据(Marked0、Marked1、Remapped、Finalizable),实现对象状态的标记。
  • 读屏障(Load Barrier):在读取对象引用时执行屏障代码,配合染色指针实现并发移动对象。
  • 内存多重映射(Multi-Mapping):通过操作系统的虚拟内存映射,将不同标记状态的同一段物理内存映射到不同的虚拟地址。
  • 并发整理:标记、转移、重定位阶段都与用户线程并发执行,仅在极短的初始标记和再定位初始阶段需要 STW。

特点

  • 支持 TB 级堆内存。
  • 停顿时间 < 1ms(JDK 16+),且不随堆大小增长。
  • JDK 21 中引入了分代 ZGC(JEP 439),进一步降低了分配停顿。

7.6.8 Shenandoah 收集器

Shenandoah 是由 Red Hat 开发的低延迟垃圾收集器,JDK 12 引入(实验性,JEP 189),JDK 15 成为正式特性(JEP 379)。

与 ZGC 类似,目标是将 GC 停顿时间与堆大小无关。核心区别在于实现技术:

  • Shenandoah 使用** Brooks 转发指针**(Forwarding Pointer)实现并发整理,每个对象头额外增加一个转发指针,指向对象当前的位置。
  • ZGC 使用染色指针 + 读屏障。

7.7 收集器选择指南

场景 推荐收集器 理由
客户端/小内存应用 Serial 简单高效,无线程开销
注重吞吐量(批处理、科学计算) Parallel Scavenge + Parallel Old 吞吐量优先
注重低延迟(Web 服务) G1 可预测停顿,平衡吞吐与延迟
超低延迟、大堆(8GB+,金融交易) ZGC / Shenandoah 停顿 < 10ms,堆大小无关

7.8 内存分配与回收策略

对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的 Eden 区上。主要规则如下:

  1. 对象优先在 Eden 分配:大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

  2. 大对象直接进入老年代:大对象(如长数组、长字符串)需要大量连续内存空间,频繁在新生代分配会导致复制开销。由 -XX:PretenureSizeThreshold 参数控制(只对 Serial 和 ParNew 收集器有效)。

  3. 长期存活的对象进入老年代:每个对象有一个对象年龄计数器。对象在 Survivor 区中每熬过一次 Minor GC,年龄加 1,达到阈值(默认 15)后晋升到老年代。

  4. 动态年龄判断:如果在 Survivor 空间中相同年龄所有对象大小的总和超过 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到 MaxTenuringThreshold 要求的年龄。

  5. 空间分配担保:在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果不大于,则检查是否允许担保失败(-XX:-HandlePromotionFailure,JDK 6 Update 24 之后默认允许)。如果允许,继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。如果大于,尝试进行一次 Minor GC(有风险);如果小于或不允许担保失败,改为进行一次 Full GC。

八、JVM 调优基础

8.1 常用 JVM 参数

8.1.1 堆内存参数

参数 说明 示例
-Xms 初始堆大小 -Xms2g
-Xmx 最大堆大小 -Xmx4g
-Xmn 新生代大小 -Xmn1g
-XX:NewRatio 新生代与老年代比例(老年代:新生代) -XX:NewRatio=2(老年代占 2/3)
-XX:SurvivorRatio Eden 与 Survivor 区比例 -XX:SurvivorRatio=8(Eden 占 8/10)
-XX:MetaspaceSize 元空间初始大小(触发 GC 的阈值) -XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize 元空间最大大小 -XX:MaxMetaspaceSize=512m
-Xss 每个线程的栈大小 -Xss512k

生产环境中通常建议将 -Xms-Xmx 设置为相同值,避免 JVM 动态调整堆大小带来的性能开销。

8.1.2 GC 收集器参数

参数 说明
-XX:+UseSerialGC 使用 Serial + Serial Old
-XX:+UseParNewGC 使用 ParNew + Serial Old(JDK 9 废弃)
-XX:+UseParallelGC 使用 Parallel Scavenge + Parallel Old(JDK 8 默认)
-XX:+UseConcMarkSweepGC 使用 ParNew + CMS + Serial Old 兜底(JDK 14 移除)
-XX:+UseG1GC 使用 G1(JDK 9+ 默认)
-XX:+UseZGC 使用 ZGC(JDK 11+ 实验性,JDK 15+ 正式)
-XX:+UseShenandoahGC 使用 Shenandoah(JDK 12+ 实验性,JDK 15+ 正式)

8.1.3 GC 日志参数

JDK 8 及之前

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/path/to/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M

JDK 9+(统一日志框架 Xlog)

-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags:filecount=10,filesize=100m

8.2 调优方法论

8.2.1 调优目标

调优前必须先明确优化目标,不同的应用场景目标不同:

  • 吞吐量优先:批处理、大数据计算、后台任务。目标是最大化用户代码运行时间占比(如 > 95%)。
  • 延迟优先:Web 服务、API 接口、实时交易。目标是最小化 GC 停顿时间(如 P99 < 200ms)。
  • 内存占用优先:容器化部署、微服务。目标是最小化内存占用。

三者往往不可兼得,需要根据实际场景权衡。

8.2.2 调优原则

  1. 先监控,后调优:不要盲目调优。先通过监控数据(GC 日志、APM 指标)找到性能瓶颈。
  2. 一次只调一个参数:每次只修改一个参数,观察效果后再决定下一步。
  3. 优先应用层面优化:很多时候 GC 频繁是因为代码层面的问题(如创建过多临时对象、内存泄漏),先优化代码再调 JVM 参数。
  4. 稳定优先:生产环境调优要保守,先在测试环境充分验证。
  5. 合理设置堆大小:堆不是越大越好,过大的堆会导致 GC 停顿时间增加(尤其是 Full GC)。

8.2.3 调优流程

graph TD
    S1[1. 确定优化目标<br/>吞吐量 / 延迟 / 内存]
    S2[2. 收集当前性能数据<br/>GC 日志、堆 dump、APM]
    S3[3. 分析瓶颈<br/>GC 频繁?停顿长?内存泄漏?]
    S4[4. 制定调优方案<br/>调整参数 / 优化代码 / 更换 GC 收集器]
    S5[5. 测试环境验证]
    S6[6. 灰度发布 → 全量上线]
    S7[7. 持续监控]

    S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
    S7 -.->|发现问题| S3

8.3 关键调优指标

指标 含义 关注点
GC 频率 单位时间内 GC 次数 Minor GC 过频可能是新生代太小
GC 停顿时间 每次 GC 的 STW 时间 直接影响响应延迟
GC 吞吐量 非 GC 时间占总运行时间比例 一般应 > 95%
堆内存使用率 已用堆 / 总堆 持续上涨可能内存泄漏
晋升速率 新生代对象进入老年代的速率 过快可能导致频繁 Full GC

九、JVM 调优实战

9.1 堆内存调优

9.1.1 堆大小设置

合理设置堆大小是调优的基础:

# 4 核 8G 服务器,Web 服务
java -Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -jar app.jar

经验法则:

  • -Xms-Xmx 设置为相同值,避免动态扩缩容的开销。
  • 新生代大小建议占堆的 1/3 ~ 1/2(-XX:NewRatio=2 表示老年代:新生代 = 2:1)。
  • 元空间初始值建议设置在 256MB 左右,避免因元空间扩容触发 Full GC。
  • 在容器环境中,JDK 8u191+ 和 JDK 10+ 支持 -XX:+UseContainerSupport(默认开启),JVM 能自动识别容器内存限制。

9.1.2 新生代与老年代比例

  • 如果 Minor GC 频繁:适当增大新生代(增加 -Xmn 或调整 -XX:NewRatio)。
  • 如果 Full GC 频繁:检查是否有内存泄漏,或老年代空间不足。

9.1.3 Survivor 区调优

如果对象在 Survivor 区存活率很高(如大量缓存对象),可能需要增大 Survivor 区。但要注意,Survivor 区过大会导致 Eden 区缩小,可能反而增加 Minor GC 频率。

9.2 G1 收集器调优

G1 是 JDK 9+ 的默认收集器,也是当前最推荐的通用收集器。

9.2.1 G1 核心参数

java -Xms4g -Xmx4g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:G1HeapRegionSize=8m \
  -XX:InitiatingHeapOccupancyPercent=45 \
  -XX:G1ReservePercent=10 \
  -XX:ConcGCThreads=4 \
  -XX:ParallelGCThreads=8 \
  -jar app.jar
参数 说明 默认值
-XX:MaxGCPauseMillis 目标停顿时间 200ms
-XX:G1HeapRegionSize Region 大小 自动计算(1~32MB)
-XX:InitiatingHeapOccupancyPercent 触发并发标记的堆占用率 45%
-XX:G1ReservePercent 保留内存百分比(应对晋升峰值) 10%
-XX:ConcGCThreads 并发标记线程数 总线程数的 1/4
-XX:ParallelGCThreads STW 阶段并行线程数 总线程数的 5/8

9.2.2 G1 调优建议

  1. 不要设过小的 MaxGCPauseMillis:目标停顿时间越小,G1 可能会频繁进行小范围回收,导致吞吐量下降。200ms 是一个合理的起点。

  2. 适当增大 IHOP:如果 Mixed GC 过于频繁,可以适当提高 -XX:InitiatingHeapOccupancyPercent(如调到 50%),让并发标记推迟触发。JDK 9+ 默认开启自适应 IHOP(-XX:+G1UseAdaptiveIHOP),G1 会根据历史数据自动调整。

  3. 避免大对象:G1 中超过 Region 大小一半的对象被视为 Humongous 对象,直接分配在老年代的连续 Region 中。大量大对象会导致 GC 效率降低。如果不可避免,适当增大 Region 大小。

  4. JDK 版本选择:G1 在 JDK 8 中存在一些性能问题(如 Mixed GC 不触发),建议至少使用 JDK 11,理想情况下使用 JDK 17+。

9.3 ZGC 调优

ZGC 的调优非常简单,通常只需要设置堆大小即可:

java -Xms8g -Xmx8g \
  -XX:+UseZGC \
  -XX:ZCollectionInterval=120 \
  -XX:ZAllocationSpikeTolerance=5 \
  -jar app.jar
  • ZGC 几乎不需要调优,它的设计理念是「开箱即用」。
  • -XX:ZCollectionInterval:设置两次 GC 之间的最小间隔(秒),默认 0(不限制)。
  • -XX:ZAllocationSpikeTolerance:分配速率突增容忍度,默认 2.0。

JDK 21 的分代 ZGC(-XX:+UseZGC -XX:+ZGenerational)在保持低停顿的同时显著降低了分配停顿和内存开销,是 JDK 21 的推荐选择。

9.4 不同场景的调优策略

9.4.1 Web 服务(低延迟)

java -Xms4g -Xmx4g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=100 \
  -XX:+ParallelRefProcEnabled \
  -XX:+DisableExplicitGC \
  -Xss512k \
  -jar web-app.jar
  • 使用 G1,目标停顿时间设为 100ms。
  • -XX:+DisableExplicitGC:禁用 System.gc(),防止代码中的显式 GC 调用造成 STW。
  • -XX:+ParallelRefProcEnabled:并行处理引用对象,减少处理弱引用/软引用时的停顿。
  • 线程栈设为 512KB,节省内存(默认 1MB 在大多数 Web 场景下偏大)。

9.4.2 大数据计算(高吞吐量)

java -Xms16g -Xmx16g \
  -XX:+UseParallelGC \
  -XX:MaxGCPauseMillis=500 \
  -XX:GCTimeRatio=99 \
  -XX:+UseAdaptiveSizePolicy \
  -jar batch-job.jar
  • 使用 Parallel 收集器,关注吞吐量。
  • 大堆 + 自适应策略,让 JVM 自行优化。

9.4.3 微服务/容器化

java -Xms512m -Xmx512m \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=100 \
  -XX:+UseContainerSupport \
  -XX:MaxRAMPercentage=75.0 \
  -jar microservice.jar
  • 小堆内存,使用 G1。
  • -XX:MaxRAMPercentage:限制 JVM 使用的容器内存比例(推荐 75%,剩余 25% 给 JVM 非堆内存和系统)。

十、常见问题排查

10.1 OOM 问题排查

10.1.1 OOM 类型与原因

OOM 错误信息 根因 排查方向
java.lang.OutOfMemoryError: Java heap space 堆内存不足,无法分配新对象 堆 dump 分析对象占用
java.lang.OutOfMemoryError: GC overhead limit exceeded GC 花费超过 98% 时间但回收少于 2% 内存 通常伴随堆内存不足,可能有内存泄漏
java.lang.OutOfMemoryError: Metaspace 元空间不足,加载的类过多 检查动态类生成(CGLib、反射)
java.lang.OutOfMemoryError: unable to create new native thread 无法创建新线程(操作系统线程数限制或内存不足) 检查线程数、调整 -Xss、系统 ulimit
java.lang.OutOfMemoryError: Direct buffer memory 直接内存(堆外内存)不足 检查 NIO DirectByteBuffer 使用
java.lang.OutOfMemoryError: PermGen space 永久代不足(JDK 7 及之前) 升级到 JDK 8+ 或增大 PermGen

10.1.2 OOM 自动 Dump 配置

生产环境必须配置 OOM 时自动 dump 堆内存:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
-XX:+HeapDumpAfterFullGC   # 可选:Full GC 后也 dump

10.1.3 OOM 排查步骤

  1. 获取堆 dump 文件:通过上述自动 dump 配置,或手动执行 jmap -dump:format=b,file=heap.hprof <pid>
  2. 使用 MAT 分析:用 Eclipse MAT(Memory Analyzer Tool)打开 dump 文件。
  3. 定位大对象:查看 Dominator Tree(支配树),找出占用内存最大的对象。
  4. 分析引用链:查看 GC Roots 到大对象的引用链,找到泄漏源头。
  5. 结合代码排查:根据引用链中的对象类型和代码逻辑,定位具体的内存泄漏代码。

10.2 内存泄漏排查

内存泄漏是指对象不再被使用,但仍然被引用着,无法被 GC 回收。常见场景:

  1. 静态集合类:静态 MapList 等不断添加元素但从不清理。
  2. ThreadLocal:线程池中 ThreadLocal 未调用 remove(),导致线程复用时 ThreadLocal 中的对象无法回收。
  3. 监听器和回调:注册了监听器但未取消注册。
  4. 缓存:缓存无限增长,没有淘汰策略。
  5. 内部类持有外部类引用:非静态内部类隐式持有外部类引用,导致外部类无法回收。

排查方法:

# 1. 查看堆内存变化趋势
jstat -gcutil <pid> 1000

# 2. 多次 dump 对比
jmap -dump:format=b,file=heap1.hprof <pid>
# 间隔一段时间后
jmap -dump:format=b,file=heap2.hprof <pid>

# 3. 使用 MAT 对比两个 dump,找出增长的对象

10.3 CPU 飙高排查

排查步骤

# 1. 找到 CPU 占用最高的 Java 进程
top -c

# 2. 找到该进程中 CPU 占用最高的线程
top -Hp <pid>

# 3. 将线程 PID 转为十六进制
printf "%x\n" <thread_pid>

# 4. 使用 jstack 导出线程堆栈
jstack <pid> > thread_dump.txt

# 5. 在堆栈中搜索十六进制线程 ID
grep <hex_thread_id> thread_dump.txt

常见原因:

  • 死循环或低效算法。
  • 频繁的 Full GC(GC 线程占用 CPU)。
  • 正则表达式回溯导致 CPU 暴涨。
  • 序列化/反序列化大对象。

10.4 GC 频繁 / 停顿过长排查

排查步骤

  1. 分析 GC 日志:使用 GCViewer 或 GCEasy(gceasy.io)在线分析 GC 日志。

  2. 使用 jstat 监控 GC

# 每 1 秒输出一次,共 10 次
jstat -gcutil <pid> 1000 10

# 输出说明:
# S0    S1    E     O     M     CCS   YGC  YGCT  FGC FGCT  GCT
# Survivor0  Survivor1  Eden  Old  Metaspace  CCS  YoungGC次数  YoungGC时间  FullGC次数  FullGC时间  总GC时间
  1. 常见问题与解决方案
现象 可能原因 解决方案
Minor GC 频繁 新生代太小 增大新生代(-Xmn 或调整 NewRatio
Full GC 频繁 老年代空间不足 / 内存泄漏 增大老年代 / 排查内存泄漏
GC 停顿时间长 堆太大 / GC 收集器不合适 换用 G1 或 ZGC / 减小堆
Full GC 后老年代使用率仍高 内存泄漏 Dump 分析

10.5 线程死锁排查

# 使用 jstack 检测死锁
jstack <pid>

# jstack 会自动检测并报告死锁:
# "Found one Java-level deadlock:"
# "Found 1 deadlock."

也可以使用 jconsoleVisualVM 的线程面板可视化检测死锁。

常见死锁类型:

  • 互斥死锁:两个线程互相等待对方持有的锁。
  • 数据库连接死锁:多个事务互相等待对方释放数据库锁。
  • 类初始化死锁:两个线程分别在初始化有循环依赖关系的两个类。

十一、JVM 工具箱

11.1 命令行工具

11.1.1 jps — 查看 Java 进程

# 列出所有 Java 进程
jps -l

# 输出示例:
# 12345 com.example.MainApplication
# 12346 sun.tools.jps.Jps

11.1.2 jstat — GC 统计信息

# 查看 GC 概况(每 1 秒刷新,共 10 次)
jstat -gcutil <pid> 1000 10

# 查看类加载信息
jstat -class <pid>

# 查看编译信息
jstat -compiler <pid>

11.1.3 jmap — 内存映射与堆 dump

# 查看堆内存概况
jmap -heap <pid>

# 查看对象统计(按占用大小排序)
jmap -histo <pid> | head -20

# 只看存活对象(会触发 Full GC,生产慎用)
jmap -histo:live <pid> | head -20

# 生成堆 dump 文件
jmap -dump:format=b,file=heap.hprof <pid>

jmap -histo:livejmap -dump 在 JDK 8 中可能触发 Full GC。JDK 9+ 推荐使用 jcmd 替代。

11.1.4 jstack — 线程堆栈

# 导出所有线程堆栈
jstack <pid>

# 检测死锁
jstack -l <pid>

# 强制打印(进程无响应时)
jstack -F <pid>

11.1.5 jinfo — 查看/修改 JVM 参数

# 查看所有 JVM 参数
jinfo -flags <pid>

# 查看某个参数
jinfo -flag MaxHeapSize <pid>

# 修改某个参数(仅支持可管理参数)
jinfo -flag +PrintGCDetails <pid>

11.1.6 jcmd — 统一命令工具(JDK 8+)

# 列出所有 Java 进程
jcmd -l

# 列出某进程支持的所有命令
jcmd <pid> help

# 生成堆 dump(推荐,不会触发 Full GC)
jcmd <pid> GC.heap_dump /data/dumps/heap.hprof

# 查看 GC 信息
jcmd <pid> GC.class_histogram

# 查看 JVM 信息
jcmd <pid> VM.flags
jcmd <pid> VM.system_properties

11.2 GUI 可视化工具

11.2.1 JConsole

JDK 自带的监控工具,可以监控内存、线程、类加载、GC 等。

jconsole  # 启动后选择目标进程

功能:内存监控、线程监控(死锁检测)、MBean 管理。

11.2.2 VisualVM

功能更强大的可视化工具,支持插件扩展。

jvisualvm

功能:CPU/内存 profiling、堆 dump 分析、线程 dump 分析、插件市场(如 BTrace)。

从 JDK 9 开始,VisualVM 不再随 JDK 一起发布,需要从 https://visualvm.github.io/ 单独下载。最新版本已更名为 VisualVM 2.x,支持 JDK 8~21+。

11.2.3 JMC(Java Mission Control)

JDK 11+ 中成为独立下载的工具,基于 JFR(Java Flight Recorder)提供低开销的生产环境监控。

# 启动 JFR 录制(JDK 11+,需开启商业特性或 JDK 17+ 开源)
jcmd <pid> JFR.start duration=60s filename=/data/recording.jfr

# 启动 JMC 分析
jmc

JFR 的优势是开销极低(< 1%),可以长时间在生产环境中运行,记录的事件包括 GC、内存分配、线程阻塞、I/O 等。

11.2.4 MAT(Eclipse Memory Analyzer Tool)

专门用于分析堆 dump 文件的专业工具。

核心功能:

  • Dominator Tree:显示哪些对象支配了最多内存。
  • Histogram:按类统计对象数量和大小。
  • Leak Suspects Report:自动生成内存泄漏嫌疑报告。
  • Path to GC Roots:查看对象到 GC Roots 的引用链。

11.2.5 Arthas

阿里巴巴开源的 Java 诊断工具,在生产环境中广泛使用。

# 安装并启动
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

常用命令:

命令 功能
dashboard 实时面板(线程、内存、GC)
thread 查看线程信息,thread -b 查找阻塞线程
jad <class> 反编译指定类
watch <class> <method> 方法执行数据观测(入参、返回值、异常)
trace <class> <method> 方法调用链路耗时追踪
stack <class> <method> 查看方法被调用的调用栈
monitor <class> <method> 方法执行统计(成功率、平均耗时等)
heapdump /path 生成堆 dump 文件
vmtool 查询堆中指定类的实例

十二、实战案例

案例一:线上 OOM 排查(Java heap space)

现象:某电商系统在促销活动期间频繁重启,日志报 java.lang.OutOfMemoryError: Java heap space

排查过程

  1. 确认配置:检查启动参数,发现 -Xmx2g 且未配置 -XX:+HeapDumpOnOutOfMemoryError

  2. 添加 dump 配置:修改启动参数,增加:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
  1. 等待复现:下次 OOM 后获取到 heap.hprof 文件。

  2. MAT 分析

    • 打开 Dominator Tree,发现一个 HashMap 占用了 1.2GB(总堆 2GB 的 60%)。
    • 查看 GC Roots 引用链,发现该 HashMap 是某个商品缓存对象。
    • 查看代码,发现缓存逻辑为「查询商品时写入缓存,但永不过期、无淘汰策略」。
  3. 根因:促销期间大量商品查询,商品信息全部缓存到内存中且无淘汰,导致堆内存耗尽。

  4. 解决方案

    • HashMap 替换为 Caffeine 或 Guava Cache,设置最大容量和过期时间。
    • 增大堆内存到 4GB 作为临时缓解。
    • 上线后监控确认 OOM 不再复现。

案例二:CPU 100% 排查

现象:某 API 服务响应变慢,top 显示 Java 进程 CPU 占用接近 100%。

排查过程

  1. 定位进程top 确认 PID 为 12345。

  2. 定位线程top -Hp 12345 发现线程 12378 占用 CPU 85%。

  3. 线程 ID 转换printf "%x\n" 12378305a

  4. 导出堆栈jstack 12345 > /tmp/thread.txt

  5. 搜索线程:在 thread.txt 中搜索 305a

"pool-1-thread-3" #18 prio=5 os_prio=0 tid=0x... nid=0x305a runnable
   java.lang.Thread.State: RUNNABLE
    at java.util.regex.Pattern$Curly.match(Pattern.java:4148)
    at java.util.regex.Pattern$GroupHead.match(Pattern.java:4160)
    ...
  1. 根因:正则表达式回溯灾难(Catastrophic Backtracking)。代码中使用了 (.+)*foo 这样的模式匹配长字符串。

  2. 解决方案

    • 优化正则表达式,避免嵌套量词。
    • 使用预编译的 Pattern 并设置超时。
    • 对输入长度做限制。

案例三:GC 停顿优化

现象:某金融交易系统使用 JDK 8,堆 32GB,CMS 收集器,接口 P99 延迟在 500ms~2s 波动,GC 日志显示 Full GC 停顿 3~8 秒。

排查过程

  1. 分析 GC 日志:使用 GCEasy 分析,发现:

    • Full GC 频率:每 10 分钟一次。
    • Full GC 平均停顿:5.2 秒。
    • 老年代使用率在 Full GC 后仍有 70%(疑似内存碎片)。
  2. 原因分析

    • CMS 使用标记-清除算法,大量内存碎片导致大对象分配失败,频繁触发 Full GC(退化为 Serial Old)。
    • 32GB 堆对 CMS 来说偏大,Serial Old Full GC 的标记-整理停顿很长。
  3. 优化方案

    • 升级到 JDK 17,使用 G1 收集器。
    • 设置 -XX:MaxGCPauseMillis=200
    • 调整 Region 大小为 16MB(-XX:G1HeapRegionSize=16m)。
  4. 优化效果

    • Full GC 消除,转为定期的 Mixed GC。
    • 最大 GC 停顿从 8 秒降至 180ms。
    • P99 延迟稳定在 200ms 以内。

案例四:ThreadLocal 内存泄漏

现象:某 Web 应用在运行一周后老年代使用率持续增长,最终 Full GC 无法回收,触发 OOM。

排查过程

  1. 堆 dump 分析:MAT 显示大量 ThreadLocal$ThreadLocalMap$Entry 对象,每个 Entry 持有一个大对象(用户会话数据)。

  2. 代码审查

public class UserContext {
    private static final ThreadLocal<UserSession> userSession = new ThreadLocal<>();

    public static void set(UserSession session) {
        userSession.set(session);
    }

    public static UserSession get() {
        return userSession.get();
    }
    // 缺少 remove() 方法
}
  1. 根因

    • Web 容器使用线程池,线程被复用,ThreadLocal 中的数据不会随请求结束而清除。
    • ThreadLocalMap 的 Entry 是弱引用 key + 强引用 value。即使 ThreadLocal 实例被回收,value 仍然被 Entry 强引用,无法被 GC 回收。
    • 用户会话数据不断累积,导致内存泄漏。
  2. 解决方案

public class UserContext {
    private static final ThreadLocal<UserSession> userSession = new ThreadLocal<>();

    public static void set(UserSession session) {
        userSession.set(session);
    }

    public static UserSession get() {
        return userSession.get();
    }

    // 新增:在请求结束时清理
    public static void remove() {
        userSession.remove();
    }
}

// 在 Filter 或 Interceptor 中调用
public class UserContextFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
        try {
            chain.doFilter(req, res);
        } finally {
            UserContext.remove(); // 确保清理
        }
    }
}
  1. 效果:上线后老年代使用率稳定在 40% 以下,不再出现 OOM。

案例五:元空间溢出

现象:某系统使用 CGLib 动态生成代理类,运行一段时间后报 java.lang.OutOfMemoryError: Metaspace

排查过程

  1. 查看 Metaspace 使用情况
jstat -gcutil <pid>
# M 列(Metaspace 使用率)接近 100%
  1. 类加载分析:使用 Arthas 的 classloader 命令:
classloader -t
# 发现大量 CGLib 生成的代理类
  1. 根因:每次调用都动态生成新的代理类,且这些代理类由不同的 ClassLoader 加载。虽然原代理类不再使用,但由于 ClassLoader 没有被回收(被其他对象引用),导致类无法卸载,元空间持续增长。

  2. 解决方案

    • 使用缓存池复用代理类,避免重复生成。
    • 增大元空间上限:-XX:MaxMetaspaceSize=512m
    • 确保 ClassLoader 在不再使用时能被 GC 回收,以便类卸载。

附录:JVM 知识体系速查表

JVM 参数速查

类别 参数 说明
-Xms / -Xmx 初始 / 最大堆大小
-Xmn 新生代大小
-XX:NewRatio 老年代:新生代 比例
-XX:SurvivorRatio Eden:Survivor 比例
-Xss 线程栈大小
元空间 -XX:MetaspaceSize / -XX:MaxMetaspaceSize 元空间初始 / 最大
直接内存 -XX:MaxDirectMemorySize 最大直接内存
GC -XX:+UseG1GC 使用 G1
GC -XX:MaxGCPauseMillis 目标停顿时间
GC -XX:+PrintGCDetails 打印 GC 详情(JDK 8)
GC -Xlog:gc* GC 日志(JDK 9+)
诊断 -XX:+HeapDumpOnOutOfMemoryError OOM 时自动 dump
诊断 -XX:HeapDumpPath dump 文件路径

各 JDK 版本默认 GC 收集器

JDK 版本 默认收集器
JDK 8 Parallel Scavenge + Parallel Old
JDK 9 ~ JDK 17 G1
JDK 18+ G1(ZGC 可选)

关键阈值速查

参数 默认值 说明
-XX:MaxTenuringThreshold 15 新生代晋升老年代年龄阈值
-XX:CompileThreshold 10000 JIT 编译阈值(分层编译下不直接生效)
-XX:InitiatingHeapOccupancyPercent 45 G1 触发并发标记的堆占用率
-XX:PretenureSizeThreshold 0(无限制) 大对象直接进入老年代的阈值
-XX:GCTimeRatio 99 吞吐量目标(GC 时间占比 ≤ 1%)