JVM 基础
JVM 概述
JVM 是 Java 技术体系的核心运行环境,负责加载、校验、执行 Java 字节码,并在程序运行过程中管理内存、线程、垃圾回收等底层能力。Java 能够实现跨平台运行,本质上依赖 JVM 对不同操作系统和硬件平台的屏蔽。
学习 JVM 基础时,首先需要理解 JVM 的作用、JVM 与 JDK、JRE 的关系,以及 Java 程序从源码到运行的完整执行流程。
JVM 的作用
JVM,全称是 Java Virtual Machine,即 Java 虚拟机。它不是一台真实的物理机器,而是运行在操作系统之上的虚拟运行环境。
Java 程序并不是直接交给操作系统执行的,而是先被编译成 .class 字节码文件,然后由 JVM 加载并执行。JVM 负责将平台无关的字节码转换为当前操作系统能够执行的机器指令,从而实现 Java 的跨平台特性。
JVM 的主要作用如下:
| 作用 | 说明 |
|---|---|
| 加载字节码 | 将 .class 文件加载到 JVM 内存中 |
| 校验字节码 | 检查字节码是否合法、安全,防止破坏 JVM 运行环境 |
| 执行字节码 | 通过解释器或 JIT 编译器执行 Java 程序 |
| 管理内存 | 管理堆、栈、方法区、程序计数器等运行时内存区域 |
| 自动垃圾回收 | 回收不再使用的对象,降低手动管理内存的复杂度 |
| 屏蔽平台差异 | 让同一份字节码可以在不同平台上运行 |
可以简单理解为:开发人员编写 Java 源码,编译器生成字节码,JVM 负责运行字节码。
JVM 与 JDK、JRE 的关系
JVM、JRE 和 JDK 是 Java 技术体系中的三个重要概念,它们的范围和作用不同。
JVM 是 Java 程序运行的核心,负责执行字节码。JRE 是 Java Runtime Environment,即 Java 运行环境,包含 JVM 和 Java 程序运行所需的核心类库。JDK 是 Java Development Kit,即 Java 开发工具包,包含 JRE,并额外提供开发、编译、调试、打包等工具。
它们的关系如下:
JDK
├── JRE
│ ├── JVM
│ └── Java 核心类库
└── 开发工具
├── javac
├── java
├── jar
├── javadoc
├── jps
├── jstack
├── jmap
└── jstat2
3
4
5
6
7
8
9
10
11
12
13
三者区别如下:
| 名称 | 全称 | 作用 | 使用场景 |
|---|---|---|---|
| JVM | Java Virtual Machine | 执行 Java 字节码 | Java 程序运行核心 |
| JRE | Java Runtime Environment | 提供 Java 程序运行环境 | 只需要运行 Java 程序 |
| JDK | Java Development Kit | 提供 Java 开发、编译、运行、调试工具 | 开发 Java 程序 |
如果只是运行 Java 程序,理论上只需要 JRE。如果需要开发 Java 程序,则需要安装 JDK。实际开发中通常直接安装 JDK,因为 JDK 已经包含 JRE 相关能力。
从包含关系上看:
JDK > JRE > JVM也就是说,JDK 包含 JRE,JRE 包含 JVM。
Java 程序执行流程
Java 程序的执行流程可以概括为:编写源码、编译源码、加载字节码、执行字节码、运行时内存管理。
整体流程如下:
Java 源码文件(.java)
↓
javac 编译
↓
字节码文件(.class)
↓
类加载器加载
↓
JVM 运行时数据区
↓
执行引擎执行字节码
↓
解释执行 / JIT 编译执行
↓
操作系统执行机器指令2
3
4
5
6
7
8
9
10
11
12
13
14
15
具体执行步骤如下:
编写 Java 源码文件
开发人员先编写
.java源码文件,例如HelloJvm.java。编译源码文件
使用
javac编译器将.java文件编译成.class字节码文件。bashjavac HelloJvm.java1启动 JVM 执行程序
使用
java命令启动 JVM,并执行指定类中的main方法。bashjava HelloJvm1类加载器加载字节码
JVM 通过类加载器将
.class文件加载到内存中,并完成验证、准备、解析、初始化等步骤。执行引擎执行字节码
JVM 的执行引擎会执行字节码指令。执行方式主要有两种:
执行方式 说明 解释执行 解释器逐条读取字节码并执行 JIT 编译执行 即时编译器将热点代码编译成本地机器码,提高执行效率 JVM 管理程序运行
程序运行期间,JVM 会负责线程调度、内存分配、方法调用、对象创建、垃圾回收等工作。
示例程序如下:
/**
* JVM 执行流程示例
*
* @author Ateng
* @since 2026-05-15
*/
public class HelloJvm {
/**
* 程序入口方法
*
* @param args 启动参数
*/
public static void main(String[] args) {
System.out.println("Hello JVM");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
对应执行命令如下:
# 编译 Java 源码,生成 HelloJvm.class 文件
javac HelloJvm.java
# 启动 JVM,执行 HelloJvm 类
java HelloJvm
# 查看字节码指令
javap -c HelloJvm2
3
4
5
6
7
8
其中,javac 负责编译源码,java 负责启动 JVM 并运行程序,javap -c 可以查看编译后的字节码指令,常用于学习 JVM 的底层执行过程。
需要注意的是,Java 程序最终执行的不是 .java 源码文件,而是编译后的 .class 字节码文件。JVM 正是通过执行字节码,屏蔽不同操作系统之间的差异,从而实现跨平台运行。
JVM 内存结构
JVM 在运行 Java 程序时,会将内存划分为多个不同的运行时数据区域。不同区域负责不同的数据存储和运行职责,例如线程执行位置、方法调用栈、对象实例、类元信息等。
JVM 内存结构通常包括:程序计数器、Java 虚拟机栈、本地方法栈、堆和方法区。其中,程序计数器、Java 虚拟机栈、本地方法栈是线程私有的;堆和方法区是线程共享的。
JVM 运行时数据区
├── 线程私有
│ ├── 程序计数器
│ ├── Java 虚拟机栈
│ └── 本地方法栈
└── 线程共享
├── 堆
└── 方法区2
3
4
5
6
7
8
程序计数器
程序计数器是一块较小的内存区域,用于记录当前线程正在执行的字节码指令地址。每个线程都有自己独立的程序计数器,因此它属于线程私有内存。
Java 程序是多线程执行的,CPU 会在线程之间频繁切换。线程切换后,JVM 需要知道每个线程上次执行到哪一条字节码指令,程序计数器就是用来保存这个执行位置的。
程序计数器的特点如下:
| 特点 | 说明 |
|---|---|
| 线程私有 | 每个线程都有独立的程序计数器 |
| 内存较小 | 只保存当前线程执行的字节码位置 |
| 不会发生内存溢出 | 是 JVM 规范中唯一没有规定 OutOfMemoryError 的区域 |
| 支持线程恢复 | 线程切换后可以恢复到正确的执行位置 |
如果线程正在执行 Java 方法,程序计数器记录的是当前字节码指令地址。如果线程正在执行 Native 方法,程序计数器的值通常为空。
Java 虚拟机栈
Java 虚拟机栈用于存储 Java 方法调用过程中的运行数据。每个线程在创建时都会创建一个对应的虚拟机栈,因此它也是线程私有的。
每调用一个 Java 方法,JVM 都会为这个方法创建一个栈帧,并将栈帧压入虚拟机栈。方法执行完成后,对应的栈帧会出栈。
一个栈帧中通常包含以下内容:
| 组成部分 | 说明 |
|---|---|
| 局部变量表 | 存放方法参数和方法内部定义的局部变量 |
| 操作数栈 | 用于字节码指令执行过程中的临时数据计算 |
| 动态链接 | 指向运行时常量池中该方法所属类的符号引用 |
| 方法返回地址 | 方法执行完成后返回到调用位置 |
示例代码如下:
/**
* 虚拟机栈示例
*
* @author Ateng
* @since 2026-05-15
*/
public class StackDemo {
/**
* 程序入口方法
*
* @param args 启动参数
*/
public static void main(String[] args) {
int result = add(10, 20);
System.out.println(result);
}
/**
* 两数相加
*
* @param a 第一个数字
* @param b 第二个数字
* @return 相加结果
*/
public static int add(int a, int b) {
int sum = a + b;
return sum;
}
}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
执行 main 方法时,JVM 会先创建 main 方法对应的栈帧。当调用 add 方法时,又会创建 add 方法对应的栈帧并压入虚拟机栈。add 方法执行结束后,其栈帧出栈,程序继续回到 main 方法执行。
Java 虚拟机栈常见异常如下:
| 异常 | 说明 |
|---|---|
StackOverflowError | 栈深度超过 JVM 允许的最大深度,常见于无限递归 |
OutOfMemoryError | 如果虚拟机栈可以动态扩展,但扩展时无法申请到足够内存,则可能抛出该异常 |
递归调用如果没有正确的终止条件,就容易导致 StackOverflowError。
/**
* 栈溢出示例
*
* @author Ateng
* @since 2026-05-15
*/
public class StackOverflowDemo {
/**
* 程序入口方法
*
* @param args 启动参数
*/
public static void main(String[] args) {
loop();
}
/**
* 无限递归调用
*/
public static void loop() {
loop();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
本地方法栈
本地方法栈用于支持 Native 方法的执行。Native 方法通常是由 C、C++ 等非 Java 语言实现的方法。
Java 虚拟机栈服务于 Java 方法,也就是字节码方法;本地方法栈服务于 Native 方法。二者作用相似,只是服务对象不同。
本地方法栈的特点如下:
| 特点 | 说明 |
|---|---|
| 线程私有 | 每个线程都有自己的本地方法栈 |
| 服务 Native 方法 | 支持 JVM 调用底层本地方法 |
| 依赖具体虚拟机实现 | 不同 JVM 对本地方法栈的实现可能不同 |
| 可能发生栈溢出 | 也可能抛出 StackOverflowError 或 OutOfMemoryError |
常见的 Native 方法可以通过 native 关键字识别,例如:
public native int hashCode();Object 类中的一些底层方法就是 Native 方法。它们不是由 Java 代码直接实现,而是由 JVM 或底层系统提供实现。
堆
堆是 JVM 中最大的一块内存区域,主要用于存放对象实例和数组。几乎所有通过 new 创建的对象都会分配在堆中。
堆是线程共享的内存区域,所有线程都可以访问堆中的对象。因此,堆也是垃圾回收器重点管理的区域。
堆的主要特点如下:
| 特点 | 说明 |
|---|---|
| 线程共享 | 所有线程都可以访问堆中的对象 |
| 存放对象实例 | 普通对象、数组通常都分配在堆中 |
| GC 重点区域 | 垃圾回收主要发生在堆中 |
| 可通过参数配置大小 | 常用参数包括 -Xms 和 -Xmx |
| 可能发生内存溢出 | 堆空间不足时会抛出 OutOfMemoryError |
堆内存通常可以按照垃圾回收分代思想划分为新生代和老年代:
堆
├── 新生代
│ ├── Eden 区
│ ├── Survivor From 区
│ └── Survivor To 区
└── 老年代2
3
4
5
6
新创建的对象通常会优先分配在 Eden 区。当 Eden 区空间不足时,会触发 Minor GC。经过多次 GC 后仍然存活的对象,可能会晋升到老年代。
常见堆内存参数如下:
# 设置 JVM 初始堆大小为 512MB
-Xms512m
# 设置 JVM 最大堆大小为 512MB
-Xmx512m2
3
4
5
实际生产环境中,通常会将 -Xms 和 -Xmx 设置为相同值,避免堆动态扩容或缩容带来的性能波动。
方法区
方法区用于存储类相关的信息,例如类的元信息、运行时常量池、静态变量、即时编译器编译后的代码缓存等。方法区是线程共享的内存区域。
需要注意的是,方法区是 JVM 规范中的概念,不同 JVM 可以有不同实现。在 HotSpot 虚拟机中,JDK 8 之前方法区主要由永久代实现;JDK 8 之后,永久代被移除,改为使用元空间。
方法区中通常存放以下内容:
| 内容 | 说明 |
|---|---|
| 类元信息 | 类名、父类、接口、字段、方法等信息 |
| 运行时常量池 | 类加载后生成的常量池信息 |
| 静态变量 | 被 static 修饰的变量 |
| 方法信息 | 方法名称、返回类型、参数、访问修饰符等 |
| JIT 编译代码 | 即时编译后的热点代码缓存 |
JDK 8 前后的方法区实现区别如下:
| 版本 | 实现方式 | 内存位置 |
|---|---|---|
| JDK 7 及之前 | 永久代 | JVM 堆内存的一部分 |
| JDK 8 及之后 | 元空间 | 本地内存 |
元空间使用的是本地内存,不再直接占用 JVM 堆空间,但仍然可能发生内存溢出。如果加载的类过多,元空间不足,可能会出现 OutOfMemoryError: Metaspace。
常见元空间参数如下:
# 设置元空间初始大小
-XX:MetaspaceSize=128m
# 设置元空间最大大小
-XX:MaxMetaspaceSize=256m2
3
4
5
对象创建与内存分配
对象创建是 Java 程序运行过程中最常见的操作之一。开发人员使用 new 关键字创建对象时,JVM 会在底层完成类加载检查、内存分配、对象初始化、引用建立等一系列步骤。
理解对象创建过程,有助于进一步理解堆内存分配、对象访问、垃圾回收和性能优化。
对象创建过程
当代码中出现 new 关键字时,JVM 创建对象通常会经历以下几个步骤:
对象创建过程
├── 类加载检查
├── 分配内存
├── 初始化零值
├── 设置对象头
├── 执行构造方法
└── 返回对象引用2
3
4
5
6
7
具体过程如下:
类加载检查
JVM 首先检查当前类是否已经被加载、解析和初始化。如果类还没有加载,JVM 会先执行类加载过程。
分配内存
类加载完成后,对象所需内存大小就可以确定。JVM 会在堆中为对象分配一块内存空间。
初始化零值
内存分配完成后,JVM 会将对象内存空间初始化为默认零值。例如,
int默认是0,boolean默认是false,引用类型默认是null。设置对象头
JVM 会设置对象头信息,例如对象所属类的元数据指针、哈希码、GC 分代年龄、锁状态标志等。
执行构造方法
JVM 执行对象的构造方法,也就是 Java 代码中的
<init>方法。此时会执行成员变量显式赋值、实例代码块和构造方法体。返回对象引用
对象创建完成后,JVM 会将对象引用返回给变量,变量通过引用访问堆中的对象。
示例代码如下:
/**
* 对象创建过程示例
*
* @author Ateng
* @since 2026-05-15
*/
public class ObjectCreateDemo {
/**
* 程序入口方法
*
* @param args 启动参数
*/
public static void main(String[] args) {
User user = new User("Ateng", 18);
System.out.println(user);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这里的 new User("Ateng", 18) 并不是简单地创建一个变量,而是会触发 JVM 在堆中分配对象内存,并执行对象初始化流程。
对象内存布局
在 HotSpot 虚拟机中,对象在内存中的布局通常可以分为三部分:对象头、实例数据和对齐填充。
对象内存布局
├── 对象头
├── 实例数据
└── 对齐填充2
3
4
各部分说明如下:
| 组成部分 | 说明 |
|---|---|
| 对象头 | 存储对象运行时元信息,例如哈希码、GC 年龄、锁状态、类型指针等 |
| 实例数据 | 存储对象真正的成员变量数据 |
| 对齐填充 | 保证对象大小满足 JVM 内存对齐要求 |
对象头通常包括两部分:
| 对象头内容 | 说明 |
|---|---|
| Mark Word | 存储对象哈希码、GC 年龄、锁状态标志等运行时数据 |
| 类型指针 | 指向该对象所属类的元数据,JVM 通过它确定对象属于哪个类 |
如果对象是数组,对象头中还会额外存储数组长度。
示例类如下:
/**
* 用户对象示例
*
* @author Ateng
* @since 2026-05-15
*/
public class User {
private String name;
private Integer age;
/**
* 创建用户对象
*
* @param name 用户名称
* @param age 用户年龄
*/
public User(String name, Integer age) {
this.name = name;
this.age = age;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
该对象在堆中的结构可以简单理解为:
User 对象
├── 对象头
│ ├── Mark Word
│ └── 类型指针
├── 实例数据
│ ├── name 引用
│ └── age 引用
└── 对齐填充2
3
4
5
6
7
8
需要注意的是,name 和 age 字段本身是引用类型。User 对象中保存的是引用地址,真正的 String 对象和 Integer 对象也会存储在堆中。
对象访问方式
Java 程序通过引用访问对象。局部变量通常存储在虚拟机栈的局部变量表中,而对象实例存储在堆中。
例如:
User user = new User("Ateng", 18);可以简单理解为:
虚拟机栈
└── 局部变量表
└── user 引用
↓
堆
└── User 对象实例2
3
4
5
6
对象访问方式常见有两种:句柄访问和直接指针访问。
句柄访问方式如下:
栈中的引用
↓
句柄池
├── 对象实例数据指针
└── 对象类型数据指针
↓
堆中的对象实例 / 方法区中的类元信息2
3
4
5
6
7
直接指针访问方式如下:
栈中的引用
↓
堆中的对象实例
└── 对象头中保存类型数据指针
↓
方法区中的类元信息2
3
4
5
6
两种方式对比如下:
| 访问方式 | 优点 | 缺点 |
|---|---|---|
| 句柄访问 | 对象移动时只需要修改句柄中的实例数据指针,引用本身稳定 | 多了一次指针定位,访问速度相对慢 |
| 直接指针访问 | 访问速度快,少一次指针定位 | 对象移动时需要更新引用地址 |
HotSpot 虚拟机主要使用直接指针访问方式,因为对象访问在 Java 程序中非常频繁,直接指针访问性能更高。
堆内存分配
对象通常分配在堆中。为了提高分配效率,JVM 会根据对象大小、线程情况、GC 策略等因素选择合适的分配方式。
常见堆内存分配规则如下:
| 分配规则 | 说明 |
|---|---|
| 对象优先分配在 Eden 区 | 大多数新对象会先进入新生代 Eden 区 |
| 大对象可能直接进入老年代 | 大数组、大字符串等对象可能直接分配到老年代 |
| 长期存活对象进入老年代 | 对象经过多次 Minor GC 后仍存活,可能晋升到老年代 |
| 动态年龄判断 | Survivor 区中同年龄对象总大小超过一定比例时,较大年龄对象可能提前晋升 |
| 空间分配担保 | Minor GC 前检查老年代是否有足够空间容纳晋升对象 |
新对象的一般分配流程如下:
新对象创建
↓
优先分配到 Eden 区
↓
Eden 区空间不足
↓
触发 Minor GC
↓
存活对象进入 Survivor 区
↓
多次存活后晋升老年代2
3
4
5
6
7
8
9
10
11
为了提高多线程环境下的对象分配效率,JVM 还会使用 TLAB,也就是 Thread Local Allocation Buffer,线程本地分配缓冲区。
TLAB 的基本思想是:每个线程在 Eden 区中预先分配一小块私有区域,线程创建对象时优先在自己的 TLAB 中分配,减少多线程竞争。
Eden 区
├── 线程 A 的 TLAB
├── 线程 B 的 TLAB
├── 线程 C 的 TLAB
└── 公共分配区域2
3
4
5
堆内存分配常见异常是 java.lang.OutOfMemoryError: Java heap space。该异常通常表示堆内存不足,可能原因包括:
| 原因 | 说明 |
|---|---|
| 对象创建过多 | 短时间内创建大量对象 |
| 对象无法回收 | 集合、缓存、静态变量长期持有对象引用 |
| 堆配置过小 | -Xmx 设置过小 |
| 内存泄漏 | 无用对象仍然被引用,导致 GC 无法回收 |
排查堆内存问题时,通常需要结合 jmap、堆转储文件、MAT、VisualVM 等工具分析对象数量、对象大小和引用链。
类加载机制
类加载机制负责将 .class 字节码文件加载到 JVM 内存中,并将其转换为 JVM 可以使用的类元信息。Java 程序运行时,并不是一次性加载所有类,而是在需要使用某个类时才触发加载。
类加载机制是理解反射、动态代理、热部署、SPI、双亲委派和类冲突问题的基础。
类加载过程
类加载过程通常包括五个阶段:加载、验证、准备、解析和初始化。
类加载过程
├── 加载
├── 验证
├── 准备
├── 解析
└── 初始化2
3
4
5
6
其中,验证、准备、解析三个阶段统称为连接。
加载
↓
连接
├── 验证
├── 准备
└── 解析
↓
初始化2
3
4
5
6
7
8
各阶段说明如下:
| 阶段 | 说明 |
|---|---|
| 加载 | 通过类的全限定名获取 .class 字节码,并生成对应的 Class 对象 |
| 验证 | 校验字节码格式、语义、安全性,保证不会危害 JVM |
| 准备 | 为类变量分配内存,并设置默认初始值 |
| 解析 | 将常量池中的符号引用替换为直接引用 |
| 初始化 | 执行类构造器 <clinit> 方法,为静态变量赋值并执行静态代码块 |
需要特别区分准备阶段和初始化阶段。
示例代码如下:
/**
* 类加载准备阶段和初始化阶段示例
*
* @author Ateng
* @since 2026-05-15
*/
public class ClassLoadDemo {
private static int count = 10;
static {
count = 20;
}
/**
* 程序入口方法
*
* @param args 启动参数
*/
public static void main(String[] args) {
System.out.println(count);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在准备阶段,count 会先被设置为默认值 0。在初始化阶段,才会执行 private static int count = 10 和 static { count = 20; },最终输出结果是 20。
类加载器
类加载器负责根据类的全限定名加载对应的字节码文件,并将其转换为 JVM 中的 Class 对象。
Java 中常见的类加载器如下:
| 类加载器 | 说明 |
|---|---|
| 启动类加载器 | 负责加载 Java 核心类库,例如 java.lang.String |
| 扩展类加载器 | JDK 8 中负责加载扩展目录下的类库 |
| 平台类加载器 | JDK 9 之后用于替代扩展类加载器的部分职责 |
| 应用程序类加载器 | 负责加载 classpath 下的业务类和第三方依赖 |
| 自定义类加载器 | 开发者根据需要自定义的类加载器 |
在常见应用程序中,自己编写的业务类通常由应用程序类加载器加载。
可以通过下面代码查看类加载器:
/**
* 类加载器查看示例
*
* @author Ateng
* @since 2026-05-15
*/
public class ClassLoaderDemo {
/**
* 程序入口方法
*
* @param args 启动参数
*/
public static void main(String[] args) {
System.out.println(String.class.getClassLoader());
System.out.println(ClassLoaderDemo.class.getClassLoader());
System.out.println(ClassLoaderDemo.class.getClassLoader().getParent());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
通常情况下,String.class.getClassLoader() 输出为 null,表示它由启动类加载器加载。启动类加载器不是由 Java 代码实现的,所以在 Java 层面通常显示为 null。
双亲委派机制
双亲委派机制是 Java 类加载器的一种工作模型。它的核心思想是:当一个类加载器收到类加载请求时,不会自己立即加载,而是先把请求委托给父类加载器。只有当父类加载器无法加载时,子类加载器才会尝试自己加载。
双亲委派流程如下:
应用程序类加载器
↓ 委托
平台类加载器 / 扩展类加载器
↓ 委托
启动类加载器
↓
无法加载时逐级向下返回
↓
子类加载器尝试加载2
3
4
5
6
7
8
9
双亲委派机制的主要作用如下:
| 作用 | 说明 |
|---|---|
| 保证核心类安全 | 防止用户自定义类冒充 Java 核心类 |
| 避免类重复加载 | 父类加载器已加载的类,子类加载器不会重复加载 |
| 保证类的一致性 | 确保核心类在 JVM 中只有一份标准实现 |
例如,开发人员即使自定义一个 java.lang.String 类,通常也不会替换 JDK 中真正的 String 类。因为类加载请求会优先委托给启动类加载器,而启动类加载器会加载 JDK 自带的 java.lang.String。
双亲委派的简化实现逻辑如下:
loadClass(name)
↓
检查该类是否已经加载
↓
如果没有加载,委托父类加载器加载
↓
如果父类加载器无法加载,当前类加载器再尝试加载2
3
4
5
6
7
需要注意的是,双亲委派不是强制不可破坏的规则。某些场景会主动打破双亲委派,例如:
| 场景 | 说明 |
|---|---|
| JDBC SPI | 核心接口由启动类加载器加载,具体厂商实现由应用类加载器加载 |
| Tomcat | 不同 Web 应用需要隔离各自的类 |
| 热部署框架 | 需要重新加载修改后的类 |
| OSGi | 使用更加灵活的模块化类加载机制 |
类初始化时机
类初始化是类加载过程中的最后一个阶段。在初始化阶段,JVM 会执行类构造器 <clinit> 方法,也就是执行静态变量赋值和静态代码块。
类初始化并不是类被加载后立即发生,而是在首次主动使用类时触发。
常见会触发类初始化的情况如下:
| 触发场景 | 示例 |
|---|---|
| 创建类的实例 | new User() |
| 访问类的静态变量 | User.count |
| 调用类的静态方法 | User.print() |
| 使用反射调用类 | Class.forName("com.example.User") |
| 初始化子类时父类未初始化 | 先初始化父类,再初始化子类 |
| JVM 启动主类 | 包含 main 方法的启动类会被初始化 |
示例代码如下:
/**
* 类初始化时机示例
*
* @author Ateng
* @since 2026-05-15
*/
public class InitDemo {
static {
System.out.println("InitDemo 类初始化");
}
public static int count = 10;
/**
* 静态方法
*/
public static void print() {
System.out.println("执行静态方法");
}
/**
* 程序入口方法
*
* @param args 启动参数
*/
public static void main(String[] args) {
System.out.println(InitDemo.count);
InitDemo.print();
}
}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
31
访问 InitDemo.count 或调用 InitDemo.print() 都会触发 InitDemo 类初始化。
有些情况不会触发类初始化,通常称为被动引用:
| 被动引用场景 | 示例 |
|---|---|
| 通过子类访问父类静态字段 | Child.parentValue 只会初始化父类 |
| 创建类数组 | User[] users = new User[10] 不会初始化 User 类 |
| 访问编译期常量 | public static final String NAME = "Ateng" 可能不会触发初始化 |
示例代码如下:
/**
* 被动引用示例
*
* @author Ateng
* @since 2026-05-15
*/
public class PassiveReferenceDemo {
/**
* 程序入口方法
*
* @param args 启动参数
*/
public static void main(String[] args) {
UserInfo[] users = new UserInfo[10];
System.out.println(users.length);
}
}
/**
* 用户信息类
*
* @author Ateng
* @since 2026-05-15
*/
class UserInfo {
static {
System.out.println("UserInfo 类初始化");
}
}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
31
上面代码只是创建了 UserInfo 类型的数组,并没有创建 UserInfo 对象,也没有访问它的静态变量或静态方法,因此通常不会触发 UserInfo 类初始化。
类初始化顺序可以简单总结为:
父类静态变量和静态代码块
↓
子类静态变量和静态代码块
↓
父类实例变量和实例代码块
↓
父类构造方法
↓
子类实例变量和实例代码块
↓
子类构造方法2
3
4
5
6
7
8
9
10
11
掌握类初始化时机,有助于分析静态变量赋值、单例模式、类加载顺序、反射调用和框架启动过程中的初始化问题。
垃圾回收
垃圾回收,简称 GC,主要用于自动回收 JVM 堆内存中不再使用的对象。Java 开发人员通常不需要手动释放对象内存,但需要理解 GC 的基本原理,否则在排查内存溢出、频繁 Full GC、接口响应变慢等问题时会比较困难。
GC 重点关注的是堆内存中的对象。程序计数器、虚拟机栈、本地方法栈这些线程私有区域通常随着线程结束或方法调用结束自动释放,不是 GC 的主要管理区域。
垃圾判断算法
垃圾判断算法用于判断一个对象是否已经不再被使用。只有被判定为垃圾对象,才可能被垃圾回收器回收。
常见的垃圾判断方式主要有两种:引用计数算法和可达性分析算法。
引用计数算法的思路比较简单:给每个对象维护一个引用计数器。当有地方引用该对象时,计数器加一;当引用失效时,计数器减一。如果计数器为零,就认为对象可以被回收。
示意如下:
对象 A 被变量 user 引用
↓
引用计数 +1
变量 user = null
↓
引用计数 -1
引用计数为 0
↓
对象可以被回收2
3
4
5
6
7
8
9
10
11
引用计数算法实现简单,判断效率较高,但它无法解决循环引用问题。
例如:
对象 A 引用对象 B
对象 B 引用对象 A
外部已经没有任何变量引用 A 和 B
但是 A 和 B 互相引用,引用计数都不为 02
3
4
5
在这种情况下,虽然对象 A 和对象 B 已经无法被程序访问,但引用计数算法仍然认为它们不是垃圾对象。因此,主流 JVM 并不使用引用计数算法作为主要垃圾判断方式。
HotSpot 虚拟机主要使用的是可达性分析算法。
可达性分析算法的核心思想是:从一组称为 GC Roots 的根对象开始,沿着引用链向下搜索。凡是能够从 GC Roots 到达的对象,都认为是存活对象;无法到达的对象,则认为是可回收对象。
示意如下:
GC Roots
├── 对象 A
│ └── 对象 B
│ └── 对象 C
└── 对象 D
对象 E
└── 对象 F2
3
4
5
6
7
8
在上面的结构中,对象 A、B、C、D 可以从 GC Roots 到达,因此属于存活对象。对象 E 和 F 无法从 GC Roots 到达,即使它们之间互相引用,也会被判定为可回收对象。
常见的 GC Roots 包括:
| GC Roots 类型 | 说明 |
|---|---|
| 虚拟机栈中引用的对象 | 方法参数、局部变量引用的对象 |
| 方法区中静态属性引用的对象 | static 变量引用的对象 |
| 方法区中常量引用的对象 | 字符串常量、类常量引用的对象 |
| 本地方法栈中 JNI 引用的对象 | Native 方法引用的对象 |
| 活跃线程对象 | 正在运行的线程对象 |
| 被同步锁持有的对象 | 被 synchronized 锁住的对象 |
Java 中对象引用并不只有普通强引用,还包括软引用、弱引用和虚引用。不同引用类型会影响对象的回收时机。
| 引用类型 | 回收特点 | 常见用途 |
|---|---|---|
| 强引用 | 只要强引用存在,对象就不会被回收 | 普通对象引用 |
| 软引用 | 内存不足时可能被回收 | 本地缓存 |
| 弱引用 | 下一次 GC 时会被回收 | WeakHashMap |
| 虚引用 | 无法直接获取对象,主要用于回收跟踪 | 堆外内存清理、对象回收通知 |
普通代码中最常见的是强引用:
User user = new User();只要 user 变量仍然引用着 User 对象,这个对象通常就不会被 GC 回收。如果执行:
user = null;对象失去强引用后,在后续 GC 中就可能被回收。
垃圾回收算法
垃圾回收算法用于真正执行对象回收和内存整理。常见算法包括标记-清除算法、复制算法、标记-整理算法和分代收集算法。
标记-清除算法分为两个阶段:先标记出需要回收的对象,然后清除这些对象占用的内存。
流程如下:
标记存活对象 / 垃圾对象
↓
清除垃圾对象占用的内存2
3
它的优点是实现简单,不需要移动对象。缺点是清除后容易产生大量内存碎片,后续如果需要分配大对象,即使总剩余内存足够,也可能因为没有连续空间而分配失败。
复制算法会将内存分成两块,每次只使用其中一块。GC 时,将存活对象复制到另一块空闲内存中,然后一次性清理原来的整块内存。
流程如下:
From 区存放对象
↓
GC 时将存活对象复制到 To 区
↓
清空 From 区
↓
From 和 To 角色互换2
3
4
5
6
7
复制算法的优点是回收后没有内存碎片,分配对象也比较高效。缺点是需要浪费一部分内存空间。新生代中的 Survivor 区通常就体现了复制算法的思想。
标记-整理算法先标记存活对象,然后将存活对象向内存一端移动,最后清理边界以外的内存。
流程如下:
标记存活对象
↓
将存活对象向一端整理
↓
清理边界外的垃圾内存2
3
4
5
它的优点是不会产生内存碎片,适合老年代这种对象存活率较高的区域。缺点是移动对象需要额外开销。
分代收集算法并不是一种单独的回收算法,而是一种组合策略。它根据对象生命周期不同,将堆划分为新生代和老年代,然后在不同区域使用不同算法。
堆内存
├── 新生代:对象朝生夕死,适合复制算法
└── 老年代:对象存活率高,适合标记-清除或标记-整理算法2
3
常见算法对比如下:
| 算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 标记-清除 | 实现简单,不移动对象 | 容易产生内存碎片 | 基础回收思想 |
| 复制算法 | 无内存碎片,分配效率高 | 浪费部分空间 | 新生代 |
| 标记-整理 | 无内存碎片 | 移动对象成本较高 | 老年代 |
| 分代收集 | 根据对象生命周期选择算法 | 实现复杂 | 主流 JVM 堆回收 |
常见垃圾回收器
垃圾回收器是垃圾回收算法的具体实现。不同垃圾回收器关注的目标不同,有的追求吞吐量,有的追求低延迟,有的适合小内存应用,有的适合大堆内存服务。
常见垃圾回收器如下:
| 垃圾回收器 | 作用区域 | 特点 | 常见场景 |
|---|---|---|---|
| Serial | 新生代 | 单线程,简单稳定 | 客户端、小内存应用 |
| Serial Old | 老年代 | 单线程,标记-整理 | 客户端、小内存应用 |
| ParNew | 新生代 | Serial 的多线程版本 | 早期常配合 CMS |
| Parallel Scavenge | 新生代 | 多线程,关注吞吐量 | 后台计算、批处理 |
| Parallel Old | 老年代 | 多线程,关注吞吐量 | 与 Parallel Scavenge 搭配 |
| CMS | 老年代 | 并发低停顿,容易产生碎片 | JDK 8 常见,后续版本已不推荐 |
| G1 | 整个堆 | 分区回收,兼顾低停顿和吞吐量 | 服务端应用常用 |
| ZGC | 整个堆 | 超低停顿,适合大堆 | 低延迟、大内存应用 |
| Shenandoah | 整个堆 | 低停顿,并发整理 | 部分 OpenJDK 发行版支持 |
Serial 收集器是最基础的垃圾回收器。它在 GC 时只使用一个线程,并且会暂停所有用户线程,也就是 Stop The World。
用户线程运行
↓
发生 GC
↓
暂停所有用户线程
↓
Serial 单线程回收
↓
用户线程恢复2
3
4
5
6
7
8
9
Parallel 系列收集器使用多个 GC 线程进行回收,主要目标是提高吞吐量。吞吐量可以简单理解为:程序运行时间占总时间的比例。
吞吐量 = 用户代码运行时间 / (用户代码运行时间 + GC 时间)如果系统是后台任务、批处理任务、报表计算任务,短暂停顿不是特别敏感,但希望整体处理效率高,Parallel 系列比较适合。
CMS 收集器主要目标是降低老年代 GC 停顿时间。它的回收过程大致包括初始标记、并发标记、重新标记和并发清除。
初始标记
↓
并发标记
↓
重新标记
↓
并发清除2
3
4
5
6
7
CMS 的优点是并发回收,停顿时间较短。缺点是会产生内存碎片,并且在并发阶段会占用 CPU 资源。CMS 在较新的 JDK 中已经不再推荐使用,新项目一般不优先选择 CMS。
G1,也就是 Garbage First,是当前服务端应用中非常常见的垃圾回收器。G1 不再严格按照传统的新生代、老年代连续内存布局进行管理,而是将堆划分为多个大小相等的 Region。
堆
├── Region
├── Region
├── Region
├── Region
└── Region2
3
4
5
6
每个 Region 可以根据需要扮演 Eden、Survivor、Old 或 Humongous 区域。G1 会优先回收垃圾收益高的 Region,因此称为 Garbage First。
G1 的特点如下:
| 特点 | 说明 |
|---|---|
| 分区管理 | 将堆划分为多个 Region |
| 可预测停顿 | 可以通过参数设置期望最大停顿时间 |
| 并发标记 | 减少长时间 Stop The World |
| 整体适用性强 | 适合多数服务端应用 |
ZGC 是面向低延迟场景的垃圾回收器,设计目标是尽可能降低 GC 停顿时间。它适合大堆内存、低延迟服务,但具体是否使用需要结合 JDK 版本、业务场景和压测结果判断。
实际选择垃圾回收器时,可以按下面思路判断:
| 业务特点 | 可考虑的 GC |
|---|---|
| 小工具、小内存应用 | Serial |
| 后台计算、批处理、吞吐优先 | Parallel GC |
| 普通服务端应用 | G1 |
| 大内存、低延迟应用 | ZGC 或 Shenandoah |
| 老项目 JDK 8 低停顿场景 | CMS,但不建议新项目继续依赖 |
GC 日志基础
GC 日志用于记录 JVM 垃圾回收的过程、耗时、回收前后内存变化等信息。排查内存问题时,GC 日志是非常重要的依据。
常见 GC 日志可以帮助分析以下问题:
| 问题 | 可观察内容 |
|---|---|
| 是否频繁 GC | GC 发生频率 |
| GC 是否耗时过长 | 每次 GC 的暂停时间 |
| 堆内存是否不足 | GC 后堆内存是否明显下降 |
| 老年代是否持续增长 | Full GC 后老年代是否仍然很高 |
| 是否存在内存泄漏 | 多次 GC 后对象仍无法释放 |
| 是否需要调参 | 堆大小、新生代比例、GC 策略是否合理 |
JDK 8 常用 GC 日志参数如下:
# 打印详细 GC 日志
-XX:+PrintGCDetails
# 打印 GC 发生时间
-XX:+PrintGCDateStamps
# 打印应用启动后的相对时间
-XX:+PrintGCTimeStamps
# 指定 GC 日志输出文件
-Xloggc:/data/logs/app-gc.log
# GC 日志文件滚动
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=100M2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这些参数适合 JDK 8 及更早的日志风格。-Xloggc 用于指定 GC 日志文件路径,滚动参数用于避免单个 GC 日志文件过大。
JDK 9 及之后推荐使用统一日志参数:
# 输出 GC 基础日志到控制台
-Xlog:gc
# 输出详细 GC 日志
-Xlog:gc*
# 输出 GC 日志到指定文件
-Xlog:gc*:file=/data/logs/app-gc.log
# 输出 GC 日志并启用文件滚动
-Xlog:gc*:file=/data/logs/app-gc.log:time,uptime,level,tags:filecount=5,filesize=100M2
3
4
5
6
7
8
9
10
11
其中,gc* 表示输出 GC 相关的详细日志,filecount 表示保留的日志文件数量,filesize 表示单个日志文件大小。
一段简化的 GC 日志可以这样理解:
[GC pause (G1 Evacuation Pause) 256M->128M(1024M), 0.030s]含义如下:
| 日志片段 | 说明 |
|---|---|
GC pause | 发生了一次 GC 停顿 |
G1 Evacuation Pause | G1 的转移暂停 |
256M->128M | GC 前堆使用 256M,GC 后堆使用 128M |
(1024M) | 当前堆总容量 1024M |
0.030s | 本次 GC 耗时 0.030 秒 |
分析 GC 日志时,需要重点关注三个指标:GC 频率、GC 耗时、GC 后内存是否下降。如果 Full GC 频繁发生,并且每次 Full GC 后老年代占用仍然很高,通常需要重点排查内存泄漏或堆配置不足。
JVM 参数
JVM 参数用于控制 Java 程序运行时的内存大小、垃圾回收器、日志输出、诊断能力等。合理配置 JVM 参数,可以提高程序稳定性,也能为线上问题排查提供必要数据。
JVM 参数大致可以分为三类:
| 参数类型 | 示例 | 说明 |
|---|---|---|
| 标准参数 | -version、-classpath | 各 JVM 基本都支持 |
| 非标准参数 | -Xms、-Xmx、-Xss | 常用但不属于标准参数 |
| 高级参数 | -XX:+UseG1GC、-XX:MaxMetaspaceSize | 用于 GC、诊断、性能调优 |
堆内存参数
堆内存用于存放对象实例和数组,是 JVM 调优中最常关注的内存区域。堆空间过小容易导致频繁 GC 或 OutOfMemoryError: Java heap space;堆空间过大则可能导致单次 GC 停顿时间变长。
常用堆内存参数如下:
| 参数 | 说明 |
|---|---|
-Xms | 初始堆大小 |
-Xmx | 最大堆大小 |
-Xmn | 新生代大小 |
-XX:NewRatio | 老年代与新生代比例 |
-XX:SurvivorRatio | Eden 与 Survivor 区比例 |
-XX:MaxTenuringThreshold | 对象晋升老年代的年龄阈值 |
常见配置如下:
# 设置初始堆大小为 2GB
-Xms2g
# 设置最大堆大小为 2GB
-Xmx2g
# 设置新生代大小为 512MB
-Xmn512m
# 设置 Eden 和单个 Survivor 的比例为 8:1
-XX:SurvivorRatio=8
# 设置对象最大晋升年龄为 15
-XX:MaxTenuringThreshold=152
3
4
5
6
7
8
9
10
11
12
13
14
生产环境中,通常建议将 -Xms 和 -Xmx 设置为相同值,减少 JVM 运行期间堆扩容和缩容带来的性能波动。
例如:
-Xms2g -Xmx2g这表示 JVM 启动时直接申请 2GB 堆内存,最大堆内存也是 2GB。
需要注意的是,堆内存不是越大越好。堆越大,能够容纳的对象越多,但 GC 扫描和整理的成本也可能增加。实际配置需要结合机器内存、容器限制、业务对象规模和 GC 日志分析结果确定。
栈内存参数
栈内存主要用于方法调用。每个线程都会拥有自己的 Java 虚拟机栈,栈中保存一个个方法调用对应的栈帧。
常用栈内存参数是 -Xss:
# 设置每个线程的栈大小为 1MB
-Xss1m2
-Xss 控制的是每个线程的栈空间大小,而不是所有线程共享的总栈大小。
如果 -Xss 设置过小,方法调用层级稍深时容易出现:
java.lang.StackOverflowError如果 -Xss 设置过大,每个线程占用内存增加,在机器总内存固定的情况下,能够创建的线程数量会减少,严重时可能出现:
java.lang.OutOfMemoryError: unable to create native thread栈内存配置需要结合线程数量和调用深度判断。
例如,一个 Web 服务如果线程池配置较大,同时 -Xss 设置过大,就可能导致本地内存压力上升。
# 常见服务端配置示例
-Xss1m2
如果应用中没有特别深的递归调用或复杂调用链,通常不需要把 -Xss 设置得过大。
GC 参数
GC 参数用于指定垃圾回收器、控制 GC 停顿目标、设置 GC 线程数、输出 GC 日志等。
常见垃圾回收器选择参数如下:
| 参数 | 说明 |
|---|---|
-XX:+UseSerialGC | 使用 Serial + Serial Old |
-XX:+UseParallelGC | 使用 Parallel Scavenge + Parallel Old |
-XX:+UseG1GC | 使用 G1 垃圾回收器 |
-XX:+UseZGC | 使用 ZGC 垃圾回收器 |
-XX:+UseShenandoahGC | 使用 Shenandoah 垃圾回收器,取决于 JDK 发行版支持情况 |
G1 常用参数如下:
# 使用 G1 垃圾回收器
-XX:+UseG1GC
# 设置期望最大 GC 停顿时间为 200 毫秒
-XX:MaxGCPauseMillis=200
# 设置触发并发 GC 周期的堆占用比例
-XX:InitiatingHeapOccupancyPercent=452
3
4
5
6
7
8
MaxGCPauseMillis 是期望目标,不是绝对保证。JVM 会尽量朝这个目标优化,但实际停顿时间仍然受堆大小、对象数量、CPU、业务负载等因素影响。
Parallel GC 常用参数如下:
# 使用吞吐量优先的 Parallel GC
-XX:+UseParallelGC
# 设置最大 GC 停顿时间目标
-XX:MaxGCPauseMillis=500
# 设置吞吐量目标,值越大表示越关注吞吐量
-XX:GCTimeRatio=992
3
4
5
6
7
8
ZGC 常用参数如下:
# 使用 ZGC
-XX:+UseZGC
# 设置堆内存
-Xms4g
-Xmx4g
# 输出 GC 日志
-Xlog:gc*:file=/data/logs/app-gc.log:time,uptime,level,tags:filecount=5,filesize=100M2
3
4
5
6
7
8
9
对普通 Spring Boot 服务来说,常见 JVM 参数组合如下:
# 固定堆大小,使用 G1,输出 GC 日志,发生 OOM 时自动导出堆转储
-Xms2g
-Xmx2g
-Xss1m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xlog:gc*:file=/data/logs/app-gc.log:time,uptime,level,tags:filecount=5,filesize=100M
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump2
3
4
5
6
7
8
9
这组参数适合多数基于较新 JDK 的服务端应用作为初始配置参考。实际生产环境仍然需要结合压测结果、GC 日志、接口响应时间和机器资源进行调整。
常用诊断参数
诊断参数用于在线上发生问题时保留现场信息,例如 OOM 堆转储、错误日志、GC 日志等。相比盲目调大内存,诊断参数更有助于定位根因。
常用诊断参数如下:
| 参数 | 说明 |
|---|---|
-XX:+HeapDumpOnOutOfMemoryError | 发生 OOM 时自动导出堆转储文件 |
-XX:HeapDumpPath | 指定堆转储文件路径 |
-XX:ErrorFile | JVM 崩溃时生成错误日志 |
-XX:+PrintCommandLineFlags | 启动时打印 JVM 参数 |
-XX:+UnlockDiagnosticVMOptions | 解锁部分诊断参数 |
-XX:NativeMemoryTracking | 开启本地内存跟踪 |
-XX:+ExitOnOutOfMemoryError | 发生 OOM 时直接退出进程 |
-XX:+UseContainerSupport | 识别容器内存和 CPU 限制,较新 JDK 通常默认支持 |
OOM 诊断参数示例:
# 发生 OOM 时自动导出堆内存快照
-XX:+HeapDumpOnOutOfMemoryError
# 指定 dump 文件输出目录
-XX:HeapDumpPath=/data/dump
# 发生 OOM 时退出进程,便于容器或守护进程自动拉起
-XX:+ExitOnOutOfMemoryError2
3
4
5
6
7
8
JVM 崩溃日志参数示例:
# JVM 崩溃时输出 hs_err 日志
-XX:ErrorFile=/data/logs/hs_err_pid%p.log2
其中,%p 表示进程 PID。JVM 崩溃时会生成类似 hs_err_pid12345.log 的文件,里面包含线程、寄存器、动态库、内存映射等底层信息。
本地内存跟踪参数示例:
# 开启本地内存跟踪,summary 表示概要级别
-XX:NativeMemoryTracking=summary2
启动后可以配合 jcmd 查看本地内存使用情况:
# 查看指定 Java 进程的本地内存概要
jcmd <pid> VM.native_memory summary2
这里的 <pid> 需要替换成 Java 进程 ID。可以先通过 jps -l 查看 Java 进程,再使用 jcmd 查询本地内存。
常见生产环境参数模板如下:
-Xms2g
-Xmx2g
-Xss1m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xlog:gc*:file=/data/logs/app-gc.log:time,uptime,level,tags:filecount=5,filesize=100M
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
-XX:ErrorFile=/data/logs/hs_err_pid%p.log
-XX:+ExitOnOutOfMemoryError2
3
4
5
6
7
8
9
10
如果是 JDK 8,可以将 GC 日志部分替换为:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/data/logs/app-gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=100M2
3
4
5
6
7
实际 JVM 参数调优时,不建议只凭经验直接修改参数。更合理的流程是:先观察 GC 日志、接口耗时、CPU 使用率、堆内存曲线、线程数量和对象分布,再判断是内存配置问题、对象创建过多、缓存未释放、线程过多,还是垃圾回收器不适合当前业务场景。
JVM 工具
JDK 自带了一批 JVM 诊断工具,可以用于查看 Java 进程、分析线程状态、导出堆内存、观察 GC 情况以及进行图形化监控。实际开发和线上排查中,最常用的是 jps、jstack、jmap、jstat,图形化工具常用 jconsole 和 VisualVM。
这些工具通常位于 JDK 的 bin 目录下。如果服务器已经正确配置 JAVA_HOME 和 PATH,可以直接在命令行中使用。
jps
jps 用于查看当前机器上的 Java 进程。它类似于 Linux 中的 ps 命令,但专门用于查看 JVM 进程。
常用命令如下:
# 查看当前机器上的 Java 进程
jps
# 显示 Java 进程的完整类名、Jar 包路径
jps -l
# 显示传递给 main 方法的参数
jps -m
# 显示 JVM 启动参数
jps -v
# 显示完整信息,排查时常用
jps -lvm2
3
4
5
6
7
8
9
10
11
12
13
14
示例输出如下:
12345 app.jar
23456 org.apache.catalina.startup.Bootstrap
34567 sun.tools.jps.Jps2
3
其中,第一列是 Java 进程 ID,也就是 PID。后续使用 jstack、jmap、jstat 等工具时,通常都需要先通过 jps 找到目标 Java 进程的 PID。
常见使用场景如下:
| 场景 | 说明 |
|---|---|
| 查找 Java 进程 PID | 后续配合 jstack、jmap、jstat 使用 |
| 确认应用是否启动 | 查看目标 Jar 或主类是否存在 |
| 查看启动参数 | 使用 jps -v 检查 JVM 参数是否生效 |
| 多 Java 进程区分 | 使用 jps -l 查看完整类名或 Jar 路径 |
如果 jps 看不到目标进程,可能有以下原因:
| 原因 | 说明 |
|---|---|
| 不是同一用户执行 | 建议使用启动 Java 进程的同一用户执行 |
| JDK 环境异常 | JAVA_HOME 或 PATH 配置不正确 |
| 进程已退出 | 应用已经崩溃或停止 |
| 容器隔离 | 在 Docker 或 Kubernetes 环境中,需要进入对应容器查看 |
jstack
jstack 用于查看 Java 进程的线程堆栈信息。它是排查 CPU 飙高、线程死锁、线程阻塞、接口卡死等问题时非常重要的工具。
常用命令如下:
# 查看指定 Java 进程的线程堆栈
jstack <pid>
# 将线程堆栈输出到文件,便于后续分析
jstack <pid> > /tmp/jstack.log
# 输出更详细的锁信息
jstack -l <pid> > /tmp/jstack-lock.log2
3
4
5
6
7
8
其中,<pid> 需要替换为 Java 进程 ID,可以通过 jps -l 获取。
jstack 输出中常见线程状态如下:
| 线程状态 | 说明 |
|---|---|
RUNNABLE | 线程正在运行,或等待 CPU 调度 |
BLOCKED | 线程正在等待获取锁 |
WAITING | 线程无限期等待其他线程唤醒 |
TIMED_WAITING | 线程限时等待,例如 sleep、定时等待 |
NEW | 线程已创建但尚未启动 |
TERMINATED | 线程已经结束 |
排查死锁时,可以直接使用:
# 查看线程堆栈,并检查输出中是否存在 deadlock 信息
jstack -l <pid> > /tmp/deadlock.log2
如果存在 Java 层面的死锁,jstack 输出中通常会出现类似信息:
Found one Java-level deadlock:排查 CPU 飙高时,jstack 通常需要配合 top 使用:
# 查看 Java 进程中各线程的 CPU 使用情况
top -Hp <pid>
# 将十进制线程 ID 转换为十六进制
printf "%x\n" <tid>
# 导出线程堆栈
jstack <pid> > /tmp/jstack.log
# 根据十六进制线程 ID 在堆栈中查找对应线程
grep -n "<nid>" /tmp/jstack.log2
3
4
5
6
7
8
9
10
11
这里的 <tid> 是 top -Hp 中看到的线程 ID,<nid> 是转换后的十六进制线程 ID。在 jstack 输出中,线程 ID 通常以 nid=0x... 的形式出现。
jmap
jmap 用于查看 JVM 堆内存信息、对象统计信息,以及导出堆转储文件。它常用于排查内存溢出、内存泄漏、对象数量异常等问题。
常用命令如下:
# 查看堆内存概要信息
jmap -heap <pid>
# 查看堆中对象统计信息,按对象占用排序
jmap -histo <pid>
# 只统计存活对象,可能会触发 Full GC
jmap -histo:live <pid>
# 导出堆转储文件
jmap -dump:format=b,file=/tmp/heap.hprof <pid>
# 只导出存活对象,可能会触发 Full GC
jmap -dump:live,format=b,file=/tmp/heap-live.hprof <pid>2
3
4
5
6
7
8
9
10
11
12
13
14
jmap -histo 的输出通常包含对象数量、占用字节数和类名:
num #instances #bytes class name
1: 120000 9600000 java.lang.String
2: 50000 8000000 byte[]
3: 30000 3600000 java.util.HashMap$Node2
3
4
各列含义如下:
| 列名 | 说明 |
|---|---|
num | 排名 |
#instances | 对象实例数量 |
#bytes | 对象占用字节数 |
class name | 对象所属类名 |
导出的 .hprof 文件可以使用 MAT、VisualVM、JProfiler、YourKit 等工具分析。常见分析方向包括:
| 分析方向 | 说明 |
|---|---|
| 大对象 | 查找占用内存特别大的对象 |
| 对象数量异常 | 查找实例数量异常增长的类 |
| GC Roots 引用链 | 分析对象为什么没有被回收 |
| 集合膨胀 | 检查 Map、List、缓存对象是否过大 |
| 内存泄漏 | 找到无用但仍被引用的对象 |
需要注意的是,在线上环境执行 jmap -dump 可能会造成应用短暂停顿,堆越大,导出时间越长。生产环境应尽量在低峰期执行,或者优先依赖 OOM 自动导出的 dump 文件。
jstat
jstat 用于实时查看 JVM 运行统计信息,常用于观察 GC 次数、GC 耗时、堆内存变化、类加载数量等。
常用命令如下:
# 查看 GC 概要信息,每 1 秒输出一次,共输出 10 次
jstat -gcutil <pid> 1000 10
# 查看更详细的 GC 容量信息
jstat -gc <pid> 1000 10
# 查看 GC 原因
jstat -gccause <pid> 1000 10
# 查看类加载统计信息
jstat -class <pid> 1000 102
3
4
5
6
7
8
9
10
11
jstat -gcutil 常见输出如下:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 35.20 68.40 72.15 88.30 80.10 120 2.350 3 1.120 3.4702
常见字段说明如下:
| 字段 | 说明 |
|---|---|
S0 | Survivor 0 区使用百分比 |
S1 | Survivor 1 区使用百分比 |
E | Eden 区使用百分比 |
O | 老年代使用百分比 |
M | 元空间使用百分比 |
CCS | 压缩类空间使用百分比 |
YGC | Young GC 次数 |
YGCT | Young GC 总耗时 |
FGC | Full GC 次数 |
FGCT | Full GC 总耗时 |
GCT | GC 总耗时 |
通过 jstat 可以快速判断 GC 是否异常。例如:
| 现象 | 可能原因 |
|---|---|
YGC 增长很快 | 新生代对象创建频繁 |
FGC 持续增长 | 老年代压力大,可能存在内存泄漏 |
O 持续升高 | 老年代对象不断增加 |
Full GC 后 O 仍然很高 | 存活对象多,可能是缓存过大或泄漏 |
M 持续升高 | 类加载过多,可能存在类加载器泄漏 |
实际排查 GC 问题时,jstat 适合做实时观察,GC 日志适合做长期分析,堆 dump 适合做对象引用链分析。
jconsole 与 VisualVM
jconsole 和 VisualVM 都是图形化 JVM 监控工具,适合在测试环境、开发环境或临时排查时使用。
jconsole 是 JDK 自带的图形化监控工具,可以查看内存、线程、类加载、MBean 等信息。
启动方式如下:
# 启动 jconsole
jconsole2
jconsole 常见功能如下:
| 功能 | 说明 |
|---|---|
| 内存监控 | 查看堆、非堆内存使用情况 |
| 线程监控 | 查看线程数量、线程状态、死锁检测 |
| 类加载监控 | 查看已加载类数量 |
| VM 概要 | 查看 JVM 参数、系统属性 |
| MBean | 查看和操作 JMX 暴露的管理对象 |
VisualVM 是更强的图形化 JVM 分析工具,可以查看 CPU、内存、线程、堆 dump、线程 dump 等信息,还可以安装插件扩展功能。
VisualVM 常见功能如下:
| 功能 | 说明 |
|---|---|
| 进程监控 | 查看本地或远程 Java 进程 |
| CPU 分析 | 分析方法调用耗时 |
| 内存分析 | 查看对象数量和内存占用 |
| 线程分析 | 查看线程状态和线程 dump |
| 堆 dump 分析 | 打开 .hprof 文件分析内存泄漏 |
| GC 观察 | 查看堆变化和 GC 行为 |
图形化工具适合直观观察趋势,但在线上服务器中通常不建议直接长时间连接生产应用。生产问题排查一般优先使用命令行工具和日志文件,必要时再导出 dump 到本地分析。
常见问题
JVM 常见问题通常集中在内存结构、垃圾回收、类加载和线上故障排查几个方面。掌握这些问题,有助于理解 JVM 原理,也能提高线上问题定位效率。
堆和栈的区别
堆和栈是 JVM 中最常被比较的两个内存区域。堆主要存放对象实例,栈主要存放方法调用过程中的局部变量、操作数栈、返回地址等数据。
主要区别如下:
| 对比项 | 堆 | 栈 |
|---|---|---|
| 存储内容 | 对象实例、数组 | 方法调用栈帧、局部变量、操作数栈 |
| 线程关系 | 线程共享 | 线程私有 |
| 生命周期 | 对象由 GC 管理 | 方法调用结束后栈帧自动出栈 |
| 空间大小 | 通常较大 | 通常较小 |
| 是否 GC 管理 | 是,GC 重点管理区域 | 通常不需要 GC 管理 |
| 常见异常 | OutOfMemoryError: Java heap space | StackOverflowError |
| 参数配置 | -Xms、-Xmx | -Xss |
示例代码:
User user = new User();这行代码可以简单理解为:
Java 虚拟机栈
└── 局部变量表
└── user 引用
堆
└── User 对象实例2
3
4
5
6
user 这个局部变量通常位于当前线程的虚拟机栈中,而 new User() 创建出来的对象实例位于堆中。栈中保存的是对象引用,堆中保存的是对象本身。
常见误区是认为“对象引用一定在栈中,对象一定在堆中”。通常情况下可以这样理解,但在 JIT 优化、逃逸分析、标量替换等优化存在时,部分对象未必一定真实分配到堆中。基础学习阶段可以先按“引用在栈、对象在堆”理解。
方法区和元空间的区别
方法区是 JVM 规范中的逻辑概念,用于存储类元信息、运行时常量池、静态变量、方法信息等内容。元空间是 HotSpot 虚拟机在 JDK 8 之后对方法区的一种具体实现。
二者关系可以理解为:方法区是规范,元空间是实现。
区别如下:
| 对比项 | 方法区 | 元空间 |
|---|---|---|
| 本质 | JVM 规范定义的运行时数据区 | HotSpot 对方法区的实现 |
| 出现范围 | JVM 规范层面一直存在 | JDK 8 之后引入 |
| 存储内容 | 类元信息、常量、静态变量等 | 主要存储类元信息 |
| 内存位置 | 取决于具体 JVM 实现 | 使用本地内存 |
| 常见异常 | 方法区溢出 | OutOfMemoryError: Metaspace |
JDK 8 之前,HotSpot 使用永久代实现方法区。JDK 8 之后,永久代被移除,改为使用元空间。
JDK 7 及之前
方法区 ≈ 永久代
JDK 8 及之后
方法区 ≈ 元空间2
3
4
5
元空间使用本地内存,不再直接占用 JVM 堆内存,但这不代表元空间不会溢出。如果应用动态生成大量类,或者存在类加载器泄漏,仍然可能出现:
java.lang.OutOfMemoryError: Metaspace常见导致元空间溢出的场景包括:
| 场景 | 说明 |
|---|---|
| 动态代理类过多 | 大量生成代理类 |
| CGLIB 字节码增强过多 | 动态生成大量增强类 |
| 热部署类加载器泄漏 | 老的类加载器无法释放 |
| 应用加载类过多 | 第三方依赖或模块过多 |
| 元空间限制过小 | -XX:MaxMetaspaceSize 设置过小 |
常用元空间参数如下:
# 设置元空间初始大小
-XX:MetaspaceSize=128m
# 设置元空间最大大小
-XX:MaxMetaspaceSize=256m2
3
4
5
Minor GC、Major GC 与 Full GC 的区别
Minor GC、Major GC 和 Full GC 都是垃圾回收相关概念,但它们的回收范围和触发场景不同。
区别如下:
| 类型 | 回收范围 | 常见触发场景 | 特点 |
|---|---|---|---|
| Minor GC | 新生代 | Eden 区空间不足 | 频率高,速度相对快 |
| Major GC | 通常指老年代 GC | 老年代空间不足 | 不同资料和收集器中含义可能不完全一致 |
| Full GC | 整个堆和方法区相关区域 | 老年代不足、元空间不足、显式 GC 等 | 停顿通常较长,影响较大 |
Minor GC 主要发生在新生代。当 Eden 区空间不足,无法继续分配新对象时,通常会触发 Minor GC。
对象优先分配到 Eden
↓
Eden 空间不足
↓
触发 Minor GC
↓
存活对象进入 Survivor 或晋升老年代2
3
4
5
6
7
Major GC 在不同语境下容易产生歧义。有些资料中 Major GC 指老年代 GC,有些工具或日志中可能把 Major GC 和 Full GC 混用。因此实际分析时,不建议只看名称,而要结合 GC 日志确认回收范围。
Full GC 通常会回收整个 Java 堆,并可能涉及方法区或元空间相关回收。Full GC 的停顿时间通常比 Minor GC 更长,对接口响应影响也更明显。
常见 Full GC 触发原因如下:
| 触发原因 | 说明 |
|---|---|
| 老年代空间不足 | 新生代对象晋升失败或老年代无法分配 |
| 元空间不足 | 类元信息过多 |
| 显式调用 GC | 代码调用 System.gc() |
| 堆 dump 操作 | 某些 jmap -dump:live 操作可能触发 |
| 空间分配担保失败 | Minor GC 前发现老年代空间可能不足 |
| 大对象分配失败 | 大对象无法在堆中找到合适空间 |
判断 GC 是否异常,需要重点观察:
| 指标 | 判断方式 |
|---|---|
| Minor GC 频率 | 是否过于频繁 |
| Full GC 次数 | 是否持续增长 |
| Full GC 耗时 | 是否影响接口响应 |
| GC 后老年代占用 | 是否明显下降 |
| GC 后堆使用趋势 | 是否持续升高 |
如果 Full GC 很频繁,并且 Full GC 后老年代占用仍然较高,通常需要排查内存泄漏、缓存过大、对象生命周期过长或堆内存配置不足。
什么情况下会发生内存溢出
内存溢出,即 OutOfMemoryError,表示 JVM 在申请内存时无法获得足够空间。不同内存区域溢出时,错误信息和排查方向不同。
常见内存溢出类型如下:
| 异常信息 | 可能区域 | 常见原因 |
|---|---|---|
Java heap space | 堆 | 对象过多、内存泄漏、堆太小 |
GC overhead limit exceeded | 堆 | GC 频繁但回收效果很差 |
Metaspace | 元空间 | 类加载过多、类加载器泄漏 |
Direct buffer memory | 直接内存 | NIO、Netty、堆外内存释放不及时 |
unable to create native thread | 本地内存 / 线程 | 线程数过多、栈设置过大、系统资源不足 |
StackOverflowError | 虚拟机栈 | 递归过深、方法调用层级过深 |
堆内存溢出常见原因如下:
| 原因 | 示例 |
|---|---|
| 集合无限增长 | List、Map 持续添加数据不清理 |
| 缓存未设置上限 | 本地缓存长期持有大量对象 |
| 查询数据量过大 | 一次性加载大量数据库记录 |
| 文件读取方式不合理 | 大文件一次性读入内存 |
| 对象引用未释放 | 无用对象仍被静态变量、线程、本地缓存引用 |
元空间溢出常见原因如下:
| 原因 | 示例 |
|---|---|
| 动态生成类过多 | 频繁生成代理类 |
| 类加载器泄漏 | 热部署后旧类加载器无法释放 |
| 依赖或模块过多 | 应用加载类数量过大 |
| 元空间上限过小 | MaxMetaspaceSize 设置不合理 |
线程创建失败常见原因如下:
| 原因 | 说明 |
|---|---|
| 线程池配置过大 | 创建大量业务线程 |
| 每个线程栈过大 | -Xss 设置过大 |
| 系统线程数限制 | Linux 用户进程数限制 |
| 容器资源不足 | 容器内存或 PID 限制过小 |
排查内存溢出时,建议按以下步骤处理:
# 查看 Java 进程
jps -l
# 查看 JVM 启动参数
jps -lv
# 查看堆内存和 GC 情况
jstat -gcutil <pid> 1000 10
# 查看对象数量统计
jmap -histo <pid> | head -n 50
# 导出堆转储文件,谨慎在线上高峰期执行
jmap -dump:format=b,file=/tmp/heap.hprof <pid>2
3
4
5
6
7
8
9
10
11
12
13
14
更推荐在生产环境提前配置 OOM 自动导出:
# 发生 OOM 时自动导出堆转储文件
-XX:+HeapDumpOnOutOfMemoryError
# 指定堆转储文件目录
-XX:HeapDumpPath=/data/dump
# 发生 OOM 后退出进程,便于容器或守护进程自动重启
-XX:+ExitOnOutOfMemoryError2
3
4
5
6
7
8
拿到 dump 文件后,使用 MAT 或 VisualVM 分析大对象、对象数量、GC Roots 引用链,重点判断对象为什么没有被回收。
如何排查 CPU 飙高问题
CPU 飙高通常表示某些线程长时间占用 CPU。Java 应用中常见原因包括死循环、复杂计算、频繁 GC、锁竞争、线程池任务堆积等。
排查 CPU 飙高的核心思路是:先找到 Java 进程,再找到高 CPU 线程,然后根据线程堆栈定位具体代码。
第一步,找到 Java 进程 PID:
# 查看 Java 进程
jps -l
# 或者使用 ps 查看 Java 进程
ps -ef | grep java2
3
4
5
第二步,查看该进程中线程级别的 CPU 使用情况:
# 查看指定 Java 进程下各线程的 CPU 使用情况
top -Hp <pid>2
在输出中找到 CPU 使用率较高的线程 ID,也就是 TID。
第三步,将十进制线程 ID 转换为十六进制:
# 将线程 ID 转成十六进制
printf "%x\n" <tid>2
例如:
# 12376 转换为十六进制
printf "%x\n" 123762
输出:
3058第四步,导出 Java 线程堆栈:
# 导出线程堆栈到文件
jstack <pid> > /tmp/jstack.log2
第五步,在堆栈中查找对应线程:
# 根据 nid 查找线程堆栈,注意 jstack 中通常带 0x 前缀
grep -n "nid=0x3058" /tmp/jstack.log2
找到对应线程后,重点查看该线程正在执行的方法调用栈。例如:
"pool-1-thread-1" #32 prio=5 os_prio=0 tid=0x00007f nid=0x3058 runnable
java.lang.Thread.State: RUNNABLE
at com.example.service.OrderService.calculate(OrderService.java:88)
at com.example.service.OrderService.process(OrderService.java:45)2
3
4
如果线程状态是 RUNNABLE,并且多次采样都停留在同一段业务代码,通常说明该代码可能存在死循环、复杂计算或大量数据处理。
建议连续导出多次线程堆栈进行对比:
# 连续采集 3 次线程堆栈,间隔 5 秒
jstack <pid> > /tmp/jstack-1.log
sleep 5
jstack <pid> > /tmp/jstack-2.log
sleep 5
jstack <pid> > /tmp/jstack-3.log2
3
4
5
6
如果同一个高 CPU 线程在多次堆栈中都停留在相同方法,定位价值更高。
CPU 飙高的常见原因和判断方式如下:
| 原因 | 现象 | 排查方式 |
|---|---|---|
| 死循环 | 某线程长期 RUNNABLE,堆栈固定 | top -Hp + jstack |
| 大量计算 | CPU 高,堆栈指向计算方法 | 分析业务算法和数据量 |
| 频繁 GC | CPU 高,同时 GC 次数快速增长 | jstat -gcutil、GC 日志 |
| 锁竞争 | 多线程 BLOCKED | jstack -l 查看锁竞争 |
| 线程池任务堆积 | 线程池线程长期繁忙 | 查看线程池、队列、业务耗时 |
| 正则回溯 | 堆栈停留在正则匹配相关方法 | 检查复杂正则表达式 |
| 日志过量 | 堆栈涉及日志输出、字符串拼接 | 检查日志级别和日志量 |
如果怀疑是频繁 GC 导致 CPU 高,可以同时观察 GC:
# 查看 GC 是否频繁
jstat -gcutil <pid> 1000 102
如果 YGC 或 FGC 快速增长,并且 GC 耗时明显增加,说明 CPU 高可能与垃圾回收有关。此时需要继续查看 GC 日志、堆使用情况和对象分布。
如果怀疑是锁竞争,可以使用:
# 输出线程堆栈和锁信息
jstack -l <pid> > /tmp/jstack-lock.log2
重点关注大量线程是否处于 BLOCKED 状态,以及是否等待同一把锁。
完整排查流程可以总结为:
CPU 飙高
↓
jps 找到 Java 进程 PID
↓
top -Hp 找到高 CPU 线程 TID
↓
printf 转换 TID 为十六进制
↓
jstack 导出线程堆栈
↓
根据 nid 定位线程
↓
分析线程状态和业务调用栈
↓
结合 jstat / GC 日志 / 业务日志确认原因2
3
4
5
6
7
8
9
10
11
12
13
14
15
实际处理时,不建议只采集一次线程堆栈就下结论。更稳妥的方式是连续采集多次,确认高 CPU 线程是否持续停留在同一段代码,再结合业务日志、调用链、慢 SQL、GC 日志一起判断根因。