深入理解 JVM:从字节码、类加载、运行时内存到 GC 与调优实战

欢迎你来读这篇博客,这篇博客主要是关于 JVM 的系统性梳理。

JVM 不是一个“背八股”的东西,它是 Java 程序运行时的地基:代码为什么能跨平台、类为什么能动态加载、对象为什么会被回收、线上为什么会 Full GC、为什么一个接口偶尔慢 3 秒、为什么调大堆反而更卡,这些问题都绕不开 JVM。

序言

很多人学习 JVM 时容易走偏:一上来就背“运行时数据区”“双亲委派”“G1 三色标记”,结果线上遇到问题还是只会重启。

真正有用的 JVM 学习路径应该是:

  1. 先知道 JVM 是什么,它解决了什么问题;
  2. 再理解 .class 字节码文件,因为 JVM 真正执行的是字节码;
  3. 然后理解类加载机制,因为类不是凭空出现在内存里的;
  4. 接着理解运行时内存区域,因为对象、栈帧、方法元数据都要有地方放;
  5. 再理解垃圾回收算法和收集器,因为内存问题大部分都绕不开 GC;
  6. 最后落到调优和诊断工具,因为工程里最终要解决的是生产问题。

一句话:JVM 是 Java 程序从“源码”走向“运行中对象”的整条生命线。


第一章 JVM 简介

1.1 JVM 是什么

JVM,全称 Java Virtual Machine,即 Java 虚拟机。

它本质上是一台“抽象计算机”,拥有自己的指令集、内存模型、类加载机制、异常处理机制和执行引擎。Java 源码不会直接运行在操作系统上,而是先被编译成 .class 字节码文件,再由 JVM 加载、验证、解释或编译执行。

经典流程如下:

1
2
3
4
5
6
7
8
9
Java 源码
↓ javac 编译
.class 字节码
↓ 类加载器加载
JVM 运行时数据区
↓ 解释器 / JIT 编译器执行
机器码

操作系统 / CPU

所以 Java 的跨平台不是“源码跨平台”,而是:

1
同一份字节码 + 不同平台的 JVM 实现 = 跨平台运行

也就是常说的:

1
Write Once, Run Anywhere

不过别误会,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
2
3
4
5
6
7
8
9
10
11
解释执行

JIT 即时编译

分层编译、逃逸分析、锁优化

G1 成为默认 GC

ZGC / Shenandoah 等低延迟 GC 发展

GraalVM / Native Image / 云原生启动优化

从工程视角看,JVM 的演进一直围绕三个目标:

  1. 启动更快;
  2. 吞吐更高;
  3. 延迟更低;
  4. 内存更省;
  5. 诊断更强。

是的,这五个目标经常互相打架。JVM 调优本质就是在这些目标之间做取舍。

1.4 JVM 的组成

JVM 可以拆成几大核心模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
JVM
├── 类加载子系统
│ ├── 加载
│ ├── 验证
│ ├── 准备
│ ├── 解析
│ └── 初始化

├── 运行时数据区
│ ├── 程序计数器
│ ├── Java 虚拟机栈
│ ├── 本地方法栈
│ ├── 堆
│ └── 方法区

├── 执行引擎
│ ├── 解释器
│ ├── JIT 编译器
│ └── 垃圾回收器

├── 本地方法接口
│ └── JNI

└── 监控诊断体系
├── jcmd
├── jstat
├── jmap
├── jstack
├── JFR / JMC
└── Arthas

可以这样理解:

  • 类加载器:把 class 文件搬进 JVM;
  • 运行时数据区:给类、对象、线程、方法调用分配位置;
  • 执行引擎:真正执行字节码;
  • GC:负责回收不再使用的对象;
  • JNI:让 Java 可以调用本地 C / C++ 方法;
  • 诊断工具:帮你知道 JVM 里面到底发生了什么。

第二章 字节码

2.1 字节码简介

字节码是 JVM 的“机器语言”。

Java 源码经过 javac 编译后,会生成 .class 文件。这个 class 文件不是普通文本,也不是机器码,而是一种严格定义的二进制格式。

示例代码:

1
2
3
4
5
public class Hello {
public int add(int a, int b) {
return a + b;
}
}

编译:

1
javac Hello.java

反编译查看字节码:

1
javap -v Hello.class

你会看到类似内容:

1
2
3
4
5
6
7
8
9
public int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: ireturn

这段字节码的含义是:

1
2
3
4
iload_1  // 把局部变量表中第 1 个变量压入操作数栈
iload_2 // 把局部变量表中第 2 个变量压入操作数栈
iadd // 弹出两个 int,相加后压回操作数栈
ireturn // 返回 int 结果

这体现了 JVM 的一个重要特点:JVM 是基于操作数栈的虚拟机。

2.2 字节码组成

一个 .class 文件大致由下面这些部分组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

看上去复杂,其实可以拆成几层:

区域 作用
魔数 判断是不是合法 class 文件
版本号 判断当前 JVM 是否支持这个 class 版本
常量池 存放字面量和符号引用
访问标志 public、final、interface、abstract 等
类索引 当前类、父类、接口信息
字段表 成员变量信息
方法表 方法签名、方法字节码
属性表 Code、LineNumberTable、Signature、注解等附加信息

2.3 魔数和版本号

.class 文件前 4 个字节是魔数:

1
0xCAFEBABE

这个值用于标识当前文件是一个合法的 class 文件。名字也挺有 Java 味,咖啡味都溢出来了。

紧接着是版本号:

1
2
minor_version
major_version

常见版本对应关系:

Java 版本 major version
Java 8 52
Java 11 55
Java 17 61
Java 21 65
Java 25 69

如果你用高版本 JDK 编译,用低版本 JRE 运行,就可能遇到:

1
UnsupportedClassVersionError

这类问题不是代码错,而是字节码版本超出了当前 JVM 能识别的范围。

2.4 常量池

常量池是 class 文件里最重要的结构之一。

它主要存两类东西:

  1. 字面量;
  2. 符号引用。

字面量包括:

1
2
3
4
字符串常量
整数常量
浮点数常量
long / double 常量

符号引用包括:

1
2
3
4
5
6
7
类和接口的全限定名
字段名称和描述符
方法名称和描述符
方法句柄
动态调用点
模块信息
包信息

比如代码:

1
2
String name = "Mario";
System.out.println(name);

常量池中可能包含:

1
2
3
4
5
6
"Mario"
java/lang/System
out
Ljava/io/PrintStream;
println
(Ljava/lang/String;)V

为什么要有符号引用?

因为编译期无法知道所有运行时地址。类加载时,JVM 会把常量池中的一部分符号引用解析为直接引用。这个过程发生在链接阶段的解析过程里。

简单说:

1
2
符号引用:我要调用 java/lang/System.out.println
直接引用:这个方法在 JVM 运行时内存中的具体入口

常量池是 JVM 动态链接能力的基础。

2.5 类索引与访问标志

class 文件中有几个关键索引:

1
2
3
this_class
super_class
interfaces

它们指向常量池中的 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
2
3
4
access_flags
name_index
descriptor_index
attributes

例如:

1
private String name;

对应描述符可能是:

1
Ljava/lang/String;

常见字段描述符:

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
public int add(int a, int b)

方法描述符是:

1
(II)I

意思是:

1
2
入参:int, int
返回:int

再比如:

1
public void hello(String name)

描述符是:

1
(Ljava/lang/String;)V

方法表里还可能出现两个特殊方法:

1
2
<init>
<clinit>

其中:

  • <init>:实例构造方法;
  • <clinit>:类初始化方法,用于静态变量赋值和 static 代码块。

属性表

属性表是 class 文件扩展能力的来源。

常见属性包括:

属性 作用
Code 方法字节码
ConstantValue final 常量值
Exceptions 方法声明抛出的异常
LineNumberTable 字节码行号和源码行号映射
LocalVariableTable 局部变量调试信息
Signature 泛型签名
RuntimeVisibleAnnotations 运行时可见注解
SourceFile 源文件名
InnerClasses 内部类信息
BootstrapMethods invokedynamic 相关

为什么线上堆栈能显示源码行号?

很大程度就是因为 class 文件里有 LineNumberTable。如果编译时去掉调试信息,排查问题就会难受很多。线上排障最怕什么?不是报错,是报错还不给你行号。那感觉就像导航只告诉你“你在地球上”。

2.7 字节码指令简介

JVM 字节码指令由两部分组成:

1
2
操作码 opcode
操作数 operands

例如:

1
bipush 10

bipush 是操作码,10 是操作数。

大部分 JVM 指令都是围绕操作数栈工作的,比如:

1
2
3
4
iload_1
iload_2
iadd
istore_3

执行过程:

1
局部变量表 -> 操作数栈 -> 计算 -> 操作数栈 -> 局部变量表

这就是为什么理解栈帧时一定要理解“局部变量表”和“操作数栈”。

2.8 字节码指令分类

JVM 指令可以按功能分成几大类。

1. 加载和存储指令

用于在局部变量表和操作数栈之间搬数据。

1
2
3
4
5
6
7
8
9
10
11
iload
lload
fload
dload
aload

istore
lstore
fstore
dstore
astore

前缀含义:

前缀 类型
i int
l long
f float
d double
a reference

2. 常量入栈指令

1
2
3
4
5
6
7
8
aconst_null
iconst_m1
iconst_0 ~ iconst_5
bipush
sipush
ldc
ldc_w
ldc2_w

3. 算术指令

1
2
3
4
5
6
7
iadd
isub
imul
idiv
irem
ineg
iinc

类似地还有 long、float、double 版本。

4. 类型转换指令

1
2
3
4
5
6
i2l
i2f
i2d
l2i
f2i
d2i

5. 对象创建与访问指令

1
2
3
4
5
6
7
8
9
10
new
newarray
anewarray
arraylength
getfield
putfield
getstatic
putstatic
checkcast
instanceof

6. 操作数栈管理指令

1
2
3
4
5
pop
pop2
dup
dup2
swap

这些指令看似不起眼,但理解它们能帮你看懂对象创建、方法调用、异常处理时的字节码行为。

7. 控制转移指令

1
2
3
4
5
6
7
ifeq
ifne
iflt
if_icmpeq
goto
tableswitch
lookupswitch

Java 中的 ifforwhileswitch 最终都会变成控制转移指令。

8. 方法调用与返回指令

1
2
3
4
5
6
7
8
9
10
11
12
invokevirtual
invokespecial
invokestatic
invokeinterface
invokedynamic

ireturn
lreturn
freturn
dreturn
areturn
return

方法调用指令是理解多态、lambda、动态语言支持的关键。

  • invokevirtual:普通实例方法调用,支持动态分派;
  • invokespecial:构造方法、私有方法、父类方法;
  • invokestatic:静态方法;
  • invokeinterface:接口方法;
  • invokedynamic:动态调用点,lambda、动态语言支持常见。

9. 异常和同步指令

1
2
3
athrow
monitorenter
monitorexit

synchronized 最终就和 monitorentermonitorexit 有关。


第三章 类加载机制

3.1 类加载执行过程

类从 .class 文件到能被 JVM 使用,大致经历:

1
2
3
4
5
6
7
8
9
10
11
12
加载 Loading

链接 Linking
├── 验证 Verification
├── 准备 Preparation
└── 解析 Resolution

初始化 Initialization

使用 Using

卸载 Unloading

注意,“加载”和“类加载机制”不是同一个概念。类加载机制包括加载、链接、初始化一整套流程。

3.2 加载阶段

加载阶段主要做三件事:

  1. 通过类的全限定名获取二进制字节流;
  2. 将字节流转换成方法区中的运行时数据结构;
  3. 在堆中生成一个 java.lang.Class 对象,作为访问这个类元数据的入口。

类的字节流不一定来自本地 class 文件,也可以来自:

  • jar 包;
  • 网络;
  • 数据库;
  • 动态代理生成;
  • 字节码增强框架生成;
  • 自定义 ClassLoader 生成。

比如 Spring、MyBatis、Dubbo、CGLIB、ByteBuddy、Arthas 都会在某些场景下和字节码生成或类加载打交道。

3.3 链接阶段

链接分为验证、准备、解析。

1. 验证

验证的目标是确保 class 文件安全、合法,不会破坏 JVM。

验证内容包括:

  • 文件格式验证;
  • 元数据验证;
  • 字节码验证;
  • 符号引用验证。

比如 JVM 会检查:

1
2
3
4
5
6
7
class 文件魔数是否正确
版本号是否支持
常量池结构是否合法
父类是否存在
final 类是否被继承
操作数栈类型是否匹配
方法返回类型是否正确

验证阶段是 JVM 安全体系的重要组成部分。你不能随便构造一段乱七八糟的字节码就让 JVM 执行,JVM 不是冤大头。

2. 准备

准备阶段为类变量,也就是 static 变量,分配内存并设置默认初始值。

注意,是默认值,不是代码里的赋值。

例如:

1
public static int count = 10;

准备阶段:

1
count = 0

初始化阶段:

1
count = 10

如果是 static final 编译期常量,则可能在准备阶段就直接赋值:

1
public static final int COUNT = 10;

这类常量会进入常量池,处理方式和普通 static 变量不同。

3. 解析

解析阶段把常量池中的符号引用转换为直接引用。

例如:

1
2
UserService userService;
userService.query();

编译期记录的是:

1
com/example/UserService.query:()V

运行期解析后,JVM 才知道具体方法入口在哪里。

解析可能发生在链接阶段,也可能延迟到真正使用时再发生。JVM 规范允许一定的灵活性。

3.4 初始化

初始化阶段真正执行类初始化方法:

1
<clinit>

它由编译器自动收集下面两类代码生成:

1
2
static 变量显式赋值
static 代码块

例如:

1
2
3
4
5
6
7
public class User {
static int age = 18;

static {
System.out.println("init");
}
}

会被编译器合并到 <clinit> 中。

类初始化触发时机常见有:

  1. 创建类实例;
  2. 访问类的静态变量;
  3. 调用类的静态方法;
  4. 反射调用类;
  5. 初始化子类时,先初始化父类;
  6. JVM 启动时初始化主类;
  7. MethodHandle 相关调用触发。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Parent {
static {
System.out.println("Parent init");
}
}

class Child extends Parent {
static {
System.out.println("Child init");
}
}

public class Test {
public static void main(String[] args) {
new Child();
}
}

输出:

1
2
Parent init
Child init

父类永远先于子类初始化。

3.5 类加载器

类加载器负责把类加载进 JVM。

JDK 9 以后,常见类加载器包括:

类加载器 作用
Bootstrap ClassLoader 加载核心 Java 类库
Platform ClassLoader 加载平台模块
Application ClassLoader 加载应用 classpath / modulepath 下的类
Custom ClassLoader 用户自定义类加载器

在代码里查看:

1
2
3
4
5
6
7
public class ClassLoaderDemo {
public static void main(String[] args) {
System.out.println(String.class.getClassLoader());
System.out.println(ClassLoaderDemo.class.getClassLoader());
System.out.println(ClassLoader.getSystemClassLoader());
}
}

你可能会看到:

1
2
3
null
jdk.internal.loader.ClassLoaders$AppClassLoader@...
jdk.internal.loader.ClassLoaders$AppClassLoader@...

String.class.getClassLoader() 返回 null,不代表没有类加载器,而是表示由 Bootstrap ClassLoader 加载。Bootstrap ClassLoader 是 JVM 内部实现,Java 层通常看不到。

3.6 自定义类加载器

自定义类加载器常见用途:

  • 插件化;
  • 热部署;
  • 隔离不同版本依赖;
  • 加密 class 文件加载;
  • 动态生成类;
  • 多租户隔离;
  • 容器中隔离 Web 应用。

简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MyClassLoader extends ClassLoader {

private final String classPath;

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

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
String fileName = name.replace(".", "/") + ".class";
java.nio.file.Path path = java.nio.file.Paths.get(classPath, fileName);
byte[] bytes = java.nio.file.Files.readAllBytes(path);
return defineClass(name, bytes, 0, bytes.length);
} catch (Exception e) {
throw new ClassNotFoundException(name, e);
}
}
}

使用:

1
2
3
MyClassLoader loader = new MyClassLoader("/tmp/classes");
Class<?> clazz = loader.loadClass("com.demo.Hello");
Object instance = clazz.getDeclaredConstructor().newInstance();

重点注意:类的唯一性不是只由类名决定,而是由下面两部分共同决定:

1
类全限定名 + 加载它的 ClassLoader

也就是说,两个不同 ClassLoader 加载同名类,在 JVM 看来是两个不同的类。

这就是为什么有时会出现看起来离谱的错误:

1
ClassCastException: com.demo.User cannot be cast to com.demo.User

这不是 JVM 喝多了,而是两个 com.demo.User 来自不同类加载器。

3.7 双亲委派机制

双亲委派机制的核心流程:

1
2
3
4
5
6
7
类加载请求

先交给父加载器尝试加载

父加载器加载不到

再由当前加载器加载

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
protected Class<?> loadClass(String name, boolean resolve) {
Class<?> c = findLoadedClass(name);

if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// parent 找不到,自己再加载
}

if (c == null) {
c = findClass(name);
}
}

if (resolve) {
resolveClass(c);
}

return c;
}

双亲委派的好处:

  1. 避免类重复加载;
  2. 保证 Java 核心类库安全;
  3. 保证类加载层次稳定。

比如你自己写一个:

1
2
3
4
package java.lang;

public class String {
}

正常情况下不会替换掉 JDK 自带的 java.lang.String,因为加载请求会优先交给 Bootstrap ClassLoader。

但双亲委派不是绝对不能打破。

常见破坏或绕开场景:

场景 原因
JDBC SPI 核心库需要反向加载厂商实现
Tomcat 不同 Web 应用要隔离依赖
OSGi 模块化、动态加载、版本隔离
Spring Boot DevTools 热重启类加载隔离
Arthas / Agent 运行期增强和诊断
插件系统 插件之间依赖隔离

所以双亲委派不是宗教。它是默认安全策略,但工程里有些场景必须做类加载隔离。


第四章 JVM 运行时数据区

4.1 JVM 运行时数据区总览

JVM 运行时数据区可以分为线程私有和线程共享。

1
2
3
4
5
6
7
8
线程私有:
├── 程序计数器
├── Java 虚拟机栈
└── 本地方法栈

线程共享:
├── 堆
└── 方法区

更直观地看:

1
2
3
4
5
6
7
8
每个线程一份:
PC Register
JVM Stack
Native Method Stack

所有线程共享:
Heap
Method Area

线程私有区域随着线程创建和销毁。线程共享区域随着 JVM 启动创建,随着 JVM 退出销毁。

4.2 程序计数器

程序计数器可以理解为当前线程执行字节码的“行号指示器”。

每个线程都有自己的程序计数器,因为 CPU 在线程之间切换时,需要知道线程恢复后从哪里继续执行。

如果线程正在执行 Java 方法,程序计数器记录当前字节码指令地址。

如果线程正在执行 Native 方法,程序计数器值可能为空。

程序计数器是 JVM 运行时数据区里唯一一个在 JVM 规范中没有规定 OutOfMemoryError 的区域。

4.3 虚拟机栈与栈帧组成

每个 Java 方法调用都会创建一个栈帧。

方法调用过程:

1
2
方法调用 -> 创建栈帧 -> 入栈
方法返回 -> 栈帧出栈

一个栈帧主要包括:

1
2
3
4
5
局部变量表
操作数栈
动态链接
方法返回地址
附加信息

示意:

1
2
3
4
5
6
7
8
Java 虚拟机栈
┌──────────────────┐
│ main() 栈帧 │
├──────────────────┤
│ service() 栈帧 │
├──────────────────┤
│ dao() 栈帧 │
└──────────────────┘

如果方法调用太深,就可能出现:

1
StackOverflowError

比如无限递归:

1
2
3
public void loop() {
loop();
}

如果线程太多,导致无法为新的栈分配内存,则可能出现:

1
OutOfMemoryError: unable to create native thread

所以线上看到 OOM,不一定是堆爆了,也可能是线程太多。

4.4 局部变量表

局部变量表用于存放:

  • 方法参数;
  • 方法内部局部变量;
  • this 引用。

例如:

1
2
3
4
public int add(int a, int b) {
int c = a + b;
return c;
}

实例方法的局部变量表大致是:

1
2
3
4
slot 0: this
slot 1: a
slot 2: b
slot 3: c

静态方法没有 this,所以从 slot 0 开始就是第一个参数。

需要注意:

  • intfloat、reference 占 1 个 slot;
  • longdouble 占 2 个 slot;
  • boolean、byte、char、short 在局部变量表中通常按 int 处理。

4.5 操作数栈

操作数栈是方法执行时的临时计算区。

还是这个方法:

1
2
3
public int add(int a, int b) {
return a + b;
}

字节码:

1
2
3
4
iload_1
iload_2
iadd
ireturn

执行过程:

1
2
3
4
iload_1: 把 a 压入操作数栈
iload_2: 把 b 压入操作数栈
iadd: 弹出 a 和 b,相加后结果入栈
ireturn: 返回栈顶结果

JVM 指令大量围绕操作数栈设计,这也是 JVM 字节码紧凑、跨平台容易的原因之一。

4.6 动态链接与方法返回地址

每个栈帧都包含一个指向运行时常量池的引用,用于支持动态链接。

编译后的 class 文件中,方法调用、字段访问很多都是符号引用。运行时需要把这些符号引用转成直接引用,这就是动态链接的基础。

方法返回地址用于记录方法执行完后,应该回到调用者的哪个位置继续执行。

方法退出有两种方式:

  1. 正常返回;
  2. 异常返回。

正常返回对应各种 return 指令:

1
2
3
4
5
6
ireturn
lreturn
freturn
dreturn
areturn
return

异常返回则是抛出异常后,当前方法没有处理,栈帧被弹出,异常继续向上层调用栈传播。

4.7 本地方法栈

本地方法栈服务于 Native 方法。

Java 可以通过 JNI 调用 C / C++ 等本地方法,例如:

1
public native void start0();

线程启动底层就涉及 Native 方法。

本地方法栈和 Java 虚拟机栈类似,也可能出现:

1
2
StackOverflowError
OutOfMemoryError

实际工程里,直接写 JNI 的场景不算多,但很多底层库、压缩库、加密库、Netty native transport、数据库驱动、操作系统交互都会间接涉及本地内存。

这也是为什么有些 Java 程序堆内存没爆,但进程内存爆了。因为 JVM 进程内存不只包括 Java Heap,还包括:

1
2
3
4
5
6
7
8
9
Java Heap
Metaspace
Thread Stack
Code Cache
Direct Memory
GC 内部结构
JNI Native Memory
Mapped Buffer
JIT 编译产物

4.8 堆结构

堆是 JVM 中最大的一块内存区域,用于存放对象实例和数组。

传统分代模型:

1
2
3
4
5
6
Heap
├── Young Generation
│ ├── Eden
│ ├── Survivor 0
│ └── Survivor 1
└── Old Generation

对象通常先分配在 Eden 区。经过多次 Young GC 仍然存活的对象,会晋升到 Old 区。

不过现代 GC 的堆结构已经不完全是固定的 Eden / Survivor / Old 物理分区。

比如 G1 把堆切成多个 Region:

1
2
3
4
5
6
G1 Heap
├── Eden Region
├── Survivor Region
├── Old Region
├── Humongous Region
└── Free Region

ZGC 和 Shenandoah 又有自己的堆管理方式。

所以学习堆结构时要区分:

1
2
分代理论模型
具体 GC 的实现模型

不要把 JDK 8 的图拿来硬套所有现代 GC。八股可以背,线上机器可不会配合你背。

4.9 对象分配过程

一个对象从 new 到完成初始化,大致流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
遇到 new 指令

检查类是否已加载

类加载 / 链接 / 初始化

计算对象大小

在堆中分配内存

内存清零

设置对象头

执行 <init> 构造方法

返回对象引用

1. 类加载检查

如果要创建的类还没有加载,JVM 需要先触发类加载流程。

2. 分配内存

常见分配方式:

方式 说明
指针碰撞 堆内存规整,移动指针即可
空闲列表 堆内存不规整,需要维护可用块列表

选择哪种方式取决于 GC 是否带压缩整理能力。

3. TLAB

为了减少多线程分配对象时的竞争,JVM 会给每个线程分配一小块私有 Eden 空间,叫 TLAB。

1
Thread Local Allocation Buffer

大部分小对象都可以在线程自己的 TLAB 中快速分配。

4. 内存清零

JVM 会把分配到的内存初始化为零值。

这也是为什么 Java 对象字段即使不赋值,也有默认值:

1
2
3
int -> 0
boolean -> false
reference -> null

5. 设置对象头

对象头通常包括:

1
2
3
Mark Word
Class Pointer
Array Length,数组对象才有

Mark Word 中可能包含:

  • 哈希码;
    -GC 年龄;
  • 锁状态;
  • 偏向锁信息,旧版本;
  • GC 标记信息。

6. 执行构造方法

最后执行 <init>,也就是构造方法。

注意,对象内存分配完成不代表对象初始化完成。构造方法执行完,对象才真正可用。

4.10 GC 日志分析

现代 JDK 推荐使用统一日志:

1
-Xlog:gc*,safepoint:file=/logs/gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=100m

常见 GC 日志关注点:

指标 含义
Pause Time GC 停顿时间
GC Cause GC 触发原因
Before / After GC 前后堆使用量
Young GC 频率 年轻代回收频率
Old 区增长速度 长生命周期对象趋势
Promotion 对象晋升情况
Humongous 大对象分配情况
Full GC 是否出现整堆停顿
Metaspace 类元数据是否增长异常
Safepoint 是否存在安全点停顿问题

示例日志:

1
2
3
4
5
[2.345s][info][gc,start] GC(12) Pause Young (Normal) (G1 Evacuation Pause)
[2.350s][info][gc,heap ] GC(12) Eden regions: 20->0(18)
[2.350s][info][gc,heap ] GC(12) Survivor regions: 2->3(3)
[2.350s][info][gc,heap ] GC(12) Old regions: 10->12
[2.350s][info][gc ] GC(12) Pause Young (Normal) 256M->128M(1024M) 5.123ms

可以读出:

1
2
3
4
5
6
这是一次 G1 Young GC
停顿 5.123ms
堆从 256M 降到 128M
Eden 被清空
Survivor 增加
Old 区从 10 个 region 增加到 12 个 region

如果你看到 Old 区持续增长,并且 Young GC 后堆下降不明显,就要警惕:

1
2
3
4
5
6
对象生命周期过长
缓存未释放
集合无限增长
线程池任务堆积
连接未关闭
ClassLoader 泄漏

GC 日志不是给你看的“垃圾回收流水账”,而是对象生命周期的体检报告。

4.11 方法区

方法区是 JVM 规范中的概念,用于存储类相关信息,比如:

1
2
3
4
5
6
7
类元信息
运行时常量池
字段信息
方法信息
方法字节码
静态变量相关信息
JIT 编译后的代码缓存相关结构,具体实现相关

注意:方法区是规范概念,具体实现可以不同。

HotSpot 中,方法区经历过明显变化。

4.12 方法区的历史变化

JDK 7 及以前:永久代

早期 HotSpot 使用永久代实现方法区。

常见参数:

1
2
-XX:PermSize
-XX:MaxPermSize

常见错误:

1
java.lang.OutOfMemoryError: PermGen space

永久代的问题是它在 JVM 堆附近,大小固定,容易因为类太多、动态代理太多、频繁热部署导致 OOM。

JDK 8 以后:元空间

JDK 8 移除了永久代,改用元空间 Metaspace。

常见参数:

1
2
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

元空间使用本地内存,不再受 Java 堆大小直接限制。

但这不代表不会 OOM。如果类加载过多、ClassLoader 无法卸载,仍然可能出现:

1
java.lang.OutOfMemoryError: Metaspace

典型场景:

  • 动态代理类大量生成;
  • CGLIB / ByteBuddy 使用不当;
  • Groovy 动态脚本频繁编译;
  • 应用容器热部署导致 ClassLoader 泄漏;
  • 插件系统没有释放类加载器。

排查 Metaspace 问题时,可以关注:

1
2
3
jcmd <pid> VM.native_memory summary
jcmd <pid> GC.class_stats
jcmd <pid> GC.class_histogram

也可以用 Arthas:

1
2
classloader
sc -d com.example.*

第五章 垃圾回收算法

5.1 GC 的核心问题

垃圾回收要解决三个问题:

  1. 哪些对象是垃圾?
  2. 什么时候回收?
  3. 如何回收?

判断对象是否存活,现代 JVM 主要使用可达性分析。

从 GC Roots 出发,能被引用链访问到的对象是存活对象;访问不到的对象就是可回收对象。

常见 GC Roots 包括:

1
2
3
4
5
6
7
8
虚拟机栈中的引用
本地方法栈中的引用
方法区中的静态变量引用
方法区中的常量引用
被 synchronized 持有的对象
JVM 内部引用
活动线程对象
JNI 引用

示意:

1
2
3
4
GC Roots

A -> B -> C 存活
D -> E 不可达,可回收

5.2 GC 算法

1. 标记-清除算法

流程:

1
2
标记存活对象
清除未标记对象

优点:

  • 实现简单;
  • 不需要移动对象。

缺点:

  • 会产生内存碎片;
  • 清除效率不稳定。

示意:

1
2
3
4
5
回收前:
[活][死][活][死][死][活]

回收后:
[活][空][活][空][空][活]

碎片多了以后,大对象分配可能失败。

2. 标记-复制算法

流程:

1
2
3
4
把内存分成两块
每次只用一块
GC 时把存活对象复制到另一块
清空原区域

优点:

  • 没有碎片;
  • 分配快。

缺点:

  • 浪费一部分空间;
  • 存活对象多时复制成本高。

年轻代适合复制算法,因为大部分对象朝生夕死。

3. 标记-整理算法

流程:

1
2
3
标记存活对象
把存活对象向一端移动
清理边界外内存

优点:

  • 没有碎片;
  • 适合老年代。

缺点:

  • 移动对象成本较高;
  • 停顿时间可能更长。

4. 分代收集思想

分代收集基于一个经验假设:

1
大多数对象生命周期很短,少数对象生命周期很长。

所以堆被分成:

1
2
年轻代:频繁回收,复制算法
老年代:较少回收,标记-清除或标记-整理

这不是语言语法规定,而是基于大量应用行为总结出来的工程经验。

5. 三色标记

并发 GC 中常见三色标记思想:

颜色 含义
白色 未访问,可能是垃圾
灰色 已访问,但子引用未扫描完
黑色 已访问,子引用也扫描完

并发标记时,用户线程还在修改引用关系,所以需要写屏障或读屏障来维护正确性。

否则可能出现:

1
对象明明还活着,却被错误回收

这就是并发 GC 的难点:不是“扫一遍”那么简单,而是在应用线程继续运行的同时保证对象图判断正确。

5.3 三种年轻代 GC

传统 HotSpot 中常见年轻代收集器有三种:

1
2
3
Serial
ParNew
Parallel Scavenge

1. Serial 收集器

Serial 年轻代收集器特点:

1
2
3
单线程
Stop The World
复制算法

适合:

  • 单核环境;
  • 小堆应用;
  • 客户端程序;
  • 简单批处理。

启动参数:

1
-XX:+UseSerialGC

它的优点是简单、额外开销低。缺点是单线程 STW,服务端大应用基本不适合。

2. ParNew 收集器

ParNew 可以理解为 Serial 年轻代收集器的多线程版本。

特点:

1
2
3
4
多线程
Stop The World
复制算法
可与 CMS 搭配

在 JDK 8 时代,ParNew 常和 CMS 搭配使用:

1
-XX:+UseConcMarkSweepGC

不过 CMS 已经是历史方案了,现代 JDK 不建议再把 ParNew / CMS 当主流方案学习和使用。

3. Parallel Scavenge 收集器

Parallel Scavenge 也是年轻代多线程收集器,但它更关注吞吐量。

特点:

1
2
3
4
多线程
Stop The World
复制算法
吞吐优先

常见参数:

1
2
3
-XX:+UseParallelGC
-XX:MaxGCPauseMillis=200
-XX:GCTimeRatio=99

吞吐量可以简单理解为:

1
应用运行时间 / 总运行时间

如果一个批处理任务跑 10 分钟,中间 GC 总共花 10 秒,吞吐就很高。对于离线计算、批处理、后台任务,Parallel GC 仍然可能是不错选择。

5.4 三种老年代 GC

传统 HotSpot 中常见老年代收集器:

1
2
3
Serial Old
Parallel Old
CMS

1. Serial Old

Serial Old 是 Serial 的老年代版本。

特点:

1
2
3
单线程
Stop The World
标记-整理

适合小堆、客户端或作为某些收集器的兜底方案。

2. Parallel Old

Parallel Old 是 Parallel Scavenge 的老年代版本。

特点:

1
2
3
4
多线程
Stop The World
标记-整理
吞吐优先

适合:

  • 后台计算;
  • 批处理;
  • 对吞吐要求高、对延迟不敏感的任务。

启动:

1
-XX:+UseParallelGC

现代 JDK 中,Parallel GC 通常会配套使用年轻代和老年代的并行回收能力。

3. CMS

CMS,全称 Concurrent Mark Sweep。

特点:

1
2
3
4
并发标记
并发清除
低停顿
基于标记-清除

CMS 主要阶段:

1
2
3
4
初始标记 STW
并发标记
重新标记 STW
并发清除

优点:

  • 停顿时间比传统老年代 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
2
3
4
5
6
7
Heap
├── Region 1 Eden
├── Region 2 Survivor
├── Region 3 Old
├── Region 4 Free
├── Region 5 Humongous
└── ...

G1 的核心思想:

1
优先回收垃圾最多、收益最高的 Region

所以叫 Garbage-First。

G1 常见 GC 类型:

类型 说明
Young GC 回收年轻代 Region
Concurrent Marking 并发标记老年代存活对象
Mixed GC 同时回收年轻代和部分老年代 Region
Full GC 兜底整堆回收,通常要重点关注

常用参数:

1
2
3
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45

MaxGCPauseMillis 是目标,不是承诺。JVM 会尽量满足,但如果对象太多、分配太快、堆设置不合理,它也没法变魔术。JVM 不是许愿池。

G1 适合:

  • 大多数服务端应用;
  • Spring Boot 微服务;
  • 中大型堆;
  • 需要相对平衡吞吐和延迟的系统。

G1 排查重点:

1
2
3
4
5
6
Young GC 是否过于频繁
Mixed GC 是否能有效降低 Old 区
是否出现 Humongous allocation
是否出现 Full GC
RSet 开销是否过高
Evacuation Failure 是否出现

5.6 ZGC

ZGC 是面向低延迟的大堆垃圾回收器。

它的目标是:

1
尽量把 GC 停顿控制在非常短的范围内,并且让停顿时间不随堆大小线性增长。

ZGC 的核心技术包括:

  • 并发标记;
  • 并发整理;
  • 染色指针;
  • 读屏障;
  • 负载屏障;
  • Region 化内存管理;
  • 支持超大堆;
  • 代际 ZGC。

启动:

1
-XX:+UseZGC

现代 JDK 中,ZGC 已经从实验特性逐步走向生产可用,并且代际 ZGC 成为重点方向。

ZGC 适合:

  • 对延迟极其敏感的服务;
  • 大堆应用;
  • 低停顿要求高于极致吞吐的场景;
  • 推荐在 JDK 17、21、25 等新版本上评估。

但不要神化 ZGC。

ZGC 能降低 GC 停顿,不代表能解决所有慢接口。接口慢可能来自:

1
2
3
4
5
6
7
数据库慢
锁竞争
线程池耗尽
远程调用超时
IO 阻塞
CPU 打满
对象分配过于离谱

GC 是锅之一,不是锅王。

5.7 Shenandoah

Shenandoah 也是低停顿 GC,目标和 ZGC 类似:减少 GC 停顿时间,并尽量让停顿和堆大小解耦。

Shenandoah 的特点:

  • 并发标记;
  • 并发疏散;
  • 并发引用更新;
  • 低停顿;
  • 对大堆友好;
  • 有单代和代际模式演进。

启动:

1
-XX:+UseShenandoahGC

Shenandoah 和 ZGC 都属于低延迟 GC,但实现思路不同。具体选谁,要看:

1
2
3
4
5
6
7
JDK 发行版是否支持
部署平台是否支持
应用分配速率
延迟目标
吞吐目标
CPU 资源
线上观测结果

调 GC 不要看信仰,要看数据。

5.8 GC 选择建议

简单建议如下:

场景 推荐
默认 Spring Boot 服务 G1
小工具、小堆、单核 Serial
批处理、吞吐优先 Parallel
大堆、低延迟 ZGC / Shenandoah
JDK 8 老系统低停顿 可能仍会遇到 CMS
新项目 不建议 CMS
极端基准测试、无回收场景 Epsilon,仅特殊用途

我的建议:

1
2
3
4
JDK 17/21/25 普通服务:先用 G1
明确低延迟目标:评估 ZGC
有厂商支持和场景匹配:评估 Shenandoah
不要一上来就魔改几十个 GC 参数

第六章 JVM 调优

6.1 JVM 优化建议

JVM 调优不是背参数,而是一个诊断流程。

正确顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
明确目标

采集数据

定位瓶颈

提出假设

小步调整

压测验证

上线观察

错误方式:

1
2
3
4
网上复制一堆 -XX 参数
感觉很专业
上线
爆炸

这叫“玄学调优”,不是 JVM 调优。

6.2 JVM 调优的三个目标

JVM 调优通常围绕三个目标:

目标 说明
吞吐量 单位时间完成更多任务
延迟 单次请求停顿更短
内存占用 使用更少资源

三者经常冲突。

例如:

  • 堆调大,GC 频率可能降低,但单次 Full GC 可能更久;
  • 堆调小,内存省了,但 GC 更频繁;
  • 低延迟 GC 停顿短,但可能消耗更多 CPU;
  • 吞吐优先 GC 效率高,但停顿可能更明显。

所以调优前先问:

1
我到底要优化什么?

是接口 P99?
是吞吐?
是成本?
是启动速度?
是内存占用?
是减少 Full GC?
是解决 OOM?

目标不同,方案完全不同。

6.3 通用 JVM 参数建议

1. 设置堆大小

常见:

1
2
-Xms2g
-Xmx2g

生产环境中,很多服务会让 XmsXmx 相等,避免运行时动态扩缩堆带来的抖动。

但容器环境下,也可以使用:

1
2
-XX:InitialRAMPercentage=50
-XX:MaxRAMPercentage=75

2. 选择 GC

普通服务:

1
-XX:+UseG1GC

低延迟服务:

1
-XX:+UseZGC

吞吐优先批处理:

1
-XX:+UseParallelGC

3. 打开 GC 日志

JDK 9+:

1
-Xlog:gc*,safepoint:file=/logs/gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=100m

JDK 8:

1
2
3
4
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/logs/gc.log

4. OOM 时自动 dump

1
2
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/logs/heapdump.hprof

5. 错误日志

1
-XX:ErrorFile=/logs/hs_err_pid%p.log

6. 禁止随意 System.gc

1
-XX:+DisableExplicitGC

不过注意,有些堆外内存框架或旧系统可能依赖显式 GC 行为,是否开启要压测验证。

6.4 常见问题定位思路

1. CPU 飙高

排查步骤:

1
2
3
top -Hp <pid>
printf "%x\n" <tid>
jstack <pid> | grep -A 30 <nid>

更推荐:

1
jcmd <pid> Thread.print -l

如果使用 Arthas:

1
2
thread -n 5
thread <threadId>

重点看:

  • 是否有死循环;
  • 是否频繁 GC;
  • 是否锁竞争;
  • 是否大量序列化 / 加密 / 正则;
  • 是否线程池打满;
  • 是否 JIT 编译线程异常活跃。

2. 内存持续上涨

先判断是堆内还是堆外。

堆内:

1
2
3
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram
jmap -dump:format=b,file=heap.hprof <pid>

堆外:

1
jcmd <pid> VM.native_memory summary

需要启动 NMT:

1
-XX:NativeMemoryTracking=summary

常见原因:

1
2
3
4
5
6
7
8
缓存无限增长
Map 没有清理
ThreadLocal 泄漏
连接对象未关闭
消息堆积
大对象频繁分配
ClassLoader 泄漏
DirectByteBuffer 未释放

3. Full GC 频繁

关注:

1
2
3
4
5
6
7
Old 区增长速度
Young GC 后对象晋升量
大对象分配
元空间增长
显式 System.gc
堆设置过小
内存泄漏

命令:

1
2
3
jstat -gcutil <pid> 1000
jcmd <pid> GC.heap_info
jcmd <pid> VM.flags

GC 日志中如果看到:

1
2
3
4
5
6
Pause Full
Allocation Failure
Metadata GC Threshold
Humongous Allocation
To-space exhausted
Evacuation Failure

都要重点分析。

4. 接口偶发慢

不要只看平均耗时,要看:

1
2
3
4
5
6
7
8
P95
P99
P999
最大耗时
慢请求发生时间点
是否与 GC 重合
是否与线程池排队重合
是否与数据库慢 SQL 重合

建议组合:

1
2
3
4
5
6
7
GC 日志
应用日志 traceId
线程 dump
JFR
Arthas trace/watch
APM 链路追踪
数据库慢查询

真正的线上慢接口,经常不是单点问题,而是多个因素叠加。

6.5 JVM 监控命令

1. jps

查看 Java 进程:

1
jps -l

2. jcmd

jcmd 是现代 JDK 推荐的综合诊断命令。

常用:

1
2
3
4
5
6
7
8
jcmd <pid> VM.version
jcmd <pid> VM.flags
jcmd <pid> VM.command_line
jcmd <pid> VM.system_properties
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram
jcmd <pid> Thread.print -l
jcmd <pid> VM.native_memory summary

触发 GC,不建议随便在线上用:

1
jcmd <pid> GC.run

3. jstat

观察 GC 趋势:

1
jstat -gcutil <pid> 1000

字段大致包括:

字段 含义
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
jmap -dump:format=b,file=heap.hprof <pid>

类直方图:

1
jmap -histo:live <pid>

注意:live 可能触发 Full GC,线上慎用。

5. jstack

线程栈:

1
jstack -l <pid> > thread.txt

重点看:

1
2
3
4
5
6
BLOCKED
WAITING
TIMED_WAITING
deadlock
线程池工作线程是否全部阻塞
业务线程是否卡在同一方法

6. JFR / JMC

JFR 适合低开销采集运行时事件:

1
jcmd <pid> JFR.start name=profile settings=profile duration=120s filename=/tmp/app.jfr

也可以检查:

1
jcmd <pid> JFR.check

停止:

1
jcmd <pid> JFR.stop name=profile

然后用 JDK Mission Control 分析:

  • CPU 热点;
  • 锁竞争;
  • GC;
  • 对象分配;
  • IO;
  • Socket;
  • 线程状态;
  • 方法采样。

JFR 的好处是视角完整,不像单次 thread dump 只是一个瞬间截图。

6.6 Arthas 深入分析

Arthas 是线上 Java 诊断神器,适合“不改代码、不重启服务”分析运行中 JVM。

安装启动:

1
2
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

选择目标 Java 进程后进入 Arthas 控制台。

1. dashboard:看整体状态

1
dashboard

可以看到:

1
2
3
4
5
线程
内存
GC
运行时信息
Tomcat 信息

适合第一眼判断系统是否异常:

  • CPU 是否高;
  • GC 是否频繁;
  • 堆是否逼近上限;
  • 线程是否异常;
  • 是否存在大量阻塞。

2. thread:定位高 CPU 线程

1
thread -n 5

查看最忙的 5 个线程。

查看指定线程:

1
thread <threadId>

排查 CPU 飙高时非常有用。

3. jvm:查看 JVM 信息

1
jvm

可以看到:

1
2
3
4
5
6
7
JVM 参数
运行时间
内存信息
GC 信息
线程信息
类加载信息
操作系统信息

4. memory:查看内存

1
memory

查看堆、非堆、直接内存等信息。

5. sysprop / sysenv

查看系统属性:

1
2
sysprop
sysprop java.version

查看环境变量:

1
sysenv

线上排查配置问题很有用,比如:

1
2
3
4
当前 profile 是不是 prod
配置目录是否正确
JDK 版本是否符合预期
file.encoding 是不是 UTF-8

6. vmoption

查看 JVM 诊断参数:

1
vmoption

修改某些可写参数:

1
vmoption PrintGC true

注意,不是所有 JVM 参数都能运行时修改。

7. sc:查类

1
2
sc com.example.UserService
sc -d com.example.UserService

-d 可以查看类详情,包括:

1
2
3
4
5
classLoader
classLoaderHash
code-source
interfaces
super-class

排查类冲突、依赖版本冲突时很有用。

8. sm:查方法

1
2
sm com.example.UserService
sm -d com.example.UserService query

可以查看方法签名。

9. jad:反编译线上代码

1
jad com.example.UserService

这个命令非常实用。

线上经常出现:

1
2
3
我本地代码明明改了
线上怎么不生效
到底部署的是哪个版本

直接 jad 看线上 JVM 里加载的类,别靠猜。猜代码是玄学,jad 是科学。

10. trace:分析方法耗时链路

1
trace com.example.UserService query

只看耗时超过 100ms 的调用:

1
trace com.example.UserService query '#cost > 100'

限制次数:

1
trace com.example.UserService query '#cost > 100' -n 5

trace 适合定位:

1
2
3
4
一个方法内部到底哪一步慢
DAO 慢还是 RPC 慢
缓存慢还是序列化慢
某个分支是否异常耗时

11. watch:观察入参、返回值、异常

查看方法入参和返回值:

1
watch com.example.UserService query '{params, returnObj}' -x 3 -n 5

查看异常:

1
watch com.example.UserService query '{params, throwExp}' -e -x 3

只观察耗时超过 200ms:

1
watch com.example.UserService query '{params, returnObj, #cost}' '#cost > 200' -x 3 -n 5

watch 适合排查:

1
2
3
4
入参是否符合预期
返回值是否异常
是否抛异常
某个条件下为什么分支错误

12. stack:查看方法调用来源

1
stack com.example.UserService query

只看特定条件:

1
stack com.example.UserService query '#cost > 100'

适合回答:

1
2
3
这个方法到底是谁调用的?
为什么这个接口被频繁调用?
某个定时任务是不是在疯狂刷?

13. monitor:方法统计

1
monitor com.example.UserService query -c 5

每 5 秒统计一次:

1
2
3
4
5
6
调用次数
成功次数
失败次数
平均耗时
最大耗时
失败率

适合观察方法级别的运行趋势。

14. tt:时间隧道

记录方法调用:

1
tt -t com.example.UserService query

查看记录列表:

1
tt -l

查看某次调用详情:

1
tt -i 1000

重放调用:

1
tt -i 1000 -p

tt 很强,但线上慎用,因为它会记录调用现场,可能带来额外内存和性能开销。

15. profiler:火焰图

启动采样:

1
profiler start

停止并生成火焰图:

1
profiler stop

适合分析 CPU 热点。

如果一个接口慢,但 trace 看不出明显慢点,可能是 CPU 消耗分散,这时火焰图更适合。

16. heapdump

生成堆 dump:

1
heapdump /tmp/heap.hprof

线上慎用,大堆 dump 可能导致明显 IO 和停顿压力。

17. classloader

查看类加载器:

1
2
3
classloader
classloader -t
classloader -l

适合排查:

1
2
3
4
5
类冲突
多版本 jar
ClassLoader 泄漏
Web 容器隔离问题
插件系统问题

18. reset 和 stop

Arthas 的 trace/watch/monitor/tt 等命令会做字节码增强。诊断结束后建议:

1
2
reset
stop

线上使用 Arthas 的原则:

1
2
3
4
5
6
范围要小
次数要限制
条件要精确
观察后及时 reset
不要对高频核心方法长时间 watch
不要在大促高峰乱开 heapdump

工具越强,越要克制。线上不是练功房,是战场。

6.7 JVM 调优实战方法论

我建议把 JVM 问题拆成 5 个维度:

1
2
3
4
5
内存
GC
线程
CPU
类加载

内存问题

看:

1
2
3
4
5
堆是否增长
Metaspace 是否增长
Direct Memory 是否增长
线程数是否增长
Native Memory 是否增长

GC 问题

看:

1
2
3
4
5
6
Young GC 频率
Full GC 次数
单次 GC 停顿
Old 区回收效果
对象晋升速率
大对象分配

线程问题

看:

1
2
3
4
5
6
线程总数
阻塞线程
死锁
线程池队列
锁竞争
IO 等待

CPU 问题

看:

1
2
3
4
5
6
7
8
热点线程
热点方法
JIT 编译
GC 线程
序列化
加密
正则
死循环

类加载问题

看:

1
2
3
4
5
6
Loaded Class 数量
ClassLoader 数量
Metaspace 使用
动态代理类数量
是否热部署泄漏
是否多版本冲突

6.8 一套推荐的生产 JVM 参数模板

普通 Spring Boot 服务,JDK 17/21/25,G1:

1
2
3
4
5
6
7
8
9
10
JAVA_OPTS="
-Xms2g
-Xmx2g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/logs/heapdump.hprof
-XX:ErrorFile=/logs/hs_err_pid%p.log
-Xlog:gc*,safepoint:file=/logs/gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=100m
"

低延迟服务,评估 ZGC:

1
2
3
4
5
6
7
8
9
JAVA_OPTS="
-Xms4g
-Xmx4g
-XX:+UseZGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/logs/heapdump.hprof
-XX:ErrorFile=/logs/hs_err_pid%p.log
-Xlog:gc*,safepoint:file=/logs/gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=100m
"

容器环境:

1
2
3
4
5
6
JAVA_OPTS="
-XX:InitialRAMPercentage=50
-XX:MaxRAMPercentage=75
-XX:+UseG1GC
-Xlog:gc*,safepoint:file=/logs/gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=100m
"

注意:模板不是银弹。最终要结合压测和线上数据调整。

6.9 JVM 优化的几个经验判断

1. 不要盲目调大堆

堆大可以减少 GC 频率,但也可能增加单次回收成本。

如果是内存泄漏,调大堆只是延迟死亡。

2. 不要看到 Full GC 就只怪 GC

Full GC 是结果,不一定是原因。

可能原因是:

1
2
3
4
5
6
7
对象泄漏
大对象分配
元空间爆
System.gc
堆太小
晋升失败
Humongous 对象太多

3. 不要迷信某个 GC

G1、ZGC、Shenandoah、Parallel 都有适用场景。

选 GC 要看:

1
2
3
4
5
6
7
延迟目标
吞吐目标
堆大小
CPU 资源
JDK 版本
对象分配速率
业务峰值模型

4. 先优化代码,再调 JVM

很多 JVM 问题本质是代码问题:

1
2
3
4
5
6
7
8
缓存无上限
一次性加载大集合
循环里创建大量临时对象
日志打印大对象
ThreadLocal 不清理
线程池无界队列
数据库分页不合理
JSON 序列化过度

JVM 调优能救急,但不能替代码还债。

5. 建立基线

上线前至少记录:

1
2
3
4
5
6
7
正常 QPS
正常响应时间
正常 GC 频率
正常堆使用曲线
正常线程数
正常 CPU 使用率
正常类加载数量

没有基线,就没有异常。没有异常,就只能靠感觉。靠感觉调 JVM,基本等于闭眼开高达。


结语

JVM 的知识可以分成两层。

第一层是概念:

1
2
3
4
5
6
字节码
类加载
运行时数据区
垃圾回收
JIT
调优参数

第二层是工程判断:

1
2
3
4
5
6
7
为什么这次 GC 慢?
为什么 Old 区降不下来?
为什么 CPU 高?
为什么线程阻塞?
为什么类加载数量一直涨?
为什么堆没爆但进程被杀?
为什么同一个类不能强转?

只学第一层,容易变成八股文选手。

真正有价值的是把第一层变成第二层:能定位、能解释、能优化、能复盘。

JVM 并不神秘,它只是很复杂。复杂的东西要拆开看:从字节码到类加载,从栈帧到对象,从 GC 日志到 Arthas,从现象到证据。

最后记住一句话:

1
JVM 调优不是调参数,而是用数据理解程序运行时行为。

参考资料

  • 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 这东西也是一样:别只背远处的概念,要看近处的日志、线程、堆和代码。


深入理解 JVM:从字节码、类加载、运行时内存到 GC 与调优实战
https://allendericdalexander.github.io/2026/06/04/jvm/
作者
AtLuoFu
发布于
2026年6月4日
许可协议