深入理解 JVM:从字节码、类加载、运行时内存到 GC 与调优实战
欢迎你来读这篇博客,这篇博客主要是关于 JVM 的系统性梳理。
JVM 不是一个“背八股”的东西,它是 Java 程序运行时的地基:代码为什么能跨平台、类为什么能动态加载、对象为什么会被回收、线上为什么会 Full GC、为什么一个接口偶尔慢 3 秒、为什么调大堆反而更卡,这些问题都绕不开 JVM。
序言
很多人学习 JVM 时容易走偏:一上来就背“运行时数据区”“双亲委派”“G1 三色标记”,结果线上遇到问题还是只会重启。
真正有用的 JVM 学习路径应该是:
- 先知道 JVM 是什么,它解决了什么问题;
- 再理解
.class字节码文件,因为 JVM 真正执行的是字节码; - 然后理解类加载机制,因为类不是凭空出现在内存里的;
- 接着理解运行时内存区域,因为对象、栈帧、方法元数据都要有地方放;
- 再理解垃圾回收算法和收集器,因为内存问题大部分都绕不开 GC;
- 最后落到调优和诊断工具,因为工程里最终要解决的是生产问题。
一句话:JVM 是 Java 程序从“源码”走向“运行中对象”的整条生命线。
第一章 JVM 简介
1.1 JVM 是什么
JVM,全称 Java Virtual Machine,即 Java 虚拟机。
它本质上是一台“抽象计算机”,拥有自己的指令集、内存模型、类加载机制、异常处理机制和执行引擎。Java 源码不会直接运行在操作系统上,而是先被编译成 .class 字节码文件,再由 JVM 加载、验证、解释或编译执行。
经典流程如下:
1 | |
所以 Java 的跨平台不是“源码跨平台”,而是:
1 | |
也就是常说的:
1 | |
不过别误会,JVM 本身不是跨平台的。Windows、Linux、macOS 上的 JVM 实现是不同的,真正跨平台的是 .class 字节码规范。
1.2 JVM 与混合语言编程
JVM 最大的价值之一是:它并不只服务 Java 语言。
JVM 只认识一种东西:符合 JVM 规范的 class 文件。只要一种语言能编译成合法的 JVM 字节码,它就可以运行在 JVM 上。
常见 JVM 语言包括:
| 语言 | 特点 |
|---|---|
| Java | 最主流,生态最完整 |
| Kotlin | 更简洁,Android 和后端都常用 |
| Scala | 函数式能力强,Spark 生态常见 |
| Groovy | 动态语言,Gradle、脚本场景常见 |
| Clojure | Lisp 方言,强调不可变数据和函数式 |
| JRuby / Jython | Ruby / Python 在 JVM 上的实现路线 |
这就是 JVM 生态很强的原因。它不是单一语言平台,而是一个运行时平台。
比如 Kotlin 编译后也会生成 .class 文件;Scala 也会生成 .class 文件;Groovy 也一样。最后它们都可以复用 JVM 的:
- 类加载机制
- JIT 编译优化
- GC 能力
- 线程模型
- 监控工具
- Java 类库生态
这就是“混合语言编程”的底层基础。
进一步看,GraalVM 又把这种多语言能力推得更远。它支持在一个运行时中进行 Java、JavaScript、Python、Ruby、R 等语言互操作,并且还支持 Native Image,把 Java 程序提前编译成本地可执行文件。
但工程上要清醒一点:混合语言不是为了炫技,而是为了复用生态和降低系统边界成本。
比如:
- Java 后端项目中用 Kotlin 写业务层;
- Scala 生态中调用 Java 类库;
- Groovy 用于构建脚本和动态规则;
- GraalVM 用于低启动时间、低内存占用的云原生场景。
语言可以混,但线上问题最后还是要回到 JVM:线程、内存、GC、类加载、JIT。你可以换衣服,但身体还是那副 JVM 身体。
1.3 JVM 的发展和种类
JVM 不是只有一种实现。JVM 是规范,不同厂商可以根据规范实现自己的虚拟机。
常见 JVM 实现如下:
| JVM 实现 | 说明 |
|---|---|
| HotSpot VM | OpenJDK / Oracle JDK 主流 JVM,当前使用最广泛 |
| Eclipse OpenJ9 | IBM / Eclipse 体系,强调启动速度、低内存占用、云场景 |
| GraalVM | 高性能 JDK 发行版,支持 Graal JIT、Native Image、多语言运行 |
| Azul Zing / Azul Prime | 商业 JVM,强调低延迟、高吞吐、C4 GC、Falcon 编译器 |
| JRockit | Oracle 早期收购 BEA 后的 JVM,后来部分能力融合进 HotSpot |
| Dalvik / ART | Android 上的运行时,严格来说不是标准 HotSpot JVM,但与 Java 字节码生态有关 |
现在主流 Java 服务端开发,大部分情况下默认接触的是 HotSpot。
HotSpot 的核心能力包括:
- 解释器
- C1 / C2 JIT 编译器
- 分层编译
- 逃逸分析
- GC 子系统
- 类加载子系统
- JFR / JMX / jcmd 等诊断能力
JVM 的发展主线可以粗略理解为:
1 | |
从工程视角看,JVM 的演进一直围绕三个目标:
- 启动更快;
- 吞吐更高;
- 延迟更低;
- 内存更省;
- 诊断更强。
是的,这五个目标经常互相打架。JVM 调优本质就是在这些目标之间做取舍。
1.4 JVM 的组成
JVM 可以拆成几大核心模块:
1 | |
可以这样理解:
- 类加载器:把 class 文件搬进 JVM;
- 运行时数据区:给类、对象、线程、方法调用分配位置;
- 执行引擎:真正执行字节码;
- GC:负责回收不再使用的对象;
- JNI:让 Java 可以调用本地 C / C++ 方法;
- 诊断工具:帮你知道 JVM 里面到底发生了什么。
第二章 字节码
2.1 字节码简介
字节码是 JVM 的“机器语言”。
Java 源码经过 javac 编译后,会生成 .class 文件。这个 class 文件不是普通文本,也不是机器码,而是一种严格定义的二进制格式。
示例代码:
1 | |
编译:
1 | |
反编译查看字节码:
1 | |
你会看到类似内容:
1 | |
这段字节码的含义是:
1 | |
这体现了 JVM 的一个重要特点:JVM 是基于操作数栈的虚拟机。
2.2 字节码组成
一个 .class 文件大致由下面这些部分组成:
1 | |
看上去复杂,其实可以拆成几层:
| 区域 | 作用 |
|---|---|
| 魔数 | 判断是不是合法 class 文件 |
| 版本号 | 判断当前 JVM 是否支持这个 class 版本 |
| 常量池 | 存放字面量和符号引用 |
| 访问标志 | public、final、interface、abstract 等 |
| 类索引 | 当前类、父类、接口信息 |
| 字段表 | 成员变量信息 |
| 方法表 | 方法签名、方法字节码 |
| 属性表 | Code、LineNumberTable、Signature、注解等附加信息 |
2.3 魔数和版本号
.class 文件前 4 个字节是魔数:
1 | |
这个值用于标识当前文件是一个合法的 class 文件。名字也挺有 Java 味,咖啡味都溢出来了。
紧接着是版本号:
1 | |
常见版本对应关系:
| Java 版本 | major version |
|---|---|
| Java 8 | 52 |
| Java 11 | 55 |
| Java 17 | 61 |
| Java 21 | 65 |
| Java 25 | 69 |
如果你用高版本 JDK 编译,用低版本 JRE 运行,就可能遇到:
1 | |
这类问题不是代码错,而是字节码版本超出了当前 JVM 能识别的范围。
2.4 常量池
常量池是 class 文件里最重要的结构之一。
它主要存两类东西:
- 字面量;
- 符号引用。
字面量包括:
1 | |
符号引用包括:
1 | |
比如代码:
1 | |
常量池中可能包含:
1 | |
为什么要有符号引用?
因为编译期无法知道所有运行时地址。类加载时,JVM 会把常量池中的一部分符号引用解析为直接引用。这个过程发生在链接阶段的解析过程里。
简单说:
1 | |
常量池是 JVM 动态链接能力的基础。
2.5 类索引与访问标志
class 文件中有几个关键索引:
1 | |
它们指向常量池中的 CONSTANT_Class_info,用于描述:
- 当前类是谁;
- 父类是谁;
- 实现了哪些接口。
访问标志 access_flags 用来描述类或接口的访问权限和性质,比如:
| 标志 | 含义 |
|---|---|
| ACC_PUBLIC | public |
| ACC_FINAL | final |
| ACC_SUPER | 现代 class 文件通常会带 |
| ACC_INTERFACE | 接口 |
| ACC_ABSTRACT | abstract |
| ACC_SYNTHETIC | 编译器生成 |
| ACC_ANNOTATION | 注解 |
| ACC_ENUM | 枚举 |
| ACC_MODULE | 模块 |
需要注意的是,class 文件里保存的不是 Java 源码,而是更接近 JVM 执行需要的信息。源码中的很多语法糖,比如泛型、增强 for、lambda,编译后都会变成 JVM 能理解的结构。
2.6 字段表、方法表、属性表
字段表
字段表 fields 描述类中声明的字段,不包括继承来的字段。
字段信息包括:
1 | |
例如:
1 | |
对应描述符可能是:
1 | |
常见字段描述符:
| Java 类型 | 描述符 |
|---|---|
| int | I |
| long | J |
| float | F |
| double | D |
| boolean | Z |
| void | V |
| String | Ljava/lang/String; |
| int[] | [I |
| Object[] | [Ljava/lang/Object; |
方法表
方法表 methods 描述类中声明的方法。
一个普通方法最终会有一个 Code 属性,里面保存真正的字节码指令。
例如:
1 | |
方法描述符是:
1 | |
意思是:
1 | |
再比如:
1 | |
描述符是:
1 | |
方法表里还可能出现两个特殊方法:
1 | |
其中:
<init>:实例构造方法;<clinit>:类初始化方法,用于静态变量赋值和 static 代码块。
属性表
属性表是 class 文件扩展能力的来源。
常见属性包括:
| 属性 | 作用 |
|---|---|
| Code | 方法字节码 |
| ConstantValue | final 常量值 |
| Exceptions | 方法声明抛出的异常 |
| LineNumberTable | 字节码行号和源码行号映射 |
| LocalVariableTable | 局部变量调试信息 |
| Signature | 泛型签名 |
| RuntimeVisibleAnnotations | 运行时可见注解 |
| SourceFile | 源文件名 |
| InnerClasses | 内部类信息 |
| BootstrapMethods | invokedynamic 相关 |
为什么线上堆栈能显示源码行号?
很大程度就是因为 class 文件里有 LineNumberTable。如果编译时去掉调试信息,排查问题就会难受很多。线上排障最怕什么?不是报错,是报错还不给你行号。那感觉就像导航只告诉你“你在地球上”。
2.7 字节码指令简介
JVM 字节码指令由两部分组成:
1 | |
例如:
1 | |
bipush 是操作码,10 是操作数。
大部分 JVM 指令都是围绕操作数栈工作的,比如:
1 | |
执行过程:
1 | |
这就是为什么理解栈帧时一定要理解“局部变量表”和“操作数栈”。
2.8 字节码指令分类
JVM 指令可以按功能分成几大类。
1. 加载和存储指令
用于在局部变量表和操作数栈之间搬数据。
1 | |
前缀含义:
| 前缀 | 类型 |
|---|---|
| i | int |
| l | long |
| f | float |
| d | double |
| a | reference |
2. 常量入栈指令
1 | |
3. 算术指令
1 | |
类似地还有 long、float、double 版本。
4. 类型转换指令
1 | |
5. 对象创建与访问指令
1 | |
6. 操作数栈管理指令
1 | |
这些指令看似不起眼,但理解它们能帮你看懂对象创建、方法调用、异常处理时的字节码行为。
7. 控制转移指令
1 | |
Java 中的 if、for、while、switch 最终都会变成控制转移指令。
8. 方法调用与返回指令
1 | |
方法调用指令是理解多态、lambda、动态语言支持的关键。
invokevirtual:普通实例方法调用,支持动态分派;invokespecial:构造方法、私有方法、父类方法;invokestatic:静态方法;invokeinterface:接口方法;invokedynamic:动态调用点,lambda、动态语言支持常见。
9. 异常和同步指令
1 | |
synchronized 最终就和 monitorenter、monitorexit 有关。
第三章 类加载机制
3.1 类加载执行过程
类从 .class 文件到能被 JVM 使用,大致经历:
1 | |
注意,“加载”和“类加载机制”不是同一个概念。类加载机制包括加载、链接、初始化一整套流程。
3.2 加载阶段
加载阶段主要做三件事:
- 通过类的全限定名获取二进制字节流;
- 将字节流转换成方法区中的运行时数据结构;
- 在堆中生成一个
java.lang.Class对象,作为访问这个类元数据的入口。
类的字节流不一定来自本地 class 文件,也可以来自:
- jar 包;
- 网络;
- 数据库;
- 动态代理生成;
- 字节码增强框架生成;
- 自定义 ClassLoader 生成。
比如 Spring、MyBatis、Dubbo、CGLIB、ByteBuddy、Arthas 都会在某些场景下和字节码生成或类加载打交道。
3.3 链接阶段
链接分为验证、准备、解析。
1. 验证
验证的目标是确保 class 文件安全、合法,不会破坏 JVM。
验证内容包括:
- 文件格式验证;
- 元数据验证;
- 字节码验证;
- 符号引用验证。
比如 JVM 会检查:
1 | |
验证阶段是 JVM 安全体系的重要组成部分。你不能随便构造一段乱七八糟的字节码就让 JVM 执行,JVM 不是冤大头。
2. 准备
准备阶段为类变量,也就是 static 变量,分配内存并设置默认初始值。
注意,是默认值,不是代码里的赋值。
例如:
1 | |
准备阶段:
1 | |
初始化阶段:
1 | |
如果是 static final 编译期常量,则可能在准备阶段就直接赋值:
1 | |
这类常量会进入常量池,处理方式和普通 static 变量不同。
3. 解析
解析阶段把常量池中的符号引用转换为直接引用。
例如:
1 | |
编译期记录的是:
1 | |
运行期解析后,JVM 才知道具体方法入口在哪里。
解析可能发生在链接阶段,也可能延迟到真正使用时再发生。JVM 规范允许一定的灵活性。
3.4 初始化
初始化阶段真正执行类初始化方法:
1 | |
它由编译器自动收集下面两类代码生成:
1 | |
例如:
1 | |
会被编译器合并到 <clinit> 中。
类初始化触发时机常见有:
- 创建类实例;
- 访问类的静态变量;
- 调用类的静态方法;
- 反射调用类;
- 初始化子类时,先初始化父类;
- JVM 启动时初始化主类;
- MethodHandle 相关调用触发。
示例:
1 | |
输出:
1 | |
父类永远先于子类初始化。
3.5 类加载器
类加载器负责把类加载进 JVM。
JDK 9 以后,常见类加载器包括:
| 类加载器 | 作用 |
|---|---|
| Bootstrap ClassLoader | 加载核心 Java 类库 |
| Platform ClassLoader | 加载平台模块 |
| Application ClassLoader | 加载应用 classpath / modulepath 下的类 |
| Custom ClassLoader | 用户自定义类加载器 |
在代码里查看:
1 | |
你可能会看到:
1 | |
String.class.getClassLoader() 返回 null,不代表没有类加载器,而是表示由 Bootstrap ClassLoader 加载。Bootstrap ClassLoader 是 JVM 内部实现,Java 层通常看不到。
3.6 自定义类加载器
自定义类加载器常见用途:
- 插件化;
- 热部署;
- 隔离不同版本依赖;
- 加密 class 文件加载;
- 动态生成类;
- 多租户隔离;
- 容器中隔离 Web 应用。
简单示例:
1 | |
使用:
1 | |
重点注意:类的唯一性不是只由类名决定,而是由下面两部分共同决定:
1 | |
也就是说,两个不同 ClassLoader 加载同名类,在 JVM 看来是两个不同的类。
这就是为什么有时会出现看起来离谱的错误:
1 | |
这不是 JVM 喝多了,而是两个 com.demo.User 来自不同类加载器。
3.7 双亲委派机制
双亲委派机制的核心流程:
1 | |
伪代码:
1 | |
双亲委派的好处:
- 避免类重复加载;
- 保证 Java 核心类库安全;
- 保证类加载层次稳定。
比如你自己写一个:
1 | |
正常情况下不会替换掉 JDK 自带的 java.lang.String,因为加载请求会优先交给 Bootstrap ClassLoader。
但双亲委派不是绝对不能打破。
常见破坏或绕开场景:
| 场景 | 原因 |
|---|---|
| JDBC SPI | 核心库需要反向加载厂商实现 |
| Tomcat | 不同 Web 应用要隔离依赖 |
| OSGi | 模块化、动态加载、版本隔离 |
| Spring Boot DevTools | 热重启类加载隔离 |
| Arthas / Agent | 运行期增强和诊断 |
| 插件系统 | 插件之间依赖隔离 |
所以双亲委派不是宗教。它是默认安全策略,但工程里有些场景必须做类加载隔离。
第四章 JVM 运行时数据区
4.1 JVM 运行时数据区总览
JVM 运行时数据区可以分为线程私有和线程共享。
1 | |
更直观地看:
1 | |
线程私有区域随着线程创建和销毁。线程共享区域随着 JVM 启动创建,随着 JVM 退出销毁。
4.2 程序计数器
程序计数器可以理解为当前线程执行字节码的“行号指示器”。
每个线程都有自己的程序计数器,因为 CPU 在线程之间切换时,需要知道线程恢复后从哪里继续执行。
如果线程正在执行 Java 方法,程序计数器记录当前字节码指令地址。
如果线程正在执行 Native 方法,程序计数器值可能为空。
程序计数器是 JVM 运行时数据区里唯一一个在 JVM 规范中没有规定 OutOfMemoryError 的区域。
4.3 虚拟机栈与栈帧组成
每个 Java 方法调用都会创建一个栈帧。
方法调用过程:
1 | |
一个栈帧主要包括:
1 | |
示意:
1 | |
如果方法调用太深,就可能出现:
1 | |
比如无限递归:
1 | |
如果线程太多,导致无法为新的栈分配内存,则可能出现:
1 | |
所以线上看到 OOM,不一定是堆爆了,也可能是线程太多。
4.4 局部变量表
局部变量表用于存放:
- 方法参数;
- 方法内部局部变量;
this引用。
例如:
1 | |
实例方法的局部变量表大致是:
1 | |
静态方法没有 this,所以从 slot 0 开始就是第一个参数。
需要注意:
int、float、reference 占 1 个 slot;long、double占 2 个 slot;- boolean、byte、char、short 在局部变量表中通常按 int 处理。
4.5 操作数栈
操作数栈是方法执行时的临时计算区。
还是这个方法:
1 | |
字节码:
1 | |
执行过程:
1 | |
JVM 指令大量围绕操作数栈设计,这也是 JVM 字节码紧凑、跨平台容易的原因之一。
4.6 动态链接与方法返回地址
每个栈帧都包含一个指向运行时常量池的引用,用于支持动态链接。
编译后的 class 文件中,方法调用、字段访问很多都是符号引用。运行时需要把这些符号引用转成直接引用,这就是动态链接的基础。
方法返回地址用于记录方法执行完后,应该回到调用者的哪个位置继续执行。
方法退出有两种方式:
- 正常返回;
- 异常返回。
正常返回对应各种 return 指令:
1 | |
异常返回则是抛出异常后,当前方法没有处理,栈帧被弹出,异常继续向上层调用栈传播。
4.7 本地方法栈
本地方法栈服务于 Native 方法。
Java 可以通过 JNI 调用 C / C++ 等本地方法,例如:
1 | |
线程启动底层就涉及 Native 方法。
本地方法栈和 Java 虚拟机栈类似,也可能出现:
1 | |
实际工程里,直接写 JNI 的场景不算多,但很多底层库、压缩库、加密库、Netty native transport、数据库驱动、操作系统交互都会间接涉及本地内存。
这也是为什么有些 Java 程序堆内存没爆,但进程内存爆了。因为 JVM 进程内存不只包括 Java Heap,还包括:
1 | |
4.8 堆结构
堆是 JVM 中最大的一块内存区域,用于存放对象实例和数组。
传统分代模型:
1 | |
对象通常先分配在 Eden 区。经过多次 Young GC 仍然存活的对象,会晋升到 Old 区。
不过现代 GC 的堆结构已经不完全是固定的 Eden / Survivor / Old 物理分区。
比如 G1 把堆切成多个 Region:
1 | |
ZGC 和 Shenandoah 又有自己的堆管理方式。
所以学习堆结构时要区分:
1 | |
不要把 JDK 8 的图拿来硬套所有现代 GC。八股可以背,线上机器可不会配合你背。
4.9 对象分配过程
一个对象从 new 到完成初始化,大致流程如下:
1 | |
1. 类加载检查
如果要创建的类还没有加载,JVM 需要先触发类加载流程。
2. 分配内存
常见分配方式:
| 方式 | 说明 |
|---|---|
| 指针碰撞 | 堆内存规整,移动指针即可 |
| 空闲列表 | 堆内存不规整,需要维护可用块列表 |
选择哪种方式取决于 GC 是否带压缩整理能力。
3. TLAB
为了减少多线程分配对象时的竞争,JVM 会给每个线程分配一小块私有 Eden 空间,叫 TLAB。
1 | |
大部分小对象都可以在线程自己的 TLAB 中快速分配。
4. 内存清零
JVM 会把分配到的内存初始化为零值。
这也是为什么 Java 对象字段即使不赋值,也有默认值:
1 | |
5. 设置对象头
对象头通常包括:
1 | |
Mark Word 中可能包含:
- 哈希码;
-GC 年龄; - 锁状态;
- 偏向锁信息,旧版本;
- GC 标记信息。
6. 执行构造方法
最后执行 <init>,也就是构造方法。
注意,对象内存分配完成不代表对象初始化完成。构造方法执行完,对象才真正可用。
4.10 GC 日志分析
现代 JDK 推荐使用统一日志:
1 | |
常见 GC 日志关注点:
| 指标 | 含义 |
|---|---|
| Pause Time | GC 停顿时间 |
| GC Cause | GC 触发原因 |
| Before / After | GC 前后堆使用量 |
| Young GC 频率 | 年轻代回收频率 |
| Old 区增长速度 | 长生命周期对象趋势 |
| Promotion | 对象晋升情况 |
| Humongous | 大对象分配情况 |
| Full GC | 是否出现整堆停顿 |
| Metaspace | 类元数据是否增长异常 |
| Safepoint | 是否存在安全点停顿问题 |
示例日志:
1 | |
可以读出:
1 | |
如果你看到 Old 区持续增长,并且 Young GC 后堆下降不明显,就要警惕:
1 | |
GC 日志不是给你看的“垃圾回收流水账”,而是对象生命周期的体检报告。
4.11 方法区
方法区是 JVM 规范中的概念,用于存储类相关信息,比如:
1 | |
注意:方法区是规范概念,具体实现可以不同。
HotSpot 中,方法区经历过明显变化。
4.12 方法区的历史变化
JDK 7 及以前:永久代
早期 HotSpot 使用永久代实现方法区。
常见参数:
1 | |
常见错误:
1 | |
永久代的问题是它在 JVM 堆附近,大小固定,容易因为类太多、动态代理太多、频繁热部署导致 OOM。
JDK 8 以后:元空间
JDK 8 移除了永久代,改用元空间 Metaspace。
常见参数:
1 | |
元空间使用本地内存,不再受 Java 堆大小直接限制。
但这不代表不会 OOM。如果类加载过多、ClassLoader 无法卸载,仍然可能出现:
1 | |
典型场景:
- 动态代理类大量生成;
- CGLIB / ByteBuddy 使用不当;
- Groovy 动态脚本频繁编译;
- 应用容器热部署导致 ClassLoader 泄漏;
- 插件系统没有释放类加载器。
排查 Metaspace 问题时,可以关注:
1 | |
也可以用 Arthas:
1 | |
第五章 垃圾回收算法
5.1 GC 的核心问题
垃圾回收要解决三个问题:
- 哪些对象是垃圾?
- 什么时候回收?
- 如何回收?
判断对象是否存活,现代 JVM 主要使用可达性分析。
从 GC Roots 出发,能被引用链访问到的对象是存活对象;访问不到的对象就是可回收对象。
常见 GC Roots 包括:
1 | |
示意:
1 | |
5.2 GC 算法
1. 标记-清除算法
流程:
1 | |
优点:
- 实现简单;
- 不需要移动对象。
缺点:
- 会产生内存碎片;
- 清除效率不稳定。
示意:
1 | |
碎片多了以后,大对象分配可能失败。
2. 标记-复制算法
流程:
1 | |
优点:
- 没有碎片;
- 分配快。
缺点:
- 浪费一部分空间;
- 存活对象多时复制成本高。
年轻代适合复制算法,因为大部分对象朝生夕死。
3. 标记-整理算法
流程:
1 | |
优点:
- 没有碎片;
- 适合老年代。
缺点:
- 移动对象成本较高;
- 停顿时间可能更长。
4. 分代收集思想
分代收集基于一个经验假设:
1 | |
所以堆被分成:
1 | |
这不是语言语法规定,而是基于大量应用行为总结出来的工程经验。
5. 三色标记
并发 GC 中常见三色标记思想:
| 颜色 | 含义 |
|---|---|
| 白色 | 未访问,可能是垃圾 |
| 灰色 | 已访问,但子引用未扫描完 |
| 黑色 | 已访问,子引用也扫描完 |
并发标记时,用户线程还在修改引用关系,所以需要写屏障或读屏障来维护正确性。
否则可能出现:
1 | |
这就是并发 GC 的难点:不是“扫一遍”那么简单,而是在应用线程继续运行的同时保证对象图判断正确。
5.3 三种年轻代 GC
传统 HotSpot 中常见年轻代收集器有三种:
1 | |
1. Serial 收集器
Serial 年轻代收集器特点:
1 | |
适合:
- 单核环境;
- 小堆应用;
- 客户端程序;
- 简单批处理。
启动参数:
1 | |
它的优点是简单、额外开销低。缺点是单线程 STW,服务端大应用基本不适合。
2. ParNew 收集器
ParNew 可以理解为 Serial 年轻代收集器的多线程版本。
特点:
1 | |
在 JDK 8 时代,ParNew 常和 CMS 搭配使用:
1 | |
不过 CMS 已经是历史方案了,现代 JDK 不建议再把 ParNew / CMS 当主流方案学习和使用。
3. Parallel Scavenge 收集器
Parallel Scavenge 也是年轻代多线程收集器,但它更关注吞吐量。
特点:
1 | |
常见参数:
1 | |
吞吐量可以简单理解为:
1 | |
如果一个批处理任务跑 10 分钟,中间 GC 总共花 10 秒,吞吐就很高。对于离线计算、批处理、后台任务,Parallel GC 仍然可能是不错选择。
5.4 三种老年代 GC
传统 HotSpot 中常见老年代收集器:
1 | |
1. Serial Old
Serial Old 是 Serial 的老年代版本。
特点:
1 | |
适合小堆、客户端或作为某些收集器的兜底方案。
2. Parallel Old
Parallel Old 是 Parallel Scavenge 的老年代版本。
特点:
1 | |
适合:
- 后台计算;
- 批处理;
- 对吞吐要求高、对延迟不敏感的任务。
启动:
1 | |
现代 JDK 中,Parallel GC 通常会配套使用年轻代和老年代的并行回收能力。
3. CMS
CMS,全称 Concurrent Mark Sweep。
特点:
1 | |
CMS 主要阶段:
1 | |
优点:
- 停顿时间比传统老年代 STW 收集器短;
- 曾经是低延迟服务端应用常用选择。
缺点:
- 会产生内存碎片;
- 对 CPU 资源敏感;
- 可能出现 Concurrent Mode Failure;
- 维护复杂。
需要注意:CMS 已经被现代 JDK 淘汰。学习 CMS 的价值更多是理解 GC 发展历史,而不是新项目继续使用。
一句话:JDK 8 老项目你可能还会遇到 CMS;JDK 17/21/25 新项目就别主动选 CMS 了。时代变了,大人。
5.5 G1
G1,全称 Garbage-First。
G1 的目标是:
1 | |
G1 不再把堆固定切成连续的年轻代和老年代,而是拆成很多 Region。
1 | |
G1 的核心思想:
1 | |
所以叫 Garbage-First。
G1 常见 GC 类型:
| 类型 | 说明 |
|---|---|
| Young GC | 回收年轻代 Region |
| Concurrent Marking | 并发标记老年代存活对象 |
| Mixed GC | 同时回收年轻代和部分老年代 Region |
| Full GC | 兜底整堆回收,通常要重点关注 |
常用参数:
1 | |
MaxGCPauseMillis 是目标,不是承诺。JVM 会尽量满足,但如果对象太多、分配太快、堆设置不合理,它也没法变魔术。JVM 不是许愿池。
G1 适合:
- 大多数服务端应用;
- Spring Boot 微服务;
- 中大型堆;
- 需要相对平衡吞吐和延迟的系统。
G1 排查重点:
1 | |
5.6 ZGC
ZGC 是面向低延迟的大堆垃圾回收器。
它的目标是:
1 | |
ZGC 的核心技术包括:
- 并发标记;
- 并发整理;
- 染色指针;
- 读屏障;
- 负载屏障;
- Region 化内存管理;
- 支持超大堆;
- 代际 ZGC。
启动:
1 | |
现代 JDK 中,ZGC 已经从实验特性逐步走向生产可用,并且代际 ZGC 成为重点方向。
ZGC 适合:
- 对延迟极其敏感的服务;
- 大堆应用;
- 低停顿要求高于极致吞吐的场景;
- 推荐在 JDK 17、21、25 等新版本上评估。
但不要神化 ZGC。
ZGC 能降低 GC 停顿,不代表能解决所有慢接口。接口慢可能来自:
1 | |
GC 是锅之一,不是锅王。
5.7 Shenandoah
Shenandoah 也是低停顿 GC,目标和 ZGC 类似:减少 GC 停顿时间,并尽量让停顿和堆大小解耦。
Shenandoah 的特点:
- 并发标记;
- 并发疏散;
- 并发引用更新;
- 低停顿;
- 对大堆友好;
- 有单代和代际模式演进。
启动:
1 | |
Shenandoah 和 ZGC 都属于低延迟 GC,但实现思路不同。具体选谁,要看:
1 | |
调 GC 不要看信仰,要看数据。
5.8 GC 选择建议
简单建议如下:
| 场景 | 推荐 |
|---|---|
| 默认 Spring Boot 服务 | G1 |
| 小工具、小堆、单核 | Serial |
| 批处理、吞吐优先 | Parallel |
| 大堆、低延迟 | ZGC / Shenandoah |
| JDK 8 老系统低停顿 | 可能仍会遇到 CMS |
| 新项目 | 不建议 CMS |
| 极端基准测试、无回收场景 | Epsilon,仅特殊用途 |
我的建议:
1 | |
第六章 JVM 调优
6.1 JVM 优化建议
JVM 调优不是背参数,而是一个诊断流程。
正确顺序:
1 | |
错误方式:
1 | |
这叫“玄学调优”,不是 JVM 调优。
6.2 JVM 调优的三个目标
JVM 调优通常围绕三个目标:
| 目标 | 说明 |
|---|---|
| 吞吐量 | 单位时间完成更多任务 |
| 延迟 | 单次请求停顿更短 |
| 内存占用 | 使用更少资源 |
三者经常冲突。
例如:
- 堆调大,GC 频率可能降低,但单次 Full GC 可能更久;
- 堆调小,内存省了,但 GC 更频繁;
- 低延迟 GC 停顿短,但可能消耗更多 CPU;
- 吞吐优先 GC 效率高,但停顿可能更明显。
所以调优前先问:
1 | |
是接口 P99?
是吞吐?
是成本?
是启动速度?
是内存占用?
是减少 Full GC?
是解决 OOM?
目标不同,方案完全不同。
6.3 通用 JVM 参数建议
1. 设置堆大小
常见:
1 | |
生产环境中,很多服务会让 Xms 和 Xmx 相等,避免运行时动态扩缩堆带来的抖动。
但容器环境下,也可以使用:
1 | |
2. 选择 GC
普通服务:
1 | |
低延迟服务:
1 | |
吞吐优先批处理:
1 | |
3. 打开 GC 日志
JDK 9+:
1 | |
JDK 8:
1 | |
4. OOM 时自动 dump
1 | |
5. 错误日志
1 | |
6. 禁止随意 System.gc
1 | |
不过注意,有些堆外内存框架或旧系统可能依赖显式 GC 行为,是否开启要压测验证。
6.4 常见问题定位思路
1. CPU 飙高
排查步骤:
1 | |
更推荐:
1 | |
如果使用 Arthas:
1 | |
重点看:
- 是否有死循环;
- 是否频繁 GC;
- 是否锁竞争;
- 是否大量序列化 / 加密 / 正则;
- 是否线程池打满;
- 是否 JIT 编译线程异常活跃。
2. 内存持续上涨
先判断是堆内还是堆外。
堆内:
1 | |
堆外:
1 | |
需要启动 NMT:
1 | |
常见原因:
1 | |
3. Full GC 频繁
关注:
1 | |
命令:
1 | |
GC 日志中如果看到:
1 | |
都要重点分析。
4. 接口偶发慢
不要只看平均耗时,要看:
1 | |
建议组合:
1 | |
真正的线上慢接口,经常不是单点问题,而是多个因素叠加。
6.5 JVM 监控命令
1. jps
查看 Java 进程:
1 | |
2. jcmd
jcmd 是现代 JDK 推荐的综合诊断命令。
常用:
1 | |
触发 GC,不建议随便在线上用:
1 | |
3. jstat
观察 GC 趋势:
1 | |
字段大致包括:
| 字段 | 含义 |
|---|---|
| S0 | Survivor 0 使用率 |
| S1 | Survivor 1 使用率 |
| E | Eden 使用率 |
| O | Old 使用率 |
| M | Metaspace 使用率 |
| YGC | Young GC 次数 |
| YGCT | Young GC 总耗时 |
| FGC | Full GC 次数 |
| FGCT | Full GC 总耗时 |
| GCT | GC 总耗时 |
4. jmap
堆 dump:
1 | |
类直方图:
1 | |
注意:live 可能触发 Full GC,线上慎用。
5. jstack
线程栈:
1 | |
重点看:
1 | |
6. JFR / JMC
JFR 适合低开销采集运行时事件:
1 | |
也可以检查:
1 | |
停止:
1 | |
然后用 JDK Mission Control 分析:
- CPU 热点;
- 锁竞争;
- GC;
- 对象分配;
- IO;
- Socket;
- 线程状态;
- 方法采样。
JFR 的好处是视角完整,不像单次 thread dump 只是一个瞬间截图。
6.6 Arthas 深入分析
Arthas 是线上 Java 诊断神器,适合“不改代码、不重启服务”分析运行中 JVM。
安装启动:
1 | |
选择目标 Java 进程后进入 Arthas 控制台。
1. dashboard:看整体状态
1 | |
可以看到:
1 | |
适合第一眼判断系统是否异常:
- CPU 是否高;
- GC 是否频繁;
- 堆是否逼近上限;
- 线程是否异常;
- 是否存在大量阻塞。
2. thread:定位高 CPU 线程
1 | |
查看最忙的 5 个线程。
查看指定线程:
1 | |
排查 CPU 飙高时非常有用。
3. jvm:查看 JVM 信息
1 | |
可以看到:
1 | |
4. memory:查看内存
1 | |
查看堆、非堆、直接内存等信息。
5. sysprop / sysenv
查看系统属性:
1 | |
查看环境变量:
1 | |
线上排查配置问题很有用,比如:
1 | |
6. vmoption
查看 JVM 诊断参数:
1 | |
修改某些可写参数:
1 | |
注意,不是所有 JVM 参数都能运行时修改。
7. sc:查类
1 | |
-d 可以查看类详情,包括:
1 | |
排查类冲突、依赖版本冲突时很有用。
8. sm:查方法
1 | |
可以查看方法签名。
9. jad:反编译线上代码
1 | |
这个命令非常实用。
线上经常出现:
1 | |
直接 jad 看线上 JVM 里加载的类,别靠猜。猜代码是玄学,jad 是科学。
10. trace:分析方法耗时链路
1 | |
只看耗时超过 100ms 的调用:
1 | |
限制次数:
1 | |
trace 适合定位:
1 | |
11. watch:观察入参、返回值、异常
查看方法入参和返回值:
1 | |
查看异常:
1 | |
只观察耗时超过 200ms:
1 | |
watch 适合排查:
1 | |
12. stack:查看方法调用来源
1 | |
只看特定条件:
1 | |
适合回答:
1 | |
13. monitor:方法统计
1 | |
每 5 秒统计一次:
1 | |
适合观察方法级别的运行趋势。
14. tt:时间隧道
记录方法调用:
1 | |
查看记录列表:
1 | |
查看某次调用详情:
1 | |
重放调用:
1 | |
tt 很强,但线上慎用,因为它会记录调用现场,可能带来额外内存和性能开销。
15. profiler:火焰图
启动采样:
1 | |
停止并生成火焰图:
1 | |
适合分析 CPU 热点。
如果一个接口慢,但 trace 看不出明显慢点,可能是 CPU 消耗分散,这时火焰图更适合。
16. heapdump
生成堆 dump:
1 | |
线上慎用,大堆 dump 可能导致明显 IO 和停顿压力。
17. classloader
查看类加载器:
1 | |
适合排查:
1 | |
18. reset 和 stop
Arthas 的 trace/watch/monitor/tt 等命令会做字节码增强。诊断结束后建议:
1 | |
线上使用 Arthas 的原则:
1 | |
工具越强,越要克制。线上不是练功房,是战场。
6.7 JVM 调优实战方法论
我建议把 JVM 问题拆成 5 个维度:
1 | |
内存问题
看:
1 | |
GC 问题
看:
1 | |
线程问题
看:
1 | |
CPU 问题
看:
1 | |
类加载问题
看:
1 | |
6.8 一套推荐的生产 JVM 参数模板
普通 Spring Boot 服务,JDK 17/21/25,G1:
1 | |
低延迟服务,评估 ZGC:
1 | |
容器环境:
1 | |
注意:模板不是银弹。最终要结合压测和线上数据调整。
6.9 JVM 优化的几个经验判断
1. 不要盲目调大堆
堆大可以减少 GC 频率,但也可能增加单次回收成本。
如果是内存泄漏,调大堆只是延迟死亡。
2. 不要看到 Full GC 就只怪 GC
Full GC 是结果,不一定是原因。
可能原因是:
1 | |
3. 不要迷信某个 GC
G1、ZGC、Shenandoah、Parallel 都有适用场景。
选 GC 要看:
1 | |
4. 先优化代码,再调 JVM
很多 JVM 问题本质是代码问题:
1 | |
JVM 调优能救急,但不能替代码还债。
5. 建立基线
上线前至少记录:
1 | |
没有基线,就没有异常。没有异常,就只能靠感觉。靠感觉调 JVM,基本等于闭眼开高达。
结语
JVM 的知识可以分成两层。
第一层是概念:
1 | |
第二层是工程判断:
1 | |
只学第一层,容易变成八股文选手。
真正有价值的是把第一层变成第二层:能定位、能解释、能优化、能复盘。
JVM 并不神秘,它只是很复杂。复杂的东西要拆开看:从字节码到类加载,从栈帧到对象,从 GC 日志到 Arthas,从现象到证据。
最后记住一句话:
1 | |
参考资料
- The Java Virtual Machine Specification, Java SE 25 Edition
- Oracle Java Garbage Collection Tuning Guide
- Oracle Java Troubleshooting Guide
- OpenJDK JEP: G1、ZGC、Shenandoah、CMS 相关提案
- Arthas 官方文档
- GraalVM 官方文档
- Eclipse OpenJ9 官方文档
启示录
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。
JVM 这东西也是一样:别只背远处的概念,要看近处的日志、线程、堆和代码。