Java后端-基础-Java虚拟机-21
背景
本文是《Java 后端从小白到大神》修仙系列第二十一篇
,正式进入Java后端
世界,本篇文章主要聊Java基础
。若想详细学习请点击首篇博文,我们开始把。
文章概览
- JVM基础与架构
- JVM 内存区域与垃圾回收
- 类加载机制
- JVM 性能调优
- 高级主题与实战
- 实践项目
JVM
1. JVM 基础与架构
1. JVM 概述
JVM 的作用与核心职责,JVM(Java Virtual Machine,Java 虚拟机) 是 Java 技术的核心组件,负责将 Java 代码转换为可执行的计算机指令,并管理代码的运行环境。它是 “一次编写,到处运行”(Write Once, Run Anywhere)。核心职责,字节码解释与执行,内存管理与垃圾回收,安全与隔离,多线程与并发支持。Oracle 发布的《Java 虚拟机规范》定义了 JVM 的抽象行为标准,包括字节码格式、类加载流程、内存模型等。核心内容,字节码指令集(如 invokevirtual、getfield),类文件结构(魔数 0xCAFEBABE、常量池),运行时数据区(堆、栈、方法区),异常处理机制。JVM 规范可以有多种实现,如HotSpot、GraalVM、OpenJ9、Zing。Java 代码执行流程:源码 → 字节码 → JVM 执行。
2. JVM 内存模型(JMM)
Java 内存模型(JMM)定义了线程
与主内存
之间的交互规则,核心目标是解决多线程并发中的可见性、有序性和原子性问题。
1. 主内存与工作内存
- 主内存(Main Memory)
所有线程共享的内存区域,存储实例变量(成员变量)、类变量(静态变量)。 - 工作内存(Working Memory)
每个线程私有的内存区域,保存该线程使用的变量的副本。
关键规则:- 线程不能直接操作主内存中的变量,必须通过工作内存间接操作。
- 线程间变量值的传递需通过主内存完成(见下图)。
线程A工作内存 → 主内存 → 线程B工作内存
2. 内存间交互操作
JMM 定义了 6 种原子操作,用于主内存与工作内存间的数据同步:
操作 | 作用 |
---|---|
read | 从主内存读取变量到工作内存(仅传输,未赋值)。 |
load | 将 read 得到的变量值赋给工作内存中的变量副本。 |
use | 线程执行时,从工作内存读取变量值到操作栈(如计算、方法调用)。 |
assign | 将操作栈中的新值赋给工作内存中的变量副本(修改变量值)。 |
store | 将工作内存中的变量副本传回主内存(仅传输,未写入)。 |
write | 将 store 传回的变量值写入主内存的变量中。 |
规则与顺序:
read
和load
、store
和write
必须成对出现且顺序执行。- 不允许丢弃
assign
操作(修改后必须同步到主内存)。 - 未发生
assign
的变量不允许同步到主内存。
3. volatile
关键字与内存可见性
volatile
是 Java 提供的一种 轻量级同步机制,它的底层机制主要依赖于内存可见性 和禁止指令重排序,其核心是 CPU 内存屏障(Memory Barrier)
和 缓存一致性协议(MESI)
。
1. volatile
的底层实现机制
(1) 保证可见性:MESI 协议(缓存一致性协议)
在多核 CPU 下,每个线程通常会把数据缓存到自己的 CPU 缓存 中,导致不同线程看到的数据可能不一致。volatile
通过 内存屏障 + CPU 缓存一致性协议(MESI),保证变量的可见性:
volatile
变量修改后,JVM 会在汇编层面插入lock
前缀的指令,比如lock addl $0x0, (%rsp)
,相当于触发 缓存一致性协议(MESI 协议),强制:- 将当前 CPU 缓存中的变量刷新到主内存
- 使其他 CPU 缓存中的该变量失效,下次使用时必须重新从主内存读取
(2) 保证有序性:内存屏障(Memory Barrier)
在 CPU 指令执行时,为了优化性能,可能会进行 指令重排序(Instruction Reordering),即:
- 代码编写顺序 ≠ 实际执行顺序
- 编译器和 CPU 可能会调整指令顺序,以提升性能
如何禁止指令重排序?
volatile
变量的读写操作前后,JVM 插入内存屏障(Memory Barrier),来防止编译器和 CPU 进行 指令重排序。- 主要的内存屏障:
- 写屏障(Store Barrier):确保 volatile 变量的修改 对其他线程立即可见
- 读屏障(Load Barrier):确保 volatile 变量的读取 只能在其前面的指令执行完之后进行
2. volatile
的汇编指令
通过 javap -c -verbose
反编译 volatile
代码,我们可以看到 lock
指令:
反编译后:
0x10: lock addl $0x1, 0x10(%rsp) ; `lock` 指令触发缓存一致性协议,保证可见性
lock
指令会触发 MESI 协议,让其他 CPU 的缓存失效,从主内存读取最新值,保证 可见性。lock
也会 防止指令重排序,保证 有序性。
3. volatile
的局限性
-
不保证原子性
volatile
仅保证可见性和有序性,但不保证操作的原子性。- 例如
count++
其实是三步:- 读取
count
+1
计算- 写入
count
- 读取
- 在多线程环境下,多个线程可能同时读取相同的
count
,然后分别 +1,再写回,导致数据丢失(竞态条件)。 - 解决方案:使用
AtomicInteger
或synchronized
。
-
不支持互斥
volatile
不能用于实现临界区(Critical Section),因为它不能保证多个线程对同一资源的互斥访问。synchronized
适用于保证互斥,volatile
只适用于状态标记、单例模式中的双重检查锁等场景。
4. volatile
适用场景
-
标志位(状态变量)
- 由于
volatile
变量的修改对所有线程立即可见,适合用作标志位,如任务终止信号。
- 由于
-
单例模式(双重检查锁)
volatile
保证instance
变量在不同线程间的可见性,防止指令重排序,确保对象正确初始化。
4. 原子性、可见性、有序性
原子性(Atomicity)
- 定义:操作不可分割,要么全部执行,要么不执行。
- 实现方式:
- 使用
synchronized
或Lock
锁。 - 使用原子类(如
AtomicInteger
)。
- 使用
可见性(Visibility)
- 定义:一个线程修改共享变量后,其他线程能立即看到最新值。
- 实现方式:
volatile
关键字。synchronized
(解锁前将变量同步到主内存)。final
关键字(不可变变量的初始化安全)。
有序性(Ordering)
- 定义:程序执行顺序符合代码的先后顺序(禁止指令重排序)。
- 破坏有序性的原因:
- 编译器优化(如方法内联)。
- 处理器指令级并行优化(如流水线、乱序执行)。
- 实现方式:
volatile
关键字(插入内存屏障)。synchronized
(锁的互斥性隐式保证有序性)。happens-before
原则(JMM 定义的内存操作顺序规则)。
5. JMM 的 happens-before
原则
在 Java 内存模型(JMM)中,happens-before(先行发生) 是定义线程间内存可见性的规则,保证某些操作的执行顺序,让多线程程序运行时不会出现意外的并发问题,核心:happens-before 规则决定了 Java 线程间的可见性,保证了 JMM 的正确执行。
简单来说:
- 如果 A happens-before B,那么A 的结果对 B 可见,B 不能重排序到 A 之前。
- 如果 A 不 happens-before B,那么B 可能看不到 A 的修改,甚至可能乱序执行。
JMM 规定了 8 种 happens-before 关系,如果两个操作满足其中之一,就保证它们的执行顺序和可见性。
(1) 单线程内的程序顺序规则(Program Order Rule)
同一个线程内,代码按顺序执行,前面的操作 happens-before 后面的操作:
保证:语句 (1) 先于语句 (2) 执行,(2) 一定能看到 (1) 的结果。
(2) 监视器锁(Lock)规则
一个 unlock 操作 happens-before 之后的 lock 操作(对同一把锁)。
保证:
- (1) unlock 之后,(2) lock 之前的所有操作对 (2) 可见。
- 防止多个线程同时执行
synchronized
块。
(3) volatile
变量规则
对 volatile
变量的写操作 happens-before 之后的读操作:
保证:
- 线程 A 修改
flag
之后,线程 B 立即可见,(2) 读取flag
时一定能看到 (1) 的结果。 - 这个规则防止指令重排序。
(4) 线程 start()
规则
线程 start()
之前的操作 happens-before 线程的 run()
方法
保证:
- (1) 在 (3)
start()
之前执行,那么 (2) 线程的run()
方法一定能看到 (1) 的结果。
(5) 线程 join()
规则
线程 join()
之前的操作 happens-before join()
之后的操作
保证:
- (1) 在 (2)
join()
之前执行,那么 (3) 一定能看到 (1) 的结果。
(6) 线程 interrupt()
规则
interrupt()
先执行,isInterrupted()
结果可见
保证:
- (1)
interrupt()
先执行,isInterrupted()
一定能检测到这个中断信号。
(7) final
变量规则
构造函数对 final
变量的赋值 happens-before 该对象被其他线程使用
保证:
- (1) 赋值后,(3) 一定能看到
x = 100
。
(8) 传递性规则
如果 A happens-before B,且 B happens-before C,那么 A happens-before C
- (1) happens-before (2),所以 (2) 一定能看到
a = 10
。
(9) happens-before 和 as-if-serial 规则的区别
happens-before
- 多线程的可见性规则
- 保证内存同步
- 跨线程生效
as-if-serial
- 单线程中的重排序优化
- 不能改变程序的最终结果
- 只影响性能,不影响语义
即使 CPU 交换了 (1) 和 (2) 的顺序,也不影响最终结果。
(10) 表格总结
happens-before 规则 | 作用 |
---|---|
程序顺序 | 单线程代码按顺序执行 |
锁(synchronized) | 解锁 happens-before 之后的加锁 |
volatile 变量 |
volatile 写 happens-before 之后的读 |
start() 规则 |
start() happens-before run() |
join() 规则 |
线程执行完 happens-before join() |
interrupt() 规则 |
interrupt() happens-before isInterrupted() |
final 规则 |
final 变量初始化 happens-before 其他线程读 |
传递性 | A happens-before B,B happens-before C,⇒ A happens-before C |
2. JVM 内存区域与垃圾回收
1. 运行时数据区
1. 程序计数器(PC Register)
- 作用:存储当前线程执行的字节码指令地址(即下一条将要执行的指令)。
- 特点:
- 每个线程都有独立的 PC 寄存器(线程私有)。
- 如果线程执行的是 Java 方法,则存储下一条指令的地址。
- 如果线程执行的是 Native 方法,则 PC 计数器为空(Undefined)。
示例代码(看不见 PC 寄存器,但可以推测其作用):
分析:
- PC 计数器会随着方法指令流动而变化,每行代码执行时,PC 计数器都会记录当前执行的位置。
2. Java 虚拟机栈(Stack)与栈帧结构
- 作用:存储方法调用时的局部变量、操作数栈、动态链接、方法返回地址等。
- 特点:
- 线程私有,每个线程都有自己的Java 虚拟机栈。
- 每调用一次方法就会创建一个栈帧,存放该方法的局部变量和操作数栈。
- 方法执行完毕后,栈帧销毁,栈空间回收。
- 如果栈深度超出 JVM 允许的最大范围,会抛出
StackOverflowError
。 - 操作数栈是一个后进先出(LIFO)的栈结构,操作数栈用于存储中间计算结果和操作数。
- 栈帧既包括局部变量表(Local Variable Table)、操作数栈(Operand Stack)、动态连接(Dynamic Linking)方法返回地址(Return Address)、附加信息。
示例代码(递归导致 StackOverflowError
):
分析:
- 每次方法调用,都会创建新的栈帧,直到栈空间耗尽,抛出
StackOverflowError
。
3. 本地方法栈(Native Method Stack)
- 作用:用于执行 Native 方法(C 语言实现的 JNI 方法)。
- 特点:
- 和 Java 虚拟机栈类似,但它是为本地方法(C 语言等)服务的。
- 可能出现
StackOverflowError
或OutOfMemoryError
。 - 线程私有
示例代码(调用本地方法):
分析:
nativeMethod()
运行在 Native 方法栈,而不是 Java 虚拟机栈。
4. 堆(Heap)的分代模型
(1)概述
- 作用:存储所有对象(包括实例变量和数组)。
- 特点:
- 线程共享,整个 JVM 只有一个堆。
- **垃圾回收(GC)**主要发生在堆上。
- 采用分代管理,提高垃圾回收效率。
(2)堆的分代模型
区域 | 说明 |
---|---|
年轻代(Young Generation) | 存放新创建的对象,分为 Eden、Survivor0、Survivor1 |
老年代(Old Generation) | 存放长生命周期对象 |
元空间(Metaspace) | 存放类元信息,取代原来的方法区 |
示例代码(堆内存分配):
分析:
- 大量创建对象,会触发年轻代 GC(Minor GC)。
- 如果对象存活足够久,会晋升到 老年代。
5. 方法区(Method Area)与元空间(Metaspace)
- 作用:存储 类的元数据(类名、修饰符、父类、接口、字段、方法等)、类的字节码、方法字节码、静态变量、运行时常量池、JIT编译后的代码、类加载信息。
- 特点:
- JDK 8 之前:方法区在堆中,称为“永久代(PermGen)”。
- JDK 8 及以后:方法区使用本地内存(Metaspace),称为元空间,不受堆大小限制。
示例代码(动态创建类,占用方法区):
|
|
分析:
- 这段代码不断创建新类,最终会导致
OutOfMemoryError: Metaspace
。
6. 常量池(Constant Pool)与运行时常量池(Runtime Constant Pool)
对比项 | 常量池(Class Constant Pool) | 运行时常量池(Runtime Constant Pool) |
---|---|---|
存储位置 | .class 文件的常量池部分 |
JVM 方法区(Metaspace) |
存储内容 | 编译期静态常量(字面量、符号引用) | 运行时加载的动态常量 |
作用 | 提供字节码解析用的符号引用 | 运行时解析后的直接引用 |
加载时机 | 编译期由编译器写入 .class 文件 |
类加载时,解析 .class 的常量池数据 |
是否可扩展 | 不可变(固定在 .class 文件中) |
可扩展(可动态加入新的常量,如 String.intern() ) |
当 JVM 加载 .class 文件时,会把 .class 文件里的常量池加载到内存中,形成运行时常量池,是常量池在运行时的表现形式。
|
|
分析:
"HelloWorld"
在编译期已存入.class
文件的 常量池。str4 = (str1 + str2).intern();
运行时动态计算,然后尝试放入 运行时常量池。str3 == str4
结果为true
,因为str4
通过intern()
方法,返回的是 运行时常量池 的引用。
2. 垃圾回收(GC)
Java 虚拟机(JVM)的垃圾回收机制用于回收不再使用的对象,优化内存使用,提升应用性能。以下是 GC 相关的核心概念:
1. 对象存活性判断
JVM 需要判断对象是否仍然存活,常见方法有引用计数法和可达性分析。
(1) 引用计数法(Reference Counting)
- 原理:给每个对象维护一个引用计数器,引用增加时计数 +1,引用减少时计数 -1,计数为 0 的对象可被回收。
- 缺点:无法处理循环引用,可能导致内存泄漏。
示例(循环引用导致对象无法被回收):
|
|
(2) 可达性分析(Reachability Analysis)
- 原理:以GC Roots 作为起点,进行可达性遍历(Reachability Search)。
- GC Roots 包括:
- 栈中的局部变量(方法栈帧中的变量)
- 方法区中的静态变量
- 方法区中的常量引用
- 本地方法栈中的 JNI 引用
- 对象不可达(没有从 GC Roots 直达的引用链)时,才会被回收。
示例(对象变成不可达状态后可被回收):
2. GC 算法
(1) 标记-清除(Mark-Sweep)
- 步骤:
- 标记:从 GC Roots 遍历所有可达对象,做标记。
- 清除:未被标记的对象被回收。
- 优点:实现简单,无需移动对象。
- 缺点:
- 产生内存碎片,可能导致大对象无法分配。
- 清理速度较慢。
(2) 标记-整理(Mark-Compact)
- 步骤:
- 标记:标记可达对象。
- 整理:将存活对象移动到内存一端,回收所有未标记对象的空间。
- 优点:消除内存碎片。
- 缺点:对象移动需要额外成本。
(3) 复制算法(Copying)
- 步骤:
- 将对象分配在 Eden 区。
- 存活对象复制到 Survivor 区。
- Survivor 空间不够时,晋升到老年代。
- 优点:适用于短生命周期对象,无碎片问题。
- 缺点:需要额外的内存空间。
3. 分代收集理论与 GC 触发条件
JVM 使用分代垃圾回收策略:
- 年轻代(Young Generation):
- 采用复制算法(Copying)。
- 触发 Minor GC(回收年轻代)。
- 老年代(Old Generation):
- 采用标记-整理算法(Mark-Compact)。
- 触发 Major GC / Full GC(回收整个堆)。
- 元空间(Metaspace):
- 存储类元信息。
- 当 Metaspace 内存不足时,会触发 GC。
GC 触发条件
- Minor GC(新生代 GC):
- 触发时机:Eden 区满时触发。
- 影响范围:仅清理新生代,存活对象移至 Survivor 区或老年代。
- Major GC(老年代 GC):
- 触发时机:老年代空间不足。
- 影响范围:清理老年代,时间较长。
- Full GC(全堆 GC):
- 触发时机:
- 老年代满了。
System.gc()
被调用(可能触发)。- Metaspace 空间不足。
- 影响范围:清理整个堆,通常比 Minor GC 慢得多。
- 触发时机:
4. 垃圾收集器对比
年轻代垃圾收集器
GC 类型 | 特点 | 适用场景 |
---|---|---|
Serial | 单线程 GC,适用于单核 CPU | Client 端 |
ParNew | Serial 的多线程版本 | 适用于 多核 CPU |
Parallel Scavenge | 追求高吞吐量,适合后台任务 | 吞吐量优先应用 |
老年代 & 全堆垃圾收集器
GC 类型 | 特点 | 适用场景 |
---|---|---|
CMS(Concurrent Mark-Sweep) | 低停顿,并发收集 | 低延迟应用(Web 服务器) |
G1(Garbage First) | 以区域(Region)为单位回收,适合大内存 | 低延迟,适合大内存 |
ZGC(Z Garbage Collector) | 超低停顿时间(10ms 以内) | 适用于 10GB 以上堆内存 |
Shenandoah | 类似 ZGC,低停顿时间 | 适用于大型应用 |
5. GC 日志分析与调优参数
常见的 GC 日志格式
|
|
启用 GC 日志
|
|
示例 GC 日志
常见 JVM 调优参数
参数 | 作用 |
---|---|
-Xms |
初始堆大小 |
-Xmx |
最大堆大小 |
-XX:NewRatio |
新生代/老年代比例 |
-XX:SurvivorRatio |
Eden/Survivor 比例 |
-XX:+UseG1GC |
使用 G1 GC |
-XX:+UseZGC |
使用 ZGC |
3. 类加载机制
Java 类加载(Class Loading)是 JVM 运行时动态加载类的过程,涉及 加载(Loading)、链接(Linking)、初始化(Initialization) 三个主要阶段。
1. 类加载过程
1. 类加载过程
(1)加载(Loading)
- JVM 通过类加载器(ClassLoader)找到
.class
文件并读取字节码。 - 在方法区中创建类的运行时数据结构,并生成
java.lang.Class
对象。
示例
当 TestClass
被第一次使用时,JVM 会触发加载。
(2)链接(Linking)
链接阶段包含 验证(Verify)、准备(Prepare)、解析(Resolve) 三个步骤:
-
验证(Verify):
- 确保字节码的合法性(例如:防止非法访问、格式错误等)。
- 如果验证失败,会抛出
java.lang.VerifyError
。
-
准备(Prepare):
- 为类的静态变量分配内存,并设置默认值(未执行初始化赋值)。
-
解析(Resolve):
-
将 符号引用(Symbolic References) 转换为 直接引用(Direct References)。
-
例如:
1
String s = "Hello";
这里
"Hello"
是一个符号引用,解析后指向实际的String
对象。
-
(3) 初始化(Initialization)
- 执行类的静态代码块、静态变量的初始化。
- JVM 触发初始化的情况
new
关键字创建对象。- 访问类的静态变量或方法。
- 通过
Class.forName("XXX")
反射加载类。
示例
2. 类加载器层次结构
JVM 通过类加载器(ClassLoader)动态加载类,类加载器是分层的。
类加载器 | 作用 | 加载路径 |
---|---|---|
Bootstrap ClassLoader | 加载 JDK 内部核心类 | rt.jar 、java.base |
Extension ClassLoader | 加载扩展类库 | lib/ext/ 目录 |
Application ClassLoader | 加载用户代码 | classpath |
Custom ClassLoader | 开发者自定义类加载器 | 可加载任意自定义路径 |
3. 双亲委派模型
双亲委派机制(Parent Delegation Model):
- 类加载请求先向上委派,从 Application -> Extension -> Bootstrap 逐级查找。
- 如果父加载器找不到类,则由当前类加载器加载。
示例:模拟双亲委派
|
|
输出
ClassLoader: sun.misc.Launcher$AppClassLoader@...
Parent ClassLoader: sun.misc.Launcher$ExtClassLoader@...
Grandparent ClassLoader: null (Bootstrap 是 C++ 实现的)
4. 破坏双亲委派模型的场景
(1)Java SPI 机制
Java SPI(Service Provider Interface)是一种服务发现
机制,它允许第三方为某个接口提供实现。其核心思想是将接口定义和具体实现分离,使得程序可以在运行时动态地发现和加载服务的实现类。这种机制的好处在于提高了程序的可扩展性和解耦性,让开发者可以通过配置文件来灵活地切换服务的实现。
示例:SPI 加载
SPI 加载
|
|
Java 的 ServiceLoader
通过线程上下文类加载器(Thread Context ClassLoader,TCCL)加载 MySQL 驱动,而不是 Bootstrap 加载器。
(2)OSGi 模块化系统
OSGi 允许多个版本的同一类共存,因此不能依赖双亲委派,而是由 各个模块的类加载器 独立加载类。
2. 字节码与执行引擎
Java 的字节码与执行引擎涉及多个核心概念,主要包括 Class 文件结构、字节码指令、解释器(Interpreter)与 JIT(C1/C2)、逃逸分析等优化技术。
1. Class 文件结构
Java 源代码经过 javac
编译后会生成 .class
文件,它是 JVM 可识别的字节码文件,主要由以下结构组成:
结构 | 作用 |
---|---|
魔数(Magic Number) | 标识 Class 文件,固定值 0xCAFEBABE |
Class 文件版本 | 例如 Java 8 -> 52.0 , Java 11 -> 55.0 |
常量池(Constant Pool) | 存储字面量、方法引用、类信息等 |
访问标志(Access Flags) | 标识类的修饰符(public、abstract、final 等) |
类信息(This Class/Super Class) | 定义当前类及其父类 |
接口信息(Interfaces) | 记录实现的接口 |
字段表(Fields) | 存储类的字段信息(变量) |
方法表(Methods) | 存储类的方法及其字节码 |
属性表(Attributes) | 存储 Code 、LineNumberTable 等信息,易可理解是方发表的补充 |
示例:解析 Class 文件
|
|
可以看到 Constant pool
、Methods
等详细信息。
2. 字节码指令集
JVM 执行 Java 代码时依赖字节码指令,常见指令如下:
(1)算术运算
指令 | 作用 |
---|---|
iadd |
int 加法 |
isub |
int 减法 |
imul |
int 乘法 |
idiv |
int 除法 |
irem |
取余 |
(2)类型转换
指令 | 作用 |
---|---|
i2f |
int 转 float |
d2i |
double 转 int |
i2b |
int 转 byte |
(3)对象操作
指令 | 作用 |
---|---|
new |
创建对象 |
getfield |
读取实例字段 |
putfield |
写入实例字段 |
getstatic |
读取静态字段 |
putstatic |
写入静态字段 |
(4)方法调用
指令 | 作用 |
---|---|
invokestatic |
调用静态方法 |
invokevirtual |
调用普通实例方法 |
invokespecial |
调用私有、构造或父类方法 |
invokeinterface |
调用接口方法 |
invokedynamic |
处理 Lambda 和动态调用 |
示例:查看字节码
查看编译后的字节码:
|
|
输出
public int add(int, int);
Code:
0: iload_1 // 加载第一个参数
1: iload_2 // 加载第二个参数
2: iadd // 进行加法操作
3: ireturn // 返回结果
3. 解释器(Interpreter)与 JIT 编译器
(1)解释器
- 逐行解释执行字节码,速度较慢,但启动快。
- 适用于短时间运行的程序,如 CLI 工具。
(2)编译器
- 将热点字节码编译为机器码,提高执行效率。
- Java 采用 C1(Client 编译器)和 C2(Server 编译器)。
- C1(Client JIT):优化轻量,适合 GUI 应用(低延迟)。
- C2(Server JIT):更激进优化,适合高性能服务器(吞吐量优先)。
示例:启用 JIT 监控
4. JIT 优化技术
JIT 编译器在优化代码时,会使用 逃逸分析 进行优化。
(1)分析对象是否在方法外部被引用
- 未逃逸 → 可进行栈上分配
- 未逃逸 → 可进行标量替换
- 部分逃逸 → 可能进行同步消除
(2)栈上分配
在 Java 中,默认情况下对象是在堆上分配内存的。但对于未逃逸的对象,JVM 可以将其分配在栈上。当方法执行结束后,栈上的内存会自动释放,无需进行垃圾回收,从而减少了堆内存的压力和垃圾回收的开销。
JVM 可能优化为:
(3)标量替换
标量是指不可再分的数据类型,如基本数据类型(int、double 等)。如果一个未逃逸的对象可以被分解为多个标量,JVM 会将对象替换为这些标量,直接在栈上分配这些标量,而不是创建对象。这样可以减少对象的创建和内存分配开销。
JIT 可能优化为:
(4)同步消除
如果一个对象是部分逃逸的,即不会被多个线程同时访问,那么对该对象的同步操作(如 synchronized 关键字)是多余的。JVM 可以通过同步消除技术,将这些不必要的同步操作去除,从而提高程序的性能。
|
|
在 createString 方法中,StringBuffer 对象仅在方法内部使用,不会被多个线程同时访问,JVM 可能会消除 StringBuffer 内部的同步操作,提高性能。
4. JVM 性能调优
1. 调优方法论
Java 性能调优是一项系统性工程,涉及 监控、问题定位、JVM 参数优化 等多个方面。以下是调优的核心方法论。
1. 性能监控指标
在调优 JVM 时,需要关注以下关键指标:
指标 | 描述 | 常用工具 |
---|---|---|
GC 时间 | GC 触发频率、回收时间 | jstat -gc ,GC 日志 |
吞吐量 | CPU 时间中执行业务代码的比例 | jstat -gcutil ,JFR |
停顿时间 | GC 造成的线程暂停时间 | GC Logs ,G1 GC Logs |
内存泄漏 | 长时间占用的对象无法被回收 | MAT ,JProfiler |
示例:使用 jstat
监控 GC
|
|
2. 常见性能问题定位
2.1 CPU 高负载
- 现象:应用 CPU 使用率长期过高,影响系统响应。
- 排查工具:
top
/htop
:查看 CPU 使用情况jstack
:线程栈分析,生成进程所有线程的堆栈快照信息perf
/async-profiler
:热点分析
示例:使用 top
找到高 CPU 线程
|
|
然后将 <pid> 转换为 16 进制:
|
|
再用 jstack
定位:
|
|
2.2 内存溢出(OutOfMemoryError, OOM)
- 现象:JVM 报
OutOfMemoryError
,导致应用崩溃。 - 排查工具:
jmap -heap <pid>
:查看堆内存MAT
/JProfiler
:分析堆转储
示例:导出堆快照
|
|
然后用 Eclipse Memory Analyzer (MAT) 进行分析。
2.3 线程阻塞(Thread Block)
- 现象:应用无响应,线程池任务堆积。
- 排查工具:
jstack
:查看线程状态jconsole
/VisualVM
:监控线程
示例:分析死锁
|
|
3. JVM 参数分类与调优
JVM 参数可以分为以下几类:
3.1 堆内存相关
参数 | 作用 |
---|---|
-Xms<size> |
初始堆大小 |
-Xmx<size> |
最大堆大小 |
-Xmn<size> |
年轻代大小 |
-XX:NewRatio=<n> |
老年代:年轻代比例 |
示例:设置堆大小
|
|
3.2 GC 相关
参数 | 作用 |
---|---|
-XX:+UseG1GC |
启用 G1 GC |
-XX:+UseZGC |
启用 ZGC |
-XX:MaxGCPauseMillis=<n> |
设定最大 GC 停顿时间 |
-XX:InitiatingHeapOccupancyPercent=<n> |
触发 GC 的堆使用率 |
示例:优化 G1 GC
|
|
3.3 线程 & 栈
参数 | 作用 |
---|---|
-Xss<size> |
设置线程栈大小 |
-XX:ThreadStackSize=<n> |
线程默认栈大小 |
示例:增大线程栈
|
|
2. 工具使用
这些工具在 Java 性能分析和调优中扮演重要角色:
1. 命令行工具
-
jps
:列出当前运行的 JVM 进程1
jps -lvm
-
jstat
:监控 GC、类加载等1
jstat -gc <pid> 1000
-
jmap
:导出堆转储,查看对象分布1
jmap -dump:format=b,file=heap.hprof <pid>
-
jstack
:分析线程堆栈,排查死锁1
jstack -l <pid>
-
jinfo
:获取/修改 JVM 运行时参数1
jinfo -flags <pid>
2. 图形化工具
- JConsole:实时监控线程、GC、内存
- VisualVM:监控 GC、线程快照、内存分析
- JMC(Java Mission Control):配合 Flight Recorder 进行性能分析
3. 线上诊断工具
-
Arthas:热加载、方法跟踪
-
Async-Profiler:火焰图分析 CPU/内存
4. 内存分析工具
- MAT(Memory Analyzer Tool):分析
heap.hprof
,排查内存泄漏。
5. 高级主题与实战
1. 锁升级
偏向锁/轻量级锁/重量级锁(synchronized 实现)
Java synchronized 采用 偏向锁 -> 轻量级锁 -> 重量级锁 的 自适应升级策略,优化性能:
锁类型 | 特点 | 适用场景 |
---|---|---|
偏向锁 | 线程无竞争,CAS 替代加锁 | 线程基本不会竞争 |
轻量级锁 | CAS + 自旋锁,避免阻塞 | 竞争少,锁时间短 |
重量级锁 | 线程阻塞,依赖 OS 互斥锁 | 竞争激烈,锁时间长 |
示例:synchronized 代码块的锁升级
可以用 -XX:+PrintGCApplicationStoppedTime -XX:+PrintSafepointStatistics
观察锁的状态变化。
2. 实战案例
1. 内存泄漏排查(MAT 分析堆转储)
步骤
-
生成 heap dump
1
jmap -dump:format=b,file=heap.hprof <pid>
-
使用 MAT 进行分析
- MAT下载地址:下载 MAT
- 打开
heap.hprof
- 选择 “Dominator Tree” 查找大对象
- 使用 “Leak Suspects Report” 定位可能泄漏的对象
案例
-
单例模式导致的内存泄漏
解决方案:改为
WeakReference
或主动清理。
2. GC 调优实战(优化 Full GC 频率)
步骤
-
启用 GC 日志
1
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
-
观察 Full GC 触发情况
jstat -gcutil <pid> 1000
grep Full GC gc.log
案例
-
频繁 Full GC 影响吞吐
1
[Full GC (Allocation Failure) 512M->256M(1024M)]
优化方案
- 调大堆内存:
-Xmx2g -Xms2g
- 调整新生代比例:
-XX:NewRatio=2
- 使用 G1/ZGC:
-XX:+UseG1GC
- 调大堆内存:
3. 高并发场景下的锁竞争优化
步骤
-
使用
jstack
分析线程状态1
jstack -l <pid>
-
定位
BLOCKED
线程 -
代码优化
- 减少
synchronized
粒度 - 使用
ReentrantLock
替代synchronized
- 减少
案例
-
锁竞争导致吞吐下降
优化方案
- 改用
AtomicInteger
避免锁
- 改用
4. JIT 编译日志分析(-XX:+PrintCompilation
)
步骤
案例
-
关键代码未被 JIT 编译
1
1526 324 ! 4 com.example.Test::method (43 bytes)
优化方案
- 增加热度(如循环调用)
- 避免过大方法(拆分)
- 使用
-XX:+TieredCompilation
3. 现代 JVM 发展
这些新技术都围绕 性能优化、并发改进、跨语言支持 进行增强,我们来逐一解析。
1. GraalVM 特性与多语言支持
GraalVM 是一个高性能、多语言的 JVM 发行版,支持 Java、JavaScript、Python、Ruby 等多种语言,并提供 AOT 编译、Truffle 语言框架、Polyglot API。
核心特性
- 即时编译(JIT):优化字节码执行
- AOT 编译(Native Image):将 Java 代码编译为本地可执行文件
- 多语言支持(Polyglot API):Java 与其他语言互操作
- Truffle 语言框架:构建自定义语言解释器
示例:Java 调用 JavaScript
2. Project Loom(虚拟线程与协程)
JDK 21 引入 虚拟线程(Virtual Threads),它们是 轻量级线程,大幅提升高并发处理能力。
虚拟线程 vs. 传统线程
对比项 | 平台线程(Platform Thread) | 虚拟线程(Virtual Thread) |
---|---|---|
调度方式 | 由 OS 负责 | 由 JVM 负责 |
线程数量 | 受 OS 限制 | 可创建百万级线程 |
阻塞影响 | 影响整个线程 | 仅影响当前协程 |
示例:创建虚拟线程
最佳应用场景
- 高并发 I/O 任务
- Web 服务器(如 Tomcat 已支持)
- 事件驱动架构
3. ZGC/Shenandoah 低延迟 GC 原理
目标:减少 GC 停顿时间,适用于 低延迟应用。
GC 类型 | 停顿时间 | 特点 |
---|---|---|
ZGC | <1ms | 适用于 TB 级大内存 |
Shenandoah | 10ms 以内 | 适用于响应式应用 |
ZGC(Zero Pause GC)
- 并发执行:垃圾回收时,应用线程 几乎不暂停
- 支持超大堆(最大支持 16TB)
启用 ZGC
|
|
Shenandoah GC
- 并发压缩:边回收边整理
- 适用于低延迟、金融、电商等系统
启用 Shenandoah
|
|
4. 动态 CDS(Class Data Sharing)与 AOT 编译
Class Data Sharing(CDS)
- 减少 JVM 启动时间(缓存
class
数据) - 共享
JAR
文件的 class 信息,降低内存占用
启用 CDS
AOT 编译(Ahead-Of-Time Compilation)
- GraalVM 提供 AOT 编译,可提前编译
.class
到本地代码 - 适用于小型容器/微服务
示例:使用 native-image
6. 实践项目
1. 自定义类加载器实现热部署
核心原理:
- 双亲委派机制:自定义类加载器一般会重写
findClass
方法,而不是loadClass
,以避免父类加载器缓存已加载的类。 - 热部署:每次重新加载类时,创建新的
ClassLoader
实例,确保新的.class
文件生效。
实现步骤:
- 监视
classes/
目录下的.class
文件变化。 - 发现修改后,使用
URLClassLoader
或自定义 ClassLoader
重新加载类。 - 利用反射调用新版本的方法。
示例代码:
|
|
2. 通过字节码增强技术(ASM/Javassist)实现简单 AOP
核心原理:
- ASM/Javassist 可以动态修改字节码,实现方法级别的拦截(类似 Spring AOP)。
- 目标:在目标方法执行前后插入日志或权限校验代码。
Javassist 示例:在方法前后插入代码
|
|
应用场景:
- 日志增强(方法执行前后记录日志)
- 权限校验(在方法执行前检查权限)
- 性能监控(记录方法执行时间)
3. 模拟内存泄漏并利用工具定位
内存泄漏案例 1:静态集合导致对象无法释放
分析工具:
- JConsole/VisualVM:查看堆内存变化
- MAT(Memory Analyzer Tool):分析
heap dump
,找到泄漏对象
使用 jmap
导出堆快照并分析
总结
Java虚拟机的内容是必须掌握的。
文章作者 会写代码的小郎中
上次更新 2025-03-24
许可协议 CC BY-NC-ND 4.0