JVM规范 The Java Virtual Machine Specification
HotSpot实现 hotspot
引言
什么是 JVM
Java Virtual Machine - java 程序的运行环境(java 二进制字节码的运行环境)
好处
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能(屏蔽了指针)
- 数组下标越界检查(C语言是没有的,越界直接进行覆盖了)
- 多态
比较
jvm jre jdk 这里 JavaGuide 讲的很详细
JVM 是 Java虚拟机,是运行Java字节码的虚拟机。Java 程序通过编译器生成字节码文件,然后经过JVM解释器生成机器码进行执行。不同的系统(Linux、Windows、macOS)有不同的虚拟机实现,目的是让相同的字节码文件能在不同的机器上产生相同的结果。字节码和不同系统的JVM实现是Java语言一次编译、随处运行的关键所在。
JDK 是一个开发工具包,让开发者使用,用来创建和编译Java程序,它包含JRE以及编译器javac和一些其他工具,比如 javadoc,javap(反编译工具)。
JRE 是运行已编译Java程序所需要的环境,主要包含 JVM、Java基础类库。
整体来说
- JDK
- JRE
- JVM 负责跑程序
- Java 核心类库
- Java开发工具(javac,javap,javadoc,jar)
- JRE
不过从Java9之后引入了模块化,不再提供独立的 JRE 安装包
常见的 JVM

只要遵从 JVM 规范,我们自己也可以写 JVM,上面是常见的 JVM,比较常用的是 HotSpot 和 OpenJDK。这两个都免费,Oracle JDK 要收费的。下面讲解都是基于 HotSpot JVM。
学习路线
如下图,JVM主要包括三个部分:类加载器,JVM内存结构与执行引擎。一个类经过编译后,必须有类加载器进行加载。类被放在方法区,类的实例对象则被放在堆中,堆中对象在调用方法时会用到虚拟机栈、程序计数器和本地方法栈。方法执行时,每行代码由执行引擎中的程序解释器解释执行,方法中的热点代码(频繁调用)会由JIT即时编译器进行优化编译,GC则负责对堆中不再被引用的对象进行垃圾回收。有时JVM还需要与操作系统进行交互,本地方法接口负责这一职责。

内存结构
程序计数器

作用
Program Counter Register 程序计数器(寄存器实现)
- 记住下一条jvm指令的执行地址
Java 源代码先通过 javac 编译成 .class 字节码文件,字节码本身就是 JVM 指令的二进制表示。JVM 加载类之后,由执行引擎执行字节码。执行方式有两种:解释器逐条解释执行,或者 JIT 编译器把热点代码编译成本地机器码后交给 CPU 执行。每个 Java 线程都有自己独立的程序计数器,用来记录当前线程下一条要执行的字节码指令地址。当线程失去 CPU 后,再次获得 CPU 时,JVM 可以根据该线程自己的程序计数器继续执行。

Java 程序运行时,程序计数器记录当前线程执行到哪条 JVM 字节码;解释器根据程序计数器取出字节码指令,把它翻译成机器码,最后由 CPU 执行。
{% note danger %}
执行完一个字节码指令后,JVM 程序计数器会改变,但不是 CPU 直接改的,而是 JVM 解释器/JIT 运行时维护的。CPU 直接改变的是它自己的硬件程序计数器。
{% endnote %}
- 特点
- 是线程私有的,每一个线程都有自己的程序计数器,记录当前线程的代码执行到哪里了
- 虚拟机中唯一一个不会存在内存溢出的部分
虚拟机栈

介绍
JVM 栈是线程运行时需要的一块私有内存,每个线程都有自己的 JVM 栈。
- Java 程序启动后,会创建一个主线程,它有自己的JVM栈。主线程执行 main 方法的时候,会为它创建一个栈帧并且压入栈
- 调用
new Thread().start()后,新线程启动,也会拥有自己的线程栈。 - 每调用一个方法,JVM 都会创建一个栈帧并压入当前线程的栈中。
栈帧中主要保存局部变量表、操作数栈、动态链接和方法返回信息。方法执行完成后,对应栈帧出栈,调用者栈帧重新成为当前活动栈帧,程序计数器继续指向调用点之后的下一条字节码指令。
- 局部变量表(包括参数、局部变量,如果是成员方法还会有隐藏的
this) - 操作数栈(JVM执行字节码时临时用来计算的地方)
- 动态链接(每个栈帧中保存了一个指向运行时常量池的引用,用来在方法执行过程中,把字节码里的符号引用解析成真正可以访问的类、方法、字段引用)
比如字节码写的调用 #5,然后动态链接帮你找到#5是谁,比如Student.sayHello(),然后真正定位到这个方法去执行。 - 方法返回信息(返回后回到调用者哪里继续执行)
- 当前方法是正常返回,还是异常结束
- 如果有返回值,返回值是什么
- 当前方法返回后,应该回到调用者的哪里继续执行
- 恢复哪个调用者栈帧
- 返回值要不要压回调用者的操作数栈
{% note warning no-icon %}
当 main 调用了 method1 方法之后,到底是怎么恢复?是 method1 给下一条指令地址,还是 main 自己找?还是其他什么?
执行方法调用指令时,JVM 就已经知道"当前方法返回后应该回到哪里继续执行"。被调用方法返回后,JVM 恢复调用者栈帧,并让当前线程的程序计数器指向调用指令后面的下一条字节码。也就是说既不是 main 来做也不是 method1 来做,也不是程序计数器来做,而是JVM的执行引擎来做。
JVM 在执行调用 method1() 的时候,会保存返回所需的信息。这些信息保存在 method1 栈帧中。等 method1 执行完后 (执行了return),JVM 根据这些返回信息恢复调用者 main 的栈帧,并更新PC,让当前线程的 PC 指向 main 中调用指令之后的下一条字节码。其实这些执行完之后才让 method1 出栈!!!
比如代码
java
public static void main(String[] args) {
method1();
int x = 10;
}
public static void method1() {
System.out.println("method1");
}
字节码大概是
java
0: invokestatic #method1
3: bipush 10
5: istore_1
6: return
流程
txt
main() 调用 method1()
↓
JVM 保存调用者 main() 的执行状态(意思就是 method1 返回后,要回到 main 中 invokestatic 后面的下一条指令,也就是 3 号位置)
↓
method1 栈帧入栈
↓
PC 指向 method1 的第一条字节码
↓
method1 执行完 return
↓
method1 栈帧出栈
↓
main 栈帧恢复活动
↓
PC 被设置/恢复到 3
程序计数器不是一个"会主动读东西的对象"。
不是程序计数器自己去读下一条地址,而是 JVM 执行引擎在执行字节码的过程中不断更新程序计数器。方法调用时保存返回位置,方法返回时恢复到那个位置。
{% endnote %}
每个线程同一时刻只能有一个当前活动栈帧,也就是栈顶栈帧。

在 IDEA 中可以查看栈帧和栈帧变量信息

问题解析
垃圾回收是否涉及栈内存?
垃圾回收不会涉及栈内存,因为栈的栈帧会随着方法调用而入栈,随着方法结束而出栈,无需进行垃圾回收。
对,确实,栈帧内存不用GC回收;但是GC还会扫描栈帧里的对象引用,因为栈帧内部的对象引用属于局部变量,属于栈帧,但是申请的对象是在堆里的,栈帧内存释放了,但是堆的还没有,这一部分GC来做,所以GC发现没人指向它,才会释放。
你可能好奇,那为啥不释放栈帧的时候一块把对应堆的对象释放了?
栈帧能自动释放,是因为方法结束后这个栈帧一定没用了;堆对象不能顺手释放,是因为方法结束不代表对象没用了。
- 对象可能被返回给调用者(作为返回值)
- 对象可能被存储到别的对象里(比如在这个方法里面把这个局部变量放进了一个全局变量里面)
- 变量逃逸(在这个方法里面这个对象被其他线程引用,在JUC里面会介绍)
如果每次释放栈帧都检查一次对象是不是没有被人引用那性能消耗太大了
栈内存分配越大越好吗?
-
栈的大小可以进行设置。可以通过JVM参数设置
-Xss1m,1m 指代大小,Xss中到 ss 可以理解为Stack size。在 IDEA 中可以配置

-
这个
-ea是开启断言测试,也就是assert,跟-Xss1m没关系哈,不用管。 -
线程栈越大则可以进行嵌套调用的方法层级越多,比如递归(如果栈空间不够了,会报
StackOverflowError),但是并不会变快,所以需要在合理区间,不是越大越好。 -
因为计算机的物理内存是有限的,线程中栈的大小设置的越大,可以容纳的线程数就会越少(每个线程都有自己的栈)。一般采用系统默认的栈内存大小即可。
比如有
100MB内存,设置-Xss1m那最多能有100个线程。如果设置-Xss512k,那最多可以有200个线程。 -
栈内存是提前分配好的,但是栈帧大小不是固定的,不同方法栈帧大小不一样,复杂点栈帧可能就大。
方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
这个在 JUC 里面会详细讲
java
// 多个线程都执行 m1 方法
static void m1() {
int x = 0;
for (int j = 0; j < 500; j++) {
x++;
}
}
这种情况下不会有线程安全问题,因为每个栈都有独立的栈空间,调用 m1() 方法时,每个线程都有自己的栈帧,都有互不干扰的 i 变量,不会有线程安全问题。

java
static int x = 0;
// 多个线程都执行 m1 方法
static void m1() {
for (int j = 0; j < 500; j++) {
x++;
}
}
这个时候就不是线程安全了,为啥因为我线程 A 和线程 B 都在读取和修改 x。

{% note info no-icon %}
为啥对 static int x 的修改,在多线程下是不安全的?
本质原因是因为 x++; 操作不是原子性的。它是被拆解成四个指令的
bash
getstatic x // 读取静态变量 x
iconst_1 // 准备常量 1
iadd // x + 1
putstatic x // 把结果写回静态变量 x
也就是说我可能执行了两个指令就上下文切换切走了,另一个线程也来执行 x++,它读到的还是旧值,最后两个线程都把同一个结果写回去,导致其中一次自增丢失。
{% endnote %}
java
static void m1() {
StringBuilder sb = new StringBuilder();
sb.append("a");
sb.append("b");
sb.append("c");
System.out.println(sb.toString());
}
这个方法是线程安全的,里面的 sb 对象只在当前方法内,没有逃逸
java
static void m2(StringBuilder sb) {
sb.append("a");
sb.append("b");
sb.append("c");
System.out.println(sb.toString());
}
这个方法就是线程不安全的了,因为 sb 作为参数传递进来,那就有可能有两个线程同时用一个 StringBuilder 对象
java
StringBuilder sb = new StringBuilder();
new Thread(() -> m2(sb)).start();
new Thread(() -> m2(sb)).start();
比如这样,就存在同时修改的问题,存在线程不安全问题。因为 sb 对象放在堆里面,这两个线程都调用 m2(sb),就和前面那个 static int x 是一样的,两个线程存在同时修改它的风险。
{% note danger no-icon %}
为啥 StringBuilder 线程不安全呢?
因为它内部维护了可变的字符数据和长度,比如可以粗略理解成:
java
char[] value;
int count;
执行:
java
sb.append("a");
不是一步完成的,它内部大概需要做这些事:
- 判断容量够不够
- 把字符写入内部数组
- 修改 count 长度
这些操作不是原子的,也没有加锁。所以就可能出现很多问题。所以想要线程安全,要么考虑自己加锁,要么用 StringBuffer,就是性能稍微差点
{% endnote %}
同样的,下面这个也是线程不安全的
java
static void m3() {
StringBuilder sb = new StringBuilder();
sb.append("a");
sb.append("b");
sb.append("c"); // 内部是安全的,因为只要调用这个方法就会创建一个新的 sb, 不会有多个线程修改 sb 的问题
return sb;
}
sb 返回后可能被多个线程引用去 append。
java
StringBuilder sb = m3();
new Thread(() -> sb.append("d")).start();
new Thread(() -> sb.append("e")).start();
栈内存溢出
出现的情况
- 栈帧过多导致栈内存溢出,比如说方法的递归调用
- 栈帧过大导致栈内存溢出,一般较少出现,因为栈大小一般挺大,比如 1Mb,栈帧一般就存放一些参数、局部变量等一些其他东西,占用的内存相对较少
我们可以编写一个递归调用的示例
java
public class Demo_1 {
private static int count;
public static void main(String[] args) {
try {
method1();
} catch (Throwable e) {
e.printStackTrace();
System.out.println(count);
}
}
private static void method1() {
count++;
method1();
}
}
报错,可以看到总共调用了 46460 次
bash
java.lang.StackOverflowError
at com.lh.Demo_1.method1(Demo_1.java:17)
....
at com.lh.Demo_1.method1(Demo_1.java:17)
at com.lh.Demo_1.method1(Demo_1.java:17)
46460
可以减小一下栈帧大小,看看调用次数是不是减少,比如设置 -Xss256k
这个时候再跑一下,就只会调用 1479 次了。
线程运行诊断
虚拟机栈占用过多CPU
java
/**
* 演示 cpu 占用过高
*/
public class Demo04 {
public static void main(String[] args) {
new Thread(null, () -> {
System.out.println("1...");
while(true) {
}
}, "thread1").start();
new Thread(null, () -> {
System.out.println("2...");
try {
Thread.sleep(1000000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "thread2").start();
new Thread(null, () -> {
System.out.println("3...");
try {
Thread.sleep(1000000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "thread3").start();
}
}
这里我只讲述在 macOS 系统下怎么运行这个代码并且查看CPU占用等,Linux、Windows 下查看类似命令就可以。
bash
(base) ice@jimodebingkeledeMac-mini ~ % cd Desktop/cola/code/Java/JVM/src/main/java # 进入目录
(base) ice@jimodebingkeledeMac-mini java % javac com/lh/Demo04.java # 编译
(base) ice@jimodebingkeledeMac-mini java % java com.lh.Demo04 & # 后台运行并拿到进程 PID
[1] 10760
(base) ice@jimodebingkeledeMac-mini java % 1...
2...
3...
(base) ice@jimodebingkeledeMac-mini java % top -pid 10760 # 查看进程 CPU 占用

bash
jstack 10760 # 查看进程下的线程信息

- 可以看到进程的信息,除了JVM虚拟机本身的一些进程之外,这个
thread1、thread2、thread3都是咱们自己的,然后可以看到thread1正在运行,并且指出了在运行哪一行代码。 - 这里面
tid代表JVM内部线程标识,nid代表操作系统线程ID,都是十六进制表示的
macOS 不支持 ps 来查看线程名和 tid 字段,,,Linux 可以。

这里可以看到有个线程 %CPU=100.0 且 STAT=R
bash
(base) ice@jimodebingkeledeMac-mini java % kill 10760 # 杀死进程
(base) ice@jimodebingkeledeMac-mini java %
[1] + exit 143 java com.lh.Demo04
{% note warning %}
不能直接杀死线程,只能杀进程。。。
{% endnote %}
{% note danger no-icon %}
正常 RUNNABLE 和异常 RUNNABLE 的区别
它 %CPU=99,STAT=R,怎么就知道它有问题呢?
正常情况:
- 线程 RUNNABLE
- CPU 高一会儿,任务执行完就下降
- 栈位置会变化
异常情况:
- 线程 RUNNABLE
- CPU 长时间很高
- 多次 jstack 看到它一直卡在同一段代码
比如 while(true)、死循环、频繁重试、复杂计算
{% endnote %}
线程死锁的排查
有的时候运行一个程序迟迟没有结果,可能是出现了死锁,下面演示一下
java
package com.lh;
/**
* 演示线程死锁
*/
class A{};
class B{};
public class Demo05 {
static A a = new A();
static B b = new B();
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
synchronized (a) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println("我获得了 a 和 b");
}
}
}).start();
Thread.sleep(1000);
new Thread(()->{
synchronized (b) {
synchronized (a) {
System.out.println("我获得了 a 和 b");
}
}
}).start();
}
}
bash
(base) ice@jimodebingkeledeMac-mini ~ % cd Desktop/cola/code/Java/JVM/src/main/java # 进入目录
(base) ice@jimodebingkeledeMac-mini java % javac com/lh/Demo05.java # 编译
(base) ice@jimodebingkeledeMac-mini java % java com.lh.Demo05 & # 后台运行并返回进程ID
[1] 14569
(base) ice@jimodebingkeledeMac-mini java % jstack 14569 # 查看信息
在最后有这样一段信息

可以看到先说发现了一个死锁
然后下面详细信息说 Thread-0 正在等待一个 B 锁,锁的地址是 0x000000061fc1c6d0,自己锁住了一个 A 锁,地址是 0x000000061fc1bad0。并且也展示了代码的位置
txt
"Thread-0":
at com.lh.Demo05.lambda$main$0(Demo05.java:22)
"Thread-1":
at com.lh.Demo05.lambda$main$1(Demo05.java:30)
也就知道程序发生了死锁。通常情况下我们只能杀掉整个Java进程来结束死锁,并且修正代码防止再出现死锁。
bash
(base) ice@jimodebingkeledeMac-mini java % kill 14569
(base) ice@jimodebingkeledeMac-mini java %
[1] + exit 143 java com.lh.Demo05
本地方法栈

-
本地方法是用
native修饰的方法,它没有 Java 方法体,真正实现通常由 C/C++ 等非 Java 语言完成。当 Java 执行到这个
hashCode()时,真正进入的是底层的 C/C++ 函数。javapublic class Object { public native int hashCode(); // 本地方法 }为什么存在本地方法?这些方法通常是Java自己做不了或者不适合用Java做
-
本地方法除了直接调用操作系统 API,也可能是操作 JVM 底层内部结构,比如对象头、锁、线程等待唤醒、运行时类型信息等。
-
当 Java 程序调用
native方法时,JVM 会进入对应的本地代码执行。本地代码执行过程中也需要参数、局部变量、返回地址等运行空间,因此 JVM 规范中定义了本地方法栈,用来为native方法服务。Java 方法执行时用 JVM 栈,本地方法执行时可能使用本地方法栈
-
Object 类中就有很多
native方法,比如getClass()、hashCode()、clone()、wait()、notify()、notifyAll()等。
堆

前面说的程序计数器、虚拟机栈、本地方法栈都是线程私有的,而堆以及后面的方法区都是线程共享的。
定义
通过 new 关键字,创建对象都会使用堆内存
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
堆内存溢出
堆中具有垃圾回收机制,但是垃圾回收的前提是堆中的对象不再被引用,因此如果我们有过多无法被回收的对象,就可能导致堆内存溢出。
java
public class MemoryOverFlow {
public static void main(String[] args) {
int i = 0;
String a = "hello";
List list = new ArrayList();
try {
while (true) {
list.add(a); // 一直添加数据,直到堆溢出
a = a + a;
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}

可以通过设置参数 -Xmx 来设置堆内存最大大小,比如设置 -Xmx8m

这个时候跑的轮数就少了,更快溢出了
{% note info %}
实际上有 -Xms 和 -Xmx 两种配置
bash
-Xms256m 初始堆大小是 256MB
-Xmx1g 最大堆大小是 1GB
{% endnote %}
堆内存诊断
工作中编写了一段代码,怎么判断这段代码对于内存性能的影响呢?可以用下面这些工具
jps查看当前系统中有哪些 java 进程jmap查看瞬时时刻堆内存占用情况jmap -heap 进程idjconsole图形界面的,多功能的监测工具,可以连续监测
通过下面这个 demo 来演示
java
public static void main(String[] args) throws InterruptedException {
System.out.println("1....."); // 输出提示
Thread.sleep(30000); // 给 30s 时间我们看进程ID + 看看 Heap 情况
byte [] arr = new byte[1024 * 1024 * 10];
System.out.println("2.......");
Thread.sleep(30000);
arr = null;
System.gc(); // arr 置空之后代表可以被垃圾回收了,我们再手动 gc 进行回收一下
System.out.println("3......");
Thread.sleep(100000L);
}

{% note warning %}
在这里执行 jmap 报错是因为 jdk8 之后的版本不能再使用这个命令了,需要改用命令 jhsdb jmap --heap --pid xxx。
但是在 macOS 上对于这个命令支持不好,所以我们使用命令 jcmd xxx GC.heap_info 来查看信息
{% endnote %}
我们在控制台输出 1 之后,输出 2 之前,执行如下命令

当前提交给JVM使用的堆大小是 520MB,已使用的堆大小是 14MB
控制台输出 2 之后,执行下面命令

堆大小增加了大约 12MB,虽然我们只申请了 10MB,但是对象本身还有对象头、对齐、G1 region 分配等额外影响,所以看到增加 12MB 左右是正常的
控制台输出 3 之后,执行下面命令

可以通过 jcmd xxx VM.flags 查看堆最大大小、初始大小等等信息,列举其中几个
bash
(base) ice@jimodebingkeledeMac-mini JVM % jcmd 18101 VM.flags
18101:
-XX:G1HeapRegionSize=4194304 # G1把堆切成每个小块的大小,4MB
-XX:InitialHeapSize=536870912
-XX:MaxHeapSize=8589934592
-XX:+UseG1GC
和上面 jcmd xxx GC.heap_info 进行对应
garbage-first heap代表用的是 G1 垃圾收集器,对应 VM.flags 里面的-XX:+UseG1GC,G1 的全称就是garbage-first。-XX:MaxHeapSize=8589934592即 8GB,代表最大堆内存大小total 532480K代表当前JVM已经提交的堆内存大小,换算一下就是 520 MB,按理来说应该是 512MB,JVM 启动后,G1 根据运行过程、内部策略、对象分配、对齐等原因,当前实际提交堆变成了 520MB。InitialHeapSize=536870912512MB JVM 参数里的初始堆大小配置值。
jconsole
也可以用 jconsole 的方法进行堆内存诊断,使用方法就比较简单,直接在终端输入 jconsole 就会自动弹出来

我们选择进程点击连接就可以查看。

上面这个增大缩小的过程分别是我们分配堆内存和GC的过程。
除了内存(还提供了手动GC按钮),jconsole还可以监测线程、cpu 占用率以及类的数量变化等。
还可以帮我们坚持死锁的情况

多次垃圾回收内存占用仍很高问题的排查
jvisualvm 也是一个可视化工具,比 jconsole 更好用,在命令行输入 jvisualvm 就能用,但是 JDK1.8 之后或者是比较新的JDK1.8不会再自动集成它了,需要手动下载这个软件
macOS 下可以通过 homebrew 下载
bash
brew install --cask visualvm
安装好后,我们用如下代码进行演示
java
/**
* 演示查看对象个数 堆转储 dump
*/
public class Demo1_13 {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(30000L);
List<Student> students = new ArrayList<>();
for (int i = 0; i < 200; i++) {
students.add(new Student());
// Student student = new Student();
}
Thread.sleep(1000000000L);
}
}
class Student {
private byte[] big = new byte[1024*1024]; // 1MB
}

启动程序后打开软件我们可以选中进程

我们可以看到堆大小的变化,启动后过了一段时间可以看到堆内存确实变大了很多,我们先点击 Preform GC 手动进行 GC 尝试回收堆内存,发现堆也没啥变化。
然后点击 Heap Dump 来获取当前堆大小的快照

右下角点击 view all 可以查看当前占用堆内存的实例对象,最高的是 ArrayList,里面存放了非常多的 Student,每个都还不小

方法区

定义
- 方法区是 JVM 中所有线程共享的运行时内存区域,在虚拟机启动时创建。
- 它主要存放已经被 JVM 加载的类型信息,包括类的结构信息、字段信息、方法信息、构造器信息、方法字节码、运行时常量池等。
需要注意的是,方法区中存放的是字段和方法的描述信息,不是每个对象的字段值;对象的实例字段值仍然存放在堆中。 - 方法区在 JVM 规范中被描述为堆的一个逻辑部分,但具体虚拟机实现可以不同。例如 HotSpot 在 JDK 8 之后使用元空间 Metaspace 来实现方法区,而元空间使用的是本地内存。

-
Method Area 方法区:JVM 规范里的概念
- PermGen 永久代:JDK 6 HotSpot 对方法区的实现,永久代不在堆里哈,它是方法区的一种实现,和堆、虚拟机栈是同一级别的运行时数据区域
- Metaspace 元空间:JDK 8 HotSpot 对方法区的实现
-
元空间位于本地内存,也就是前面提到的操作系统层面的内存(Native Memory),不在 Java 堆里
-
在JDK6 及之前,字符串常量池的 String 对象在永久代里面,JDK7 之后,字符串常量池中的
String对象移动到了堆里,这个时候方法区很多东西还和永久代有关系,JDK8 之后永久代就完全被元空间取代 -
ClassLoader对象java.lang.Class对象本质是对象,放在堆里javaClass<?> clazz = User.class; // 指向在堆中
详细举例方法区,无论永久代还是元空间,核心都放着
txt
类的元信息
├─ 类名
├─ 父类
├─ 接口
├─ 访问修饰符 public / private / abstract 等
├─ 字段信息
├─ 方法信息
├─ 方法字节码
├─ 运行时常量池
├─ 注解信息
├─ 方法表,例如虚方法表
└─ 类加载器相关信息
比如下面这个类
java
public class User {
private String name;
public void sayHello() {
System.out.println("hello");
}
}
永久代/元空间主要存的是
txt
User 这个类叫什么
它继承谁
有哪些字段:name
有哪些方法:sayHello()
sayHello() 的字节码是什么
访问权限是什么
常量池里有哪些符号引用
方法区内存溢出
导入依赖
xml
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.9.1</version>
</dependency>
java
public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
Demo1_8 test = new Demo1_8();
for (int i = 0; i < 10000; i++, j++) {
// ClassWriter 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
// 参数含义:版本号, 访问级别为public, 类名, 包名, 父类, 接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 返回 byte[]
byte[] code = cw.toByteArray();
// 将 byte[] 字节码加载成 JVM 中的 Class,类元信息会进入方法区的具体实现区域
test.defineClass("Class" + i, code, 0, code.length);
}
} finally {
System.out.println(j);
}
}
}
设置一个 2M 之后

-
在 JDK 8 以前,HotSpot 使用永久代 PermGen 实现方法区,可以通过
-XX:MaxPermSize设置永久代最大大小。例如:-XX:MaxPermSize=8m。如果不断动态生成并加载大量 Class,可能会导致:java.lang.OutOfMemoryError: PermGen space。 -
在 JDK 8 以后,永久代被移除,HotSpot 改用元空间 Metaspace 实现方法区。可以通过
-XX:MaxMetaspaceSize设置元空间最大大小。例如:-XX:MaxMetaspaceSize=8m,如果不设置,元空间默认可以一直向操作系统申请内存,直到系统内存不够或者进程内存限制被打满。如果不断动态生成并加载大量 Class,元空间中的类元信息不断增加,也可能导致:java.lang.OutOfMemoryError: Metaspace -
在实际工作中,Spring、MyBatis、CGLIB、ASM、动态代理等技术都可能动态生成 Class。如果使用不当,比如不断生成新的类并且类加载器无法被回收,就可能导致方法区/元空间内存溢出。JDK 8 以后元空间使用本地内存,默认上限通常比永久代更宽松,因此发生 OOM 的概率降低了。但如果设置了
-XX:MaxMetaspaceSize,或者动态生成类过多、类加载器无法回收,仍然会出现 Metaspace OOM。
常量池(.class文件常量池)
这是一个 hello world 的代码。
java
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}
计算机最终会把这段代码转换为二进制代码后执行,这段二进制代码包含类基本信息、类方法定义(包含指令)、常量池。我们先用 javac 编译,然后通过反编译命令 javap -v xxx.class 把二进制代码转为可读的内容。常量池是指 Constant pool: 下面这些内容。
下面 // 是编译出的文件自带的注释, // --> 是我自己加的
java
(base) ice@jimodebingkeledeMac-mini JVM % javac src/main/java/com/lh/HelloWorld.java
(base) ice@jimodebingkeledeMac-mini JVM % javap -v src/main/java/com/lh/HelloWorld.class
Classfile /Users/ice/Desktop/cola/code/Java/JVM/src/main/java/com/lh/HelloWorld.class
Last modified 2026年4月27日; size 432 bytes
SHA-256 checksum 36d7d5a18d7230371bdf2af3ce1c864de68c93da0947d73dbaebb8082b676660
Compiled from "HelloWorld.java"
public class com.lh.HelloWorld // --> 类声明信息
minor version: 0
major version: 61 // --> class 文件版本号,指代 JDK 17 编译出来的
flags: (0x0021) ACC_PUBLIC, ACC_SUPER // --> public 类
this_class: #21 // com/lh/HelloWorld
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1 // --> 两个方法,一个main一个生成的无参构造
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // hello world
#14 = Utf8 hello world
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // com/lh/HelloWorld
#22 = Utf8 com/lh/HelloWorld
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 HelloWorld.java
{
public com.lh.HelloWorld();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String hello world
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
}
SourceFile: "HelloWorld.java"
先看
java
public com.lh.HelloWorld();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
()表示无参数V表示返回值为voidstack=1表示这个方法的操作数栈最大深度是 1locals=1表示局部变量表大小是1arg_size表示这个方法有1个参数,这个其实是隐藏参数this0: aload_0把局部变量表中第0个变量加载到操作数栈,也就是this1: invokespecial #1调用父类构造方法,看#1其实是java/lang/Object.<init>()V
{% note info %}
locals 是局部变量 + 参数 + this(如果是非static方法) + 编译器可能生成的临时变量
args_size 是参数 + this(如果是非static方法)
{% endnote %}
{% note info %}
操作数栈就是学习数据结构时,要计算表达式值时要借助的存数据的栈。
{% endnote %}
源码等价于
java
public HelloWorld() {
super();
}
然后看 main 方法
java
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String hello world
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
([Ljava/lang/String;)V是对类的参数返回值表述[代表是个一维数组,[[代表二维数组L代表对象类型,就是告诉JVM后面跟着的是一个完整类名,以;结束。基本类型的如下B表示 byteC表示 charD表示 doubleF表示 floatI表示 intJ表示 longS表示 shortZ表示 boolean
比如int add(int a, int b)描述符就是(II)I
flags中两个标志,表示方法是public + staticstack=2表示操作栈最大深度是2(方法执行过程中,操作数栈最多同时需要放置几个槽位的数据)locals=1只有一个局部变量args,没有this了,因为是静态方法。0: getstatic #7获取静态变量System.out3: ldc #13加载字符串hello world5: invokevirtual #15这个是调用println,其实前面0: ...会把System.out放入操作数栈,3: ...会把字符串"hello world"放入操作数栈,然后这一步把这两个操作数弹出,执行println。LineNumberTable代表源码行号和字节码偏移量的对应关系,前面第一个数字代表源码的第几行,对应字节码偏移量从多少开始。
{% note info %}
左边的数字 0 3 5 代表字节码偏移量,因为不同的字节码指令占用的字节数不同,所以代表这个字节码指令从哪里开始
{% endnote %}
{% note info %}
-
常量池本质上可以理解为
.class文件中的一张常量表。字节码指令中经常通过#编号引用常量池中的内容,比如类、字段、方法、字符串字面量、方法描述符等。 -
.class文件中的常量池属于静态数据。当类被加载到 JVM 后,常量池中的内容会进入运行时常量池。 -
运行时常量池中原本的符号引用,比如类引用、字段引用、方法引用,会在解析阶段或实际使用时,被 JVM 解析为直接引用。直接引用可以理解为 JVM 能直接定位到对应类、字段或方法的引用。
{% endnote %}
运行时常量池
{% note danger no-icon %}
当两个类中都引用了 java.lang.Object 类时,并且都加载到 JVM 后,那么这个引用会从各自的常量池中合并吗?
问题是说当两个类都用了 Object 类之后,通过编译,各自的 .class 文件里面都有 java.lang.Object 的引用了,那如果都加载的 JVM 变为运行时常量池会做一个去重吗?
不会把两个类的运行时常量池合并成一个,每个类加载后,都会有自己独立的运行时常量池,不会合并成一个公共常量池
bash
A 的运行时常量池
#2 -> java/lang/Object
B 的运行时常量池
#2 -> java/lang/Object
但是,它们里面的 java/lang/Object 这个符号引用,最终解析时,都会指向 JVM 中同一个已经加载的 java.lang.Object 类。
{% endnote %}
- 常量池就是一张表,虚拟机指令根据这个常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池,常量池是
.class文件中的,当该类被加载,它的常量池信息就会自动放入运行时常量池,并把里面的符号地址变为真实地址
{% note danger no-icon %}
符号地址变为真实地址,怎么理解呢?
-
编译阶段
javac编译后,.class生成的常量池表,这个时候,.class文件里面并不知道对应的内存地址在哪bashSystem.out 这个字段在内存哪里 println 方法在内存哪里 "hello world" 对象在堆哪里只知道类名、字段名、方法名、方法参数、返回值、字符串内容,这就是符号引用
bash#7 表示:java/lang/System 类里的 out 字段 #15 表示:java/io/PrintStream 类里的 println(String) 方法 -
类加载阶段:
.class常量池进入运行时常量池也就是执行命令
java com.lh.HelloWorld。.class文件中的常量池会进入 JVM 内存,变成运行时常量池,每个类都有自己的运行时常量池。 -
创建 main 栈帧
这里其实栈帧会指向
HelloWorld的运行时常量池,这样运行字节码指令的时候,可以去运行时常量池找#7、#13的内容 -
执行字节码
比如执行到了
0: getstatic #7,就先去运行时常量池找,知道它是谁,然后这个字段引用如果还没解析,就会解析它,找到它的直接引用,并保存/缓存解析结果,如果已经解析过,就直接拿来用。
主要是这些符号引用类型会被解析:
bash
Class 类引用
Fieldref 字段引用
Methodref 方法引用
InterfaceMethodref 接口方法引用
String 字符串常量使用时会得到 String 对象引用
{% endnote %}
StringTable
StringTable 通常又叫串池,是 hashtable 结构,一张 JVM 维护的全局字符串表,里面保存字符串常量池中字符串对象的引用。
常量池和串池的关系
{% note info %}
这里说一个帮助理解的点,我们展示的都是 javac 编译后,javap 反编译展示的内容。我们要知道 javap 只是为了帮助我们阅读而已,因为 javac 编译生成的二进制文件我们看不懂,所以又这个反编译工具帮我们理解。
{% endnote %}
编译生成的 .class 文件并没有运行!还没有经过解释器执行,所以还没有真正创建对象、进行运算、调用方法。
举例如下代码
java
public class Demo1_22 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}
}
java
(base) ice@jimodebingkeledeMac-mini JVM % javac src/main/java/com/lh/Demo1_22.java
(base) ice@jimodebingkeledeMac-mini JVM % javap -v src/main/java/com/lh/Demo1_22.class
Classfile /Users/ice/Desktop/cola/code/Java/JVM/src/main/java/com/lh/Demo1_22.class
Last modified 2026年4月27日; size 311 bytes
SHA-256 checksum 4da7bcdd05418a5d5ed048818835b1b3cb843ae1bd77c85b77ff532c5629defd
Compiled from "Demo1_22.java"
public class com.lh.Demo1_22
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #13 // com/lh/Demo1_22
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = String #8 // a
#8 = Utf8 a
#9 = String #10 // b
#10 = Utf8 b
#11 = String #12 // ab
#12 = Utf8 ab
#13 = Class #14 // com/lh/Demo1_22
#14 = Utf8 com/lh/Demo1_22
#15 = Utf8 Code
#16 = Utf8 LineNumberTable
#17 = Utf8 main
#18 = Utf8 ([Ljava/lang/String;)V
#19 = Utf8 SourceFile
#20 = Utf8 Demo1_22.java
{
public com.lh.Demo1_22();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1
0: ldc #7 // String a
2: astore_1
3: ldc #9 // String b
5: astore_2
6: ldc #11 // String ab
8: astore_3
9: return
LineNumberTable:
line 7: 0
line 8: 3
line 9: 6
line 10: 9
}
SourceFile: "Demo1_22.java"
- 常量池中的信息,都会被加载到运行时常量池中,刚开始运行时常量池中的 a b ab 都是字符串字面量的符号信息,还没变为 java 字符串对象
- 当执行到
String s1 = "a";的时候,也就是执行0: ldc #7的时候,先去运行时常量池中找#7,发现#7代表字符串常量"a",然后去StringTable里面找有没有,找不到,就在堆中创建这个字符串对象,并把引用存入StringTable,ldc就可以把这个对象引用压入操作数栈了。所以这个ldc指令就帮我们做了一连串的操作来保证拿到的是已经存到串池的"a"的引用。另外,赋值操作也就是"a"引用给s1是下一个命令astore_1执行的 - 所以说这个操作是懒惰的,没执行到就还不创建对象。
字符串变量拼接
java
String s4 = s1 + s2;
在 JDK8 中大概改为了
java
String s4 = new StringBuilder().append(s1).append(s2).toString();
s1 + s2 底层会用 StringBuilder 拼接,拼接是不会进入字符串常量池的
从 JDK9 开始,字符串拼接默认改成了 invokedynamic + StringConcatFactory,用 invokedynamic,让 StringConcatFactory 在运行期为这个拼接表达式生成合适的拼接逻辑
返回的都是一个新的对象。所以如果打印下面内容
java
System.out.println(s3 == s4); // false
{% note info no-icon %}
s3 与 s4 的区别
-
String s3 = "ab"时,JVM 执行 ldc 指令,会通过运行时常量池找到字符串字面量"ab",然后去StringTable查找。如果StringTable中没有,就在堆中创建一个String对象"ab",并把该对象的引用记录到StringTable中,最后让s3指向这个对象。 -
String s4 = s1 + s2时,因为s1和s2是变量,所以是运行期拼接。JDK 17 中通过invokedynamic + StringConcatFactory完成拼接,最终在堆中生成一个新的String对象,内容也是"ab",并把这个新对象的引用赋给s4。这个新对象默认不会自动进入StringTable。在 JDK8 中通过StringBuilder
所以 s3 和 s4 内容相同,但引用不同,因此 s3 == s4 为 false。
{% endnote %}
编译器优化
java
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
对于 s5,因为 "a" 和 "b" 都是字符串字面量,属于编译期常量,编译器在编译阶段就能确定它们拼接后的结果一定是 "ab",所以会直接把这句优化为 String s5 = "ab",s5 和 s3 一样,都是通过 ldc 加载字符串常量池中的 "ab",指向 StringTable 中同一个 "ab" 字符串对象。 不同于 s4,s1 和 s2 是普通变量,编译器不能把它们当作固定不变的编译期常量处理,所以不会直接优化成 "ab",而是在运行期进行字符串拼接。
{% note warning no-icon %}
如果 s1 和 s2 被 final 修饰,并且值在编译期就能确定,那么 s1 + s2 也会被编译器优化成 "ab"。
java
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
System.out.println(s4 == s5); // true
System.out.println(s3 == s5); // true
{% endnote %}

所以,如果我们运行 System.out.println(s3 == s5); 结果为 true
字符串延迟加载
字符串字面量也是延迟成为对象的
前面我们知道,String 对象在真正运行到那一行的时候才会去创建,字符串字面量也是
java
public static void main(String[] args) {
// java.lang.String 类型共 8055 个
System.out.println("1111"); // 8057
System.out.println("2222"); // 8058
System.out.println("3333"); // 8059
System.out.println("4444"); // 8060
System.out.println("5555"); // 8061
System.out.println("6666"); // 8062
System.out.println("7777"); // 8063
System.out.println("8888"); // 8064
System.out.println("9999"); // 8065
System.out.println("0000"); // 8066
System.out.println("1111"); // 8066
System.out.println("2222"); // 8066
System.out.println("3333"); // 8066
System.out.println("4444"); // 8066
System.out.println("5555"); // 8066
System.out.println("6666"); // 8066
System.out.println("7777"); // 8066
System.out.println("8888"); // 8066
System.out.println("9999"); // 8066
System.out.println("0000"); // 8066
}
{% note info %}
教程里面用的 0~9,但是我发现这些字面量本身就在串池里面,所以换了一组数据来测试。

debug 窗口里面选择这个即可,在第一行打上断点,然后逐行往下执行

可以看到每往下执行一个,数量就加一
{% endnote %}
intern 1.8
{% note info no-icon %}
先对前面做一个总结
- 常量池中的字符串仅是符号,第一次用到时才变为对象
- 采用串池的机制,避免重复创建字符串对象
- 字符串变量拼接的原理是
StringBuilder(1.8) - 字符串常量拼接的原理是编译期优化
- 可以使用
intern方法,主动将串池中还没有的字符串对象放入串池- 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回
- 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池,会把串池中的对象返回
理解为 1.8 是拷贝的引用,1.6 是拷贝的对象(复制一份全新的对象,引用不一样)
{% endnote %}
java
String s = new String("a") + new String("b");
System.out.println(s == "ab"); // false
String s2 = s.intern(); // 尝试将字符串对象放入串池,如果有则不放入,如果没有则放入串池,会把串池中的对象返回
System.out.println(s2 == "ab"); // true
new String("a") 之后,"a" 会在串池中生成一个,因为它是字面量,之后拿着这个串池中的值,相当于作为构造函数参数来在堆中又生成了一个字符串对象,所以是两个 String 引用。之后两个字符串对象拼接生成一个新的字符串对象 "ab" 放入堆中,并不会放入串池了(第二行代码)。
{% note warning no-icon %}
通过 new String("a") 创建的对象是放在堆中的,不会放在串池,我们可以做一个验证
java
String s = "a";
String s1 = new String("a");
System.out.println(s1 == s); // false
说明并没有
{% endnote %}
{% note info no-icon %}
new String("a") 究竟怎么做的?
"a" 是个字面量,实际上还是会去字符串常量池找找 "a" 的引用,没有就在堆中创建一个内容为 "a" 的对象,引用放在字符串池,由串池维护。然后 new String("a") 会根据串池里面的 "a" 字符串对象作为构造参数,在堆中创建一个新的 String 对象。所以这个操作在串池中放了一个对象引用,但是堆中生成了两个对象的。
所以在 Java 中不推荐通过 new String() 方式来创建,因为它会额外创建对象。
{% endnote %}
{% note danger no-icon %}
做个测试,分析一下下面两组代码,两组代码发生了什么,分别输出什么?
测试1
java
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == "ab");
System.out.println(s == "ab");
System.out.println(s2 == s);
s 对象创建的时候,会在串池放上字面量 "a","b",堆上会有两个字符串对象值为 "a",两个字符串对象值为 "b",一个字符串对象 "ab"。s.intern() 是把堆中的这个字符串对象 "ab" 的引用放串池了,所以 s2 == "ab" 是 true,s == "ab" 也是 true。s2 就是 s。
测试2
java
String s = new String("a") + new String("b");
System.out.println(s == "ab");
String s2 = s.intern();
System.out.println(s2 == "ab");
System.out.println(s == "ab");
System.out.println(s2 == s);
s 对象创建的时候,会在串池放上字面量 "a","b",堆上会有两个字符串对象值为 "a",两个字符串对象值为 "b",一个字符串对象 "ab"。这个时候你又 s == "ab" 会出现什么?"ab" 字符串字面量在串池中没有,那就会创建一个,所以堆中又出了一个 "ab" 对象,串池里面指向这个引用。s.intern() 的时候 "ab" 串池有,所以返回串池的这个引用,这个时候 s2 就不是 s 了。所以最终结果是 false true false false
核心点
-
当程序执行到某个字符串字面量时,JVM 会通过运行时常量池找到这个字面量,然后去
StringTable中查找是否已经有相同内容的字符串对象引用。如果没有,就在堆中创建对应的String对象,并把它的引用记录到StringTable中;如果已经有,就直接复用串池中已有的引用。javaSystem.out.println("a"); new String("a"); System.out.println(s == "a");这些只要出现字面量的操作,就会在串池创建或者引用
-
调用
s.intern()的时候,如果串池没有这个字符串值的引用,就把s引用放进去,否则就返回串池中这个字符串值的引用。
{% endnote %}
intern 1.6
在 JDK1.6 环境下
java
String x = "ab";
String s = new String("a") + new String("b"); // 串池 ["a", "b", "ab"] 堆中 ["a", "b", "ab"] + 对应串池中的["a", "b", "ab"]
String s2 = s.intern(); // "ab" 串池有,所以 s2 是返回的串池的 "ab" 引用
System.out.println(s2 == x); // true
System.out.println(s == x); // false
java
String s = new String("a") + new String("b"); // 串池 ["a", "b"] 堆中 ["a", "b", "ab"] + 对应串池中的["a", "b"]
String s2 = s.intern(); // "ab" 串池中没有,所以把 s 拷贝一份放回串池,也就是创建了一个新的字符串对象,值为 "ab",放入堆中,并且把引用存到串池
String x = "ab"; // 串池中有 "ab"
System.out.println(s2 == x); // true
System.out.println(s == x); // false
面试题
java
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b"; // 编译器优化
String s4 = s1 + s2; // new String("ab")
String s5 = "ab";
String s6 = s4.intern();
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
System.out.println(x1 == x2);
// jdk1.8: false
// jdk1.6: false
java
String x2 = new String("c") + new String("d");
x2.intern();
String x1 = "cd";
System.out.println(x1 == x2);
// jdk1.8: true
// jdk1.6: false
字符串相关概念关系图

日常来说来说字符串常量池就是串池就是 StringTable
严谨来说,字符串常量池是串池,StringTable 是字符串常量池的实现结构
StringTable 位置
在JDK6中,字符串常量池在永久代中,当大量调用 intern() 或者产生大量字符串常量时,会导致 java.lang.OutOfMemoryError: PermGen space
永久代回收效率低,因为永久代不是普通对象主要活动的区域。普通堆里的对象,Young GC、Old GC、Full GC 都可能参与回收。但是永久代里的类元信息、运行时常量池、字符串常量池相关内容,通常主要在 Full GC 的时候才更可能被处理。回收频率低,条件也苛刻。JDK1.7之后,HotSpot 把字符串常量池中的 String 对象移到了堆中,字符串常量池里的字符串对象可以像普通堆对象一样被 GC 管理
StringTable 垃圾回收
本节就是看一下告诉你字符串常量池 StringTable 放在堆中,也能被垃圾回收
设置虚拟机参数 -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
java
public static void main(String[] args) throws InterruptedException {
int i = 0;
try {
for (int j = 0; j < 100; j++) { // j=100, j=20000
String.valueOf(j).intern(); // 字符串对象入池,加入 StringTable 中
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
bash
100
Heap # 堆内存占用情况
PSYoungGen total 2560K, used 1489K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 2048K, 72% used [0x00000007bfd00000,0x00000007bfe744b0,0x00000007bff00000)
from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
to space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
ParOldGen total 7168K, used 0K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
object space 7168K, 0% used [0x00000007bf600000,0x00000007bf600000,0x00000007bfd00000)
Metaspace used 3249K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 350K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics: # 符号表:类字节码中类名、方法名、变量名
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 13084 = 314016 bytes, avg 24.000
Number of literals : 13084 = 514592 bytes, avg 39.330
Total footprint : = 988696 bytes
Average bucket size : 0.654
Variance of bucket size : 0.657
Std. dev. of bucket size: 0.810
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000 # 哈希表中有 60013 个桶
Number of entries : 991 = 23784 bytes, avg 24.000 # 里面有 991 个字符串记录
Number of literals : 991 = 64288 bytes, avg 64.872
Total footprint : = 568176 bytes # 占用 555 KB
Average bucket size : 0.017
Variance of bucket size : 0.016
Std. dev. of bucket size: 0.128
Maximum bucket size : 2
这是我们放入 100 个的情况下,下面我们试试调整 j < 20000
bash
[GC (Allocation Failure) [PSYoungGen: 2048K->512K(2560K)] 2048K->556K(9728K), 0.0078479 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
# [PSYoungGen: 2048K->512K(2560K)] 年轻代
# GC前用了2048K,GC已使用512K
# 2048K->556K(9728K) 整个堆空间的使用,GC前时用了2048K,GC后用了556K
# Times: 垃圾回收耗费的时间
20000
Heap
PSYoungGen total 2560K, used 1042K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 2048K, 25% used [0x00000007bfd00000,0x00000007bfd848c0,0x00000007bff00000)
from space 512K, 100% used [0x00000007bff00000,0x00000007bff80000,0x00000007bff80000)
to space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
ParOldGen total 7168K, used 44K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
object space 7168K, 0% used [0x00000007bf600000,0x00000007bf60b010,0x00000007bfd00000)
Metaspace used 3301K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 357K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 13151 = 315624 bytes, avg 24.000
Number of literals : 13151 = 516800 bytes, avg 39.297
Total footprint : = 992512 bytes
Average bucket size : 0.657
Variance of bucket size : 0.661
Std. dev. of bucket size: 0.813
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 7874 = 188976 bytes, avg 24.000
Number of literals : 7874 = 450632 bytes, avg 57.230
Total footprint : = 1119712 bytes
Average bucket size : 0.131
Variance of bucket size : 0.143
Std. dev. of bucket size: 0.378
Maximum bucket size : 3
可以看到,放入大概两万个左右,但实际上的 entries 只增加了不到 7000 个,因为开头,Allocation Failure,也就是说咱们的 10M 堆内存不够用,因为分配内存失败触发了垃圾回收机制
StringTable 性能调优
- 调整
-XX:StringTableSize=桶个数 - 考虑将字符串对象是否入池
调整 StringTableSize
StringTable 在 HotSpot 中可以理解成一张哈希表(出现碰撞就在后面放链表),查找性能和桶数量、字符串数量、哈希分布有关。如果字符串数量很多,而桶数量较少,就可能导致哈希冲突增多,影响 StringTable 的查找效率。因此 StringTable 调优的一个重要手段是通过 -XX:StringTableSize 调整桶的数量。但一般情况下不需要手动调优,只有在大量使用 intern() 或者观察到 StringTable 冲突严重时才考虑调整。
下面看看这个对性能的影响
java
public static void main(String[] args) throws IOException {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) { // 单词表,大概有 48 万个单词
String line = null;
long start = System.nanoTime();
while (true) {
line = reader.readLine();
if (line == null) {
break;
}
line.intern();
}
System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
}
}
当 -XX:StringTableSize=200000 时,cost:336
当 -XX:StringTableSize=60013 时,也就是默认桶数量,cost:495
当 -XX:StringTableSize=1009 时,cost:3552(每次运行数值都有浮动)
因为每次插入都得先查找看有没有再决定是否插入。
另外,StringTableSize 的大小有范围限制 StringTable size of 1008 is invalid; must be between 1009 and 2305843009213693951
字符串对象入池
使用 StringTable / 字符串入池的目的之一,就是复用相同内容的字符串对象,减少重复对象带来的内存浪费。
我们举例一段代码
java
public static void main(String[] args) throws IOException {
List<String> address = new ArrayList<>();
System.in.read();
for (int i = 0; i < 10; i++) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
String line = null;
long start = System.nanoTime();
while (true) {
line = reader.readLine();
if(line == null) {
break;
}
// address.add(line.intern());
address.add(line);
}
System.out.println("cost:" +(System.nanoTime()-start)/1000000);
}
}
System.in.read();
}
这是不入池的代码,文件中有大约 48 万个单词,循环 10 次,每个单词都会重复 10 遍。
运行程序

可以看到 String + char[] 大概会占用 10% 内存
等把数据放入 list 之后,直接飙升到了将近 90%

然后我们试着改动代码,把 address.add(line); 改为 address.add(line.intern());

占用了大概 56%,降低了很多
直接内存
定义
Direct Memory(直接内存),属于操作系统内存,不属于 JVM。
- 常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
使用以下代码来比较使用传统方式读写与 NIO 读写的区别,注意第一次启动读写性能会较差,需多运行几次,计算平均值。
java
/**
* 演示 ByteBuffer 作用
*/
public class Demo1_9 {
static final String FROM = "/Users/ice/Desktop/cola/mac软件/office_2024_MAC中文标准版.iso";
static final String TO = "/Users/ice/Desktop/cola/mac软件/backup/office_2024_MAC中文标准版.iso";
static final int _1Mb = 1024 * 1024;
public static void main(String[] args) {
io(); // io 用时:1535.586957 1766.963399 1359.240226
directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592
}
private static void directBuffer() {
long start = System.nanoTime();
try (FileChannel from = new FileInputStream(FROM).getChannel();
FileChannel to = new FileOutputStream(TO).getChannel();
) {
ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
while (true) {
int len = from.read(bb);
if (len == -1) {
break;
}
bb.flip();
to.write(bb);
bb.clear();
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
}
private static void io() {
long start = System.nanoTime();
try (FileInputStream from = new FileInputStream(FROM);
FileOutputStream to = new FileOutputStream(TO);
) {
byte[] buf = new byte[_1Mb];
while (true) {
int len = from.read(buf);
if (len == -1) {
break;
}
to.write(buf, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("io 用时:" + (end - start) / 1000_000.0);
}
}
{% note warning %}
我是 macOS 系统,没测出来 directBuffer 比 io 快,无论是 200MB 文件还是将近 3GB 的文件都是IO快,没搞明白,先不管了
{% endnote %}
为什么 IO 速度会比较慢呢?directBuffer 比较快呢?

当执行 IO 操作的时候,因为 Java 并不能直接读取文件,所以 CPU 会从用户态转为内核态,磁盘文件会先被读取到系统内存(读到系统内存,Java 并不认识),然后再从系统内存缓冲区读到 Java 缓冲区,也就是我们创建的堆中的 byte[]

使用 DirectBuffer 时,缓冲区位于堆外内存,数据可以从内核缓冲区拷贝到 Direct Memory,Java 通过 DirectByteBuffer 间接访问这块内存,因此减少了 Java 堆内存参与的一次数据拷贝。
内存溢出
直接内存也有内存溢出问题
java
static int _100Mb = 1024 * 1024 * 100;
public static void main(String[] args) {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
list.add(byteBuffer);
i++;
}
} finally {
System.out.println(i);
}
}

分配 72 次,就报错直接缓冲区内存(也叫堆外内存)溢出
释放原理
直接内存它不归 GC 进行回收,因为直接内存不属于 JVM 内存。
我们来解析一下直接内存回收的过程。Unsafe 是 jdk 底层的一个类,用于内存分配,内存回收等,一般普通程序员无需使用,这里我们通过反射获取 Unsafe 对象,演示直接内存分配的底层原理。
java
/**
* 直接内存分配的底层原理:Unsafe
*/
public class Demo1_27 {
static int _1Gb = 1024 * 1024 * 1024;
public static void main(String[] args) throws IOException {
Unsafe unsafe = getUnsafe();
// 分配内存
long base = unsafe.allocateMemory(_1Gb);
unsafe.setMemory(base, _1Gb, (byte) 0);
System.in.read();
// 释放内存
unsafe.freeMemory(base);
System.in.read();
}
public static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
return unsafe;
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}
运行代码,在任务管理器观察 jdk 进程内存占用发现,内存占用会在 allocateMemory() 后增加 1G,在 freeMemory() 后恢复。因此,直接内存的回收其实不是由 jvm 虚拟机完成,而是通过 Unsafe 对象调用 freeMemory() 完成。

释放后 Java 占用的内存就变得非常少了

下面查看一下 ByteBuffer 类的源码来验证
java
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
allocateDirect() 返回一个 DirectByteBuffer 对象
java
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
- 调用了
Unsafe中allocateMemory()来实现申请内存 - 新建
Cleaner对象来释放内存
Cleaner(虚引用 后面会讲) 中关联的 Deallocator 是什么?可以看到它实现了 Runnable,是回调任务对象,在 run 方法中调用了 Unsafe.freeMemory()。
java
private static class Deallocator
implements Runnable
{
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
直接内存的分配、释放过程可以理解为:调用 ByteBuffer.allocateDirect() 时,底层会通过 Unsafe.allocateMemory() 申请一块堆外内存,同时创建一个 Cleaner 对象,把 DirectByteBuffer 对象和一个释放内存的回调任务绑定起来。
当 DirectByteBuffer 对象不再被引用、被 GC 回收时,Cleaner 会执行 clean() 方法,最终调用 Unsafe.freeMemory() 释放这块直接内存。
{% note danger no-icon %}
Java 到底怎么控制直接内存呢?
- Java 是通过
DirectByteBuffer对象来管理和操作直接内存的。 - 直接内存本身不在 Java 堆中,GC 不能像回收普通对象一样直接回收它。
- 因此
DirectByteBuffer会关联一个Cleaner清理器,没有引用指向DirectByteBuffer对象时,它可以被 GC 回收,回收的时候Cleaner会执行清理逻辑,最终调用Unsafe.freeMemory()释放对应的直接内存。之后DirectByteBuffer对象也被释放。 - 猜一下也可以知道,申请和释放内存对应的方法一定是被
native修饰的
{% endnote %}
显示回收对直接内存的影响
java
static int _1Gb = 1024 * 1024 * 1024;
/*
* -XX:+DisableExplicitGC 显式的GC
*/
public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配完毕...");
System.in.read();
System.out.println("开始释放...");
byteBuffer = null;
System.gc(); // 显式的垃圾回收,Full GC
System.in.read();
}
看这段代码,我们通过 allocateDirect 分配了 1GB 的内存,然后通过设置 byteBuffer = null 让 Java 堆中的 DirectByteBuffer 对象变成可回收状态。调用 System.gc() 方式显示回收这个 byteBuffer 对象,然后触发 Cleaner,调用 clean() 方法释放这块直接内存。
但是 -XX:+DisableExplicitGC 这个设置会让 System.gc() 失效,生产环境下我们会把这个打开,所以如果开启这个就不能保证 DirectBuffer 及时释放掉(意思是 System.gc() 能释放,但是生成环境下一般不用,而且也不好)。
{% note danger no-icon %}
为什么会禁用显示的GC,也就是 -XX:+DisableExplicitGC,让 System.gc() 无效
因为 System.gc() 通常会触发一次 Full GC,特点是会暂停用户线程来执行垃圾回收,停留时间一般比较长(回收新声代、老年代、整理内存、触发STW停顿等,如果堆比较大,停顿会更明显)。为了防止程序员或者第三方库乱调用 System.gc(),导致线上服务突然 Full GC 卡顿。所以通常会开启这个禁止显示GC的配置。但是这样代价就是直接内存不能被及时释放
但是无所谓,正式开发就是如下
java
private static void useDirectBuffer() {
ByteBuffer buffer = ByteBuffer.allocateDirect(_1Mb);
// 使用 buffer
buffer.put((byte) 1);
buffer.flip();
// 方法结束后,buffer 局部变量失效
// DirectByteBuffer 对象以后被 GC 回收时,
// Cleaner 会释放它背后的直接内存
}
真想手动释放可以这样,但是不推荐,因为这个写法用了 JDK 内部类
java
public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配完毕...");
System.in.read();
System.out.println("开始释放...");
((DirectBuffer) byteBuffer).cleaner().clean(); // 手动调用释放直接内存,没有触发GC机制
byteBuffer = null;
System.in.read();
}
{% endnote %}
垃圾回收
如何判断对象可以回收
引用计数法
当一个对象被引用一次则计数 +1,失去引用计数 -1,当计数为 0 则判断为垃圾。但当对象间存在循环引用时会无法被回收。

这里 A 引用了 B,B 记数为 1,B 引用了 A,A 记数为 1。但是这样谁的记数都不会归 0,就都无法被回收,造成内存泄漏。
{% hideToggle 详细举例 %}
没看明白没有问题,我们用 Java 一个真实的例子来举例
java
A a = new A();
B b = new B();
a.b = b;
b.a = a;
a 对象在堆中对吧,被 a 这个引用引用了,然后 b.a 也指向了 a 对象。也就是说 a 对象是被引用两次的,同理 b 对象也是。
执行下面这个命令后
java
a = null;
b = null;
执行完之后,这个时候 a 对象和 b 对象都只少了一个引用,a.b = b 和 b.a = a 还存在,也就是上面这个循环里面存在的问题。但是这个时候我们的 a 和 b 都是 null 了,我们无法访问 a.b 和 b.a 了,这时候这块内存泄漏了,a 对象和 b 对象都无法被释放了。
{% endhideToggle %}
可达性分析算法
{% note warning %}
JavaScript 就是这么进行回收的
{% endnote %}
核心思想是从一组根对象出发,沿着对象之间的引用关系向下搜索。凡是能被根对象直接或间接访问到的对象,都认为是存活对象;凡是从根对象出发无法到达的对象,就认为是垃圾对象,可以被回收。
- Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
- 扫描堆中的对象,看是否能够沿着 GC Root对象 为起点的引用链找到该对象,找不到,表示可以回收
哪些对象可以作为根对象呢?使用 eclipse 的 MAT(memory analyzer) 可以进行分析。这个工具比 jvisual 更加专业,可以找到内存泄漏。
代码如下
java
/**
* 演示GC Roots
*/
public class Demo2_2 {
public static void main(String[] args) throws InterruptedException, IOException {
List<Object> list1 = new ArrayList<>();
list1.add("a");
list1.add("b");
System.out.println(1);
System.in.read();
list1 = null;
System.out.println(2);
System.in.read();
System.out.println("end...");
}
}
运行起来后用命令 jps 查看线程,在 list 回收前、后分别使用 jmap 抓取目标进程内存的快照,转储为二进制文件,并设置 live 参数在抓取快照前主动触发垃圾回收。操作如下
bash
(base) ice@jimodebingkeledeMac-mini jvm % jps
65427
62475 Main
65549 Launcher
65551 Jps
65550 Demo2_2
(base) ice@jimodebingkeledeMac-mini jvm % jmap -dump:format=b,live,file=1.bin 65550 # 回收前执行
Heap dump file created
(base) ice@jimodebingkeledeMac-mini jvm % jmap -dump:format=b,live,file=2.bin 65550 # 回收后执行
Heap dump file created
使用 MAT 工具,菜单栏 file->open dump file 打开刚才抓取的快照文件。

然后可以通过上述方式,查看 GC Roots

这是 GC Roots 的信息(绿色的 C 代表对象类型,里面的这些 class ... @... 就是根对象)
-
System Class系统类,里面存放的对象都是java.lang.Class类型,因为类对象被 JVM 加载后,它的信息都会保存一份来使用,注意不是某个类的实例,而是类的Class对象,静态变量属于类,所以也会存到这里。里面存放static集合- 单例对象
- 类加载器加载的
Class对象
{% note warning %}
简单回顾:类元信息主要在元空间里。Class 对象在 Java 堆里,关联着元空间里面的类元数据
{% endnote %}
-
JNI GlobalJNI 全局引用。JNI 是 Java 调用本地方法,也就是 native 方法用的,只要外面的 native 代码(C/C++代码)还抓着这个对象,这个对象就不能被 GC 回收
意思是比如javanative void save(Object obj);底层 C/C++ 代码如果把这个
obj保存起来,长期使用,就会创建一个 JNI Global Reference ,这样即使代码执行obj = null;,只要 native 代码还保存着这个全局引用,这个对象就不能被 GC。
{% note info %}
JNI Global Reference 这个引用本身不在堆里,它保存在 JVM 的本地数据结构里,属于 JVM/native 层维护的东西,但是它指向的 Java 对象在 Java 堆里。
{% endnote %} -
Thread活着的线程,只要线程还活着,线程栈里的局部变量、方法参数引用的对象就不能回收。

比如我们的main线程,我们也可以看到我们在里面存放的ArrayList列表。
正在运行的线程、线程栈中的局部变量、方法参数、ThreadLocal相关对象都存放在这里 -
Busy Monitor正在被线程持有锁的对象,比如javasynchronized(obj) { // 线程正在持有 obj 的锁 }只要某个对象正在被线程当作锁持有,JVM 就不能把它回收;否则锁状态、等待队列、释放逻辑都会出问题。
四种引用
一般认为是四种引用,这个老师觉得是5种(多一个终结器引用)。

强引用
咱们平时写代码最常见的就是强引用
java
Object obj = new Object();
这里 obj 就是强引用,只要强引用存在,对象就不会被 GC 回收,即使堆空间不够了,抛出异常也不会回收的
软引用
内存够时,软引用对象可以不回收;内存不够时,GC 会回收软引用指向的对象(前提是它没被强引用引用)
可以配合引用队列来释放软引用自身
java
SoftReference<byte[]> ref = new SoftReference<>(new byte[1024 * 1024 * 10]);
适合做缓存,比如图片缓存、临时数据缓存
弱引用
弱引用比软引用更弱,只要发生 GC,并且对象只被弱引用引用,就会被回收
java
WeakReference<User> ref = new WeakReference<>(new User());
System.gc();
User user = ref.get(); // 已经是 null
虚引用
虚引用是最弱的一种引用,都不能通过 get() 获取对象。
java
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> ref = new PhantomReference<>(new Object(), queue);
它的主要作用不是拿对象,而是对象被回收前,收到一个通知,常见用途是直接内存回收、资源释放监控、对象回收跟踪。
终结器引用
终结器引用和 finalize() 方法有关(Object 类有 finalize() 方法),可以被重写。
java
class User {
@Override
protected void finalize() throws Throwable {
System.out.println("对象即将被回收");
}
}
当对象重写了 finalize() 方法后,GC 第一次发现它不可达时,不会马上回收它,而是先把它放到一个队列里,让一个专门的线程去执行它的 finalize() 方法。
流程如下
txt
A 对象第一次被判定不可达
↓
不会马上回收
↓
JVM 创建/使用 FinalReference 关联这个对象
↓
FinalReference 进入一个队列
↓
Finalizer 线程从队列中取出它
↓
调用对象的 finalize() 方法
↓
如果对象没有复活,下一次 GC 才会真正回收
如果类没有重写这个 finalize() 方法,那么第一次发现 A 对象不可达,GC 就直接回收了。
软引用案例
引用队列不是负责回收对象的,而是负责通知你:这个引用关联的目标对象已经被 GC 处理了。
java
/**
* 设置堆内存大小 20MB
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
private static final int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) throws IOException {
List<byte[]> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(new byte[_4MB]);
}
System.in.read();
}
如上代码,会直接报错 java.lang.OutOfMemoryError: Java heap space
我们换为软引用试一试
{% note warning %}
我们原来说 "引用" 的时候似乎对 引用、对象 并没有严格区分,现在这里得强调一下,强引用它是一个引用变量,不是对象。但是其他几种引用是用来指向对象的一个对象,没错,软引用、弱引用这些也是对象。对象是内存中真正存放数据的实体。所以下面这个 new byte[_4MB] 是会在堆中创建一个 byte[] 对象。
引用有四种类别,SoftReference 本身也是一个 Java 对象,它内部保存了对这个 byte[] 对象的软引用关系。变量 ref 则是一个普通强引用,指向这个 SoftReference 对象。
详细分析 SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]); 这个语句
new byte[_4MB]在堆中创建了一个byte[]对象。new SoftReference<>(...)在堆中创建了一个软引用对象,这个SoftReference内部以软引用的方式 关联着byte[]对象ref强引用指向了这个软引用对象(所以当ref为null的时候,这个时候就可以GC这个软引用对象了)
所以只要内存不足,这个 new byte[_4MB] 就会被GC,因为它是被软引用的,但是 SoftReference 对象不会,因为它还被强引用着
{% endnote %}
java
public static void soft() {
// list --> SoftReference --> byte[]
List<SoftReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
System.out.println(ref.get()); // 获取软引用指向的内容
list.add(ref);
System.out.println(list.size());
}
System.out.println("循环结束:" + list.size());
for (SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}

就不会报堆空间不足了,整体引用关系是
txt
ref 变量
↓ 强引用
SoftReference 对象
↓ 软引用
byte[] 对象
{% note success %}
像缓存图片等不重要的对象,可以通过软引用来引用,当内存不足时就会回收它们。
{% endnote %}
同时可以看到上面,前四个软引用所指的对象已经是 null 了,没有必要把这四个软引用对象保留在 list 集合中了,可以配合引用队列,及时发现哪些 SoftReference 关联的 byte[] 已经被回收了,然后把这些 SoftReference 对象从 list 中移除。
这样 SoftReference 对象本身没有强引用后,之后也可以被 GC 回收。
java
List<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for (int i = 0; i < 5; i++) {
// 关联了引用队列, 当软引用所关联的 byte[]被回收时,软引用自己会加入到 queue 中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
// 从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while( poll != null) {
list.remove(poll);
poll = queue.poll();
}
System.out.println("===========================");
for (SoftReference<byte[]> reference : list) {
System.out.println(reference.get());
}

最终只剩下一个
{% note info %}
引用队列本身不会帮你回收 SoftReference 对象。它的作用是:当 SoftReference 关联的目标对象被 GC 回收后,JVM 会把这个 SoftReference 对象加入到 ReferenceQueue 中。我们可以从 ReferenceQueue 中取出这些已经失效的 SoftReference,然后把它们从 list、map 等集合中移除。移除之后,如果没有其他强引用指向这些 SoftReference 对象,它们本身才可以被 GC 回收。
{% endnote %}
弱引用案例
和软引用类似,就不跑代码了
java
/**
* 弱引用
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class Demo2_5 {
private static final int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) {
// list --> WeakReference --> byte[]
List<WeakReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
list.add(ref);
for (WeakReference<byte[]> w : list) {
System.out.print(w.get()+" ");
}
System.out.println();
}
System.out.println("循环结束:" + list.size());
}
}
软引用、弱引用、虚引用都可以用引用队列,用的都是 ReferenceQueue,用法也都一样,并且一个 ReferenceQueue 可以同时放这三种引用,但是通常不建议混用一个队列,毕竟不同引用类型含义不一样
java
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB], queue);
终结器引用不能用这个引用队列,它是 JVM 内部自己搞的 Finalizer 队列。
垃圾回收算法
《The Garbage Collection Handbook》 可以参考这个书籍
标记清除(Mark Sweep)

-
标记阶段:
从 GC Roots 出发,沿着引用链查找对象。
能找到的对象标记为存活对象。
-
清除阶段:
遍历堆内存,把没有被标记的对象当成垃圾对象回收。
-
回收不是把内存全部清零,而是把这些区域记录为空闲内存,之后分配对象时可以再次使用。
优点:实现简单,不需要移动对象,回收速度相对较快。
缺点:容易产生内存碎片
标记整理(Mark Compact)
标记-整理算法会先从 GC Roots 出发,标记所有可达对象。标记完成后,不是直接清除垃圾对象,而是把存活对象向内存的一端移动,然后直接清理掉边界以外的内存。

优点:内存连续,不容易产生内存碎片。
缺点:整理时需要移动存活对象,移动对象本身有成本;对象移动后地址变了,所有引用这些对象的引用地址也要更新;所以整理过程比单纯标记-清除更耗时。(从第一个有内存碎片的位置开始,后面所有的存活对象都要往前移动,地址都会变)
{% note info %}
如果存活对象比较多,那移动起来就更耗时了,但是标记整理算法适合存活对象多的,因为相对于复制算法,复制算法还需要一块很大的备用空间,但是这个在存活对象多的时候不需要一个很大的空间。
{% endnote %}
复制(Copy)

复制算法会先从 GC Roots 出发,标记出存活对象,然后把 from 区中的存活对象复制到 to 区。复制过程中,存活对象会被紧凑地排列到 to 区,所以同时完成了内存整理,避免了内存碎片。复制完成后,from 区中的垃圾对象不用逐个清理,直接把整个 from 区清空即可。最后交换 from 和 to 的角色,下一次 GC 时再从新的 from 区复制到新的 to 区。
优点:实现简单,回收速度快;复制后内存连续,不会产生内存碎片。并且标记和复制可以同时进行!
缺点:需要额外的空闲区域作为 to 区;如果存活对象很多,复制成本会很高;可用内存会减少,因为要预留一块空间。
分代垃圾回收

JVM 的堆内存采用分代回收机制,通常可以分为新生代和老年代。新生代又可以分为 Eden 区、Survivor From 区和 Survivor To 区。
采用分代回收的原因是:不同对象的生命周期不同,可以针对不同区域使用更合适的垃圾回收策略。
新生代主要存放生命周期较短、朝生夕死的对象,因此会比较频繁地进行垃圾回收,通常采用复制算法。
老年代主要存放生命周期较长、经过多次新生代 GC 后仍然存活的对象,因此 GC 频率较低,通常采用标记-清除或标记-整理算法。
分代回收流程

- 一个新对象创建出来后,通常会先分配到新生代的
Eden区。这样设计,是因为大多数对象生命周期很短,用完很快就没用了,所以先放到新生代里。 - 当
Eden区逐渐被对象填满,新的对象放不下时,就会触发一次 Minor GC。Minor GC 回收的是新生代,主要检查Eden + Survivor From。这里会发生 STW,也就是 Stop The World,用户线程会暂停一小段时间。 - 扫描:GC 会从 GC Roots 出发,判断哪些对象(
Eden/Survivor From中的)还能被访问到。 - Minor GC 不会把存活对象留在原位置,而是把存活对象复制到
Survivor To区。复制过去后,对象年龄会加1,那些不可达的垃圾对象直接被回收掉。
图中Eden和Survivor From变成了虚线状态,意思是它们原来的内容已经不再作为有效对象保留
从From里已经存活过的,年龄会再加1到To
所以新生代适合复制算法,因为大部分对象都是垃圾,真正需要复制的存活对象不多,所以速度快。 - 交换
Survivor区角色,这里只是改动一下指针,Survivor From指向之前的Survivor To,Survivor To指向之前的Survivor From。 - 如果一个对象经历多次 Minor GC 之后还活着,它的年龄会不断增加。当年龄达到一定阈值后,对象会晋升到老年代。如果
Survivor区放不下,部分对象也会直接进入老年代
对象年龄最大为15,因为对象头中用于记录年龄的空间是4bit - 如果出现新生代放不下新对象且老年代也紧张,会先尝试 Minor GC,不行就再 Full GC,还不行就抛异常
- 关于 Eden 还有一些小细节,如果有多个线程同时想创建对象,那么都要申请 Eden 区,是不是需要考虑同步问题?是不是速度就慢了,所以 Eden 分为多个 TLAB(线程本地分配缓冲区),以及一个共享 Eden 区,线程要分配对象时,先向 Eden 申请一块 TLAB,然后在这个上面去创建对象,如果申请失败,再在共享 Eden 区去分配,这个时候就要考虑同步,比如用 CAS 或加锁。如果共享 Eden 还无法分配对象,就 Minor GC 了。
TLAB 大小、数量都不是固定的。- Eden 区垃圾回收的时候,是不是还需要考虑跨代引用?老年区对象里面的某个对象类型的成员变量引用也可能指向 Eden 区对象,那为了知道 Eden 区到底被哪些引用来判断是否存活,岂不是要把老年代的也都扫描一遍才可以,速度又很慢。所以 JVM 引入了卡表,JVM 把老年代划分成很多 Card,如果老年代对象的引用字段被修改过,通过写屏障把对应 Card 标记为脏,Minor GC 只扫描这些脏卡,找出其中指向年轻代的引用。
{% note warning no-icon %}
新生代为什么适合复制算法?
Minor GC 暂停时间一般比较短,因为新生代的对象一般生命周期短,拷贝的次数少,大部分都是垃圾,需要回收
为什么 GC 时要暂停?
因为 GC 时会发生对象复制和移动,引用关系会发生变化,如果用户线程还在同时运行,可能一边访问旧地址,一边 GC 移动对象,引用关系就会乱。
怎么进入的老年代?
- 超过阈值:这个移动发生在 Minor GC 复制阶段,原本应该复制到
Survivor To,但对象年龄够大了,于是直接放到老年代 - Minor GC 后活下来的对象太多,
Survivor To装不下:也就是Eden + Survivor From的对象太多了,光Survivor To放不下,这时一部分对象会直接进入老年代。 - 大对象可能直接进入老年代:有些对象特别大,比如
50MB的数组,这种对象如果新生代放不下,或者 JVM 判断它不适合在新生代反复复制,可能会直接进入老年代。先记住有这个就行
{% endnote %}
{% note danger %}
即使老年代满了,Survivor To 放不开刚整理的 Eden + Survivor From,也不会把这些对象放到 Eden。Eden 在整理后必须是空的,流程如下
txt
Eden 满了
↓
触发 Minor GC
↓
扫描 Eden + Survivor From
↓
存活对象尝试复制到 Survivor To
↓
Survivor To 放得下?
├─ 放得下:进 Survivor To
└─ 放不下:尝试晋升老年代
↓
老年代放得下?
├─ 放得下:进老年代
└─ 放不下:触发 Full GC
↓
Full GC 后还放不下?
├─ 放得下:进老年代
└─ 放不下:OOM
为啥这样呢?因为 Eden 是给新对象分配用的,如果把存活对象又放回 Eden
Eden清不干净,下次新对象还是没地方放- 下次
Minor GC又要重新扫描这些老对象 - 无法体现"对象多次存活后逐渐晋升"的分代思想
所以 Minor GC 后,Eden 中原来的对象要么被回收,要么被转移出去。
{% endnote %}
相关 VM 参数
| 配置项 | JVM 参数 | 默认值 / 常见值 | 说明 |
|---|---|---|---|
| 堆初始大小 | -Xms<size> |
物理内存的一定比例,和 JVM/机器有关 | 设置 Java 堆初始大小 |
| 堆最大大小 | -Xmx<size> 或 -XX:MaxHeapSize=<size> |
物理内存的一定比例,和 JVM/机器有关 | 设置 Java 堆最大大小 |
| 固定新生代大小 | -Xmn<size> |
不固定,和堆大小、GC 策略有关 | 直接固定新生代大小,等价于同时设置 NewSize 和 MaxNewSize 为同一个值 |
| 新生代初始大小 | -XX:NewSize=<size> |
不固定,和堆大小、GC 策略有关 | 设置新生代初始大小 |
| 新生代最大大小 | -XX:MaxNewSize=<size> |
不固定,和堆大小、GC 策略有关 | 设置新生代最大大小 |
| Eden 与 Survivor 比例 | -XX:SurvivorRatio=<ratio> |
常见默认值是 8 |
设置 Eden : SurvivorFrom : SurvivorTo = ratio : 1 : 1 |
| 动态 Survivor 初始比例 | -XX:InitialSurvivorRatio=<ratio> |
常见默认值是 8 |
配合 -XX:+UseAdaptiveSizePolicy 使用,表示 Survivor 区初始比例 |
| 自适应大小策略 | -XX:+UseAdaptiveSizePolicy |
很多收集器下默认开启 | 允许 JVM 根据运行情况动态调整 Eden、Survivor、新生代等大小 |
| 晋升阈值 | -XX:MaxTenuringThreshold=<threshold> |
常见最大值是 15 |
设置对象晋升老年代的年龄阈值 |
| 晋升详情 | -XX:+PrintTenuringDistribution |
默认关闭 | 打印对象年龄分布和晋升信息 |
| GC 详情 | -XX:+PrintGCDetails |
默认关闭 | 打印 GC 详细日志 |
| GC 简要日志 | -verbose:gc |
默认关闭 | 打印 GC 基本信息 |
| Full GC 前先 Minor GC | -XX:+ScavengeBeforeFullGC |
默认通常开启 | Full GC 前先尝试执行一次 Minor GC |
-Xmn和NewSize / MaxNewSize不要同时设置- 关闭自适应
-UseAdaptiveSizePolicy,看SurvivorRatio比例;开启自适应,看InitialSurvivorRatio作为初始值,后续由 JVM 动态调。
GC 分析
参考下面代码,设置参数并运行。其中参数 -XX:+UserSerialGC 是将垃圾回收器设置为UserSerialGC,这种垃圾回收器的幸存区不会进行自动调整,有助于我们观察现象。
java
public class Demo2_1 {
// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc -XX:-ScavengeBeforeFullGC
public static void main(String[] args) throws InterruptedException {
}
}
直接跑上面这个代码

可以看到我们分配的新生代的内存是 10M,但是却显示 total 9216K,因为默认情况下 SurvivorTo 一直是空的,所以认为这一块不算总大小。tenured generation 就是老年代,大小是 10M
{% note info %}
Java 堆大小 = 新生代大小 + 老年代大小,所以我们虽然只设置了堆大小和新生代大小,但是老年代大小也就知道了
{% endnote %}
新生代 Eden 区只有 8M 内存,其中 28% 被占用了,我们新增如下代码
java
public static void main(String[] args) throws InterruptedException {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
}

可以看到是出发了一次 GC 的,这个 GC 就是 Minor GC,这里面 1877K->364K(9216K) 分别代表回收前占用内存大小和回收后占用内存大小以及总大小,这个前面的是新生代的,后面的 1877K->364K(19456K) 代表整个堆的。最后 real=0.01 secs 代表本次垃圾回收的时间
再加 512KB
java
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
list.add(new byte[_512KB]);

可以看到是能再放 512KB 的,因为只发生了一次 GC,再加 512KB 的时候没有触发第二次 GC
{% note warning %}
其实你多运行几次,会发现每次结果都有点不一样,一会是 100%,一会是 98%。
{% endnote %}
再加 512KB
java
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
list.add(new byte[_512KB]);
list.add(new byte[_512KB]);

加不上了,触发一次 Mionr GC。你可以看到其实 From 用了很多,是因为一次 GC 之后,是先把对象都复制到 To,然后 To 转换到 From。
{% note info %}
总结一下这个流程,第一次要分配 7MB 对象时,Eden 空间不够了,所以触发了一次 Minor GC,这次 GC 处理的是创建 7MB 对象之前已有的对象,存活对象复制到 To,然后 From/To 交换,之后 Eden 被清出空间,放下这 7MB 对象,再然后又要 512KB 内存,Eden 能放下,无事发生,不触发 GC,所以除了 Eden 区域其他地方无变化,之后又要加 512KB,放不下了,触发 Minor GC,但是此时 Eden + From 区域对象 To 区域放不下,所以尝试晋升老年代,这里可以看到把这个 7MB 对象放到老年代了(70% used,也就是 7MB)
{% endnote %}
{% note danger no-icon %}
Eden清空后放入到 7MB 对象,为什么不是刚好占用 7/8=0.875,也就是 87% 呢?为什么会出现多次运行上下浮动的原因呢?
待解决
{% endnote %}
思考另外一个问题,就是如果一个非主线程的其他线程发生内存溢出,会导致整个 Java 进程退出吗?
java
new Thread(() -> {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
}).start();
System.out.println("sleep....");
Thread.sleep(1000L);

其实并不会,堆内存是所有线程共享的,所以内存不足这件事和整个进程都有关系。因为某个子线程把堆吃满后,其他线程如果也要创建对象,也可能分配失败。但是 OutOfMemoryError 是在哪个线程分配对象失败,就抛给哪个线程。如果这个错误没有被捕获,默认只会导致当前线程结束,不会直接杀死整个 JVM 进程。
垃圾回收器
大多数 JVM 都需要使用两种不同的 GC 算法,一种清理年轻代,一种清理老年代。
三类垃圾回收器,其实是三种年轻代 GC 算法和老年代 GC 算法的组合
- 串行
- 单线程
- 适用于堆内存较小,个人电脑
- 吞吐量优先
- 多线程
- 适用于堆内存较大,多核 cpu
让单位时间内,STW 的时间最短0.2 0.2 = 0.4,垃圾回收时间占比最低,这样就称吞吐量高
- 响应时间优先
- 多线程
- 适用于堆内存较大,多核 cpu
尽可能让单次 STW 的时间最短0.1 0.1 0.1 0.1 0.1 = 0.5(虽然可能次数多,但是每次都很快)
串行
也就是 Serial 收集器
-XX:+UseSerialGC = Serial + SerialOld
Serial 工作在新生代,采用的回收算法是复制
SerialOld 工作在老年代,采用的是标记整理算法

当某个线程分配对象时发现内存不足,会触发 GC 请求。JVM 会让所有用户线程运行到安全点并暂停,也就是发生 STW。随后由 JVM 的 GC 线程执行垃圾回收。
在 -XX:+UseSerialGC 下:
- 新生代空间不足时,触发 Minor GC,STW 后使用
Serial收集器;--> Full GC 的时候也会用这个收集器 - 老年代空间不足或晋升失败、
System.gc()等原因触发 Full GC 时,STW 用Serial收集器回收新生代,用SerialOld收集器回收老年代
可以看到,不管有多少 CPU 内核,JVM 在垃圾收集时都只能用一个核心,比较适合几百 MB 堆内存的 JVM,而且是单核 CPU 时比较有用。一般服务器都是多个 CPU 内核,所以这个并不推荐使用,除非你需要限制 JVM 使用的资源。
吞吐量优先

{% note warning %}
Parallel 是"GC 线程并行工作",不是用户线程和 GC 线程并发工作
{% endnote %}
开启 -XX:+UseParallelGC 可以使用吞吐量优先的垃圾回收器。在 JDK 8 中,它通常对应新生代使用 Parallel Scavenge,老年代使用 Parallel Old。新生代仍然使用复制算法,老年代使用标记-整理算法。它的特点是:垃圾回收前会发生 STW,所有用户线程暂停;垃圾回收时会启动多个 GC 线程并行执行回收任务,因此可以充分利用多核 CPU,提高吞吐量(CPU占用会飙升至 100%)。因为是多个 GC 线程并行垃圾回收,所以 GC 时间会大幅度减少。
执行过程
-
新生代内存不足时触发 Minor GC --> 用 Parallel Scavenge 回收新生代
-
老年代内存不足时触发 Full GC --> 用 Parallel Scavenge 回收新生代 + Parallel Old 回收老年代
-
可以使用
-XX:ParallelGCThreads=<n>指定并行 GC 线程数量。 -
-XX:+UseAdaptiveSizePolicy表示开启自适应大小策略,JVM 会根据运行情况动态调整堆、新生代、Eden、Survivor 等区域大小。 -
-XX:GCTimeRatio=<n>用于设置吞吐量目标。GC 时间占比约为1 / (1 + n)。例如-XX:GCTimeRatio=19,表示 GC 时间约占5%;默认值通常是99,表示 GC 时间约占1%。 -
-XX:MaxGCPauseMillis=<ms>用于设置最大 GC 停顿时间目标。例如-XX:MaxGCPauseMillis=200,表示 JVM 会尽量将单次 GC 停顿控制在200ms左右。
GCTimeRatio 和 MaxGCPauseMillis 之间存在取舍:
- 如果追求更高吞吐量,JVM 可能会增大堆空间,减少 GC 次数,但单次 GC 停顿可能变长;
- 如果追求更短停顿,JVM 可能会减小每次 GC 处理的内存量,但 GC 次数可能增加,吞吐量下降。
txt
# 常见参数如下
-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:+UseAdaptiveSizePolicy
-XX:ParallelGCThreads=4
-XX:GCTimeRatio=99
-XX:MaxGCPauseMillis=200
响应时间优先
CMS,全称 Concurrent Mark Sweep ,是工作在老年代的垃圾回收器。
开启参数:
text
-XX:+UseConcMarkSweepGC
CMS 属于响应时间优先 的垃圾回收器,它采用的垃圾回收算法主要是标记-清除算法 。其中 Conc 表示并发,意思是 CMS 在某些阶段可以让垃圾回收线程和用户线程同时执行。这样有利于减少 STW 时间,提高程序的响应性能。但是因为 GC 线程会和用户线程抢占 CPU,所以也会牺牲一定的吞吐量。
CMS 并不是所有阶段都并发执行,它仍然有一些阶段需要 STW。CMS 通常和新生代垃圾回收器 ParNewGC 配合使用:
text
新生代:ParNewGC --> -XX:+UseParNewGC
老年代:CMS
不过,CMS 垃圾回收器有时会发生并发失败 ,也就是 Concurrent Mode Failure 。这时 JVM 会采取补救措施,退化为一次更重的 Full GC,可能使用 Serial Old 对老年代进行回收和整理,从而导致较长时间的 STW。
CMS 的回收流程
CMS 的回收过程主要分为四个阶段:

-
初始标记:需要 STW
-
并发标记:不需要 STW
-
重新标记:需要 STW
-
并发清除:不需要 STW
-
初始标记
在老年代使用率达到一定阈值时,CMS 会开始进行垃圾回收。首先进入初始标记阶段 。这个阶段需要 STW,也就是会暂停其他用户线程。初始标记阶段主要做的事情是:标记 GC Roots 直接关联的对象,也包括年轻代指向老年代的(不是标记所有可达对象,而是只标记 GC Roots 直接引用到的对象 )。因此这个过程很短。

例如:
text
GC Roots → A → B → C
初始标记阶段主要标记的是 A,不会继续完整扫描 B 和 C。
- 并发标记
初始标记结束后,用户线程恢复执行,同时垃圾回收线程进入并发标记阶段。这个阶段 GC 线程会和用户线程并发执行。GC 线程会从初始标记阶段找到的对象开始,继续沿着引用链向下扫描,标记所有存活对象。没有被标记到的对象,最后才会被认为是垃圾对象并被清理。并发标记阶段的优点是用户线程不用长时间暂停。但是缺点是 GC 线程会占用 CPU 资源,因此会降低系统吞吐量。
- 重新标记
在并发标记结束后,会进入重新标记阶段。这个阶段也需要 STW。重新标记的作用是:修正并发标记期间,由于用户线程继续运行而导致的对象引用关系变化(重新标记不是因为对象地址发生变化,而是因为对象引用关系发生变化)
因为 CMS 使用的是标记-清除算法,大多数情况下不会移动对象地址。
真正需要重新标记的原因是:并发标记期间用户线程还在运行,对象之间的引用关系可能会发生变化。
{% note info %}
并发标记阶段,JVM 通过写屏障记录引用写入,然后把相关的区域标记为 dirty,这样重新标记阶段,只需要扫描这些 dirty 区域,修正并发标记阶段可能漏掉的对象就可以了。
{% endnote %}
例如原来是
text
A → B

后来用户线程执行代码后变成
text
A → C

所以 CMS 需要在重新标记阶段暂停用户线程,修正这部分变化。
- 并发清除
重新标记结束后,用户线程又可以继续执行,垃圾回收线程进入并发清除阶段 。并发清除阶段中,GC 线程和用户线程并发执行。GC 线程会清理那些没有被标记到的垃圾对象。因为 CMS 使用的是标记-清除算法 ,所以它只清除垃圾对象,不会整理内存,也不会移动存活对象。因此清理后可能会留下很多内存碎片。
内存碎片过多时,可能会导致虽然老年代总剩余空间足够,但是找不到一块连续的大空间来存放大对象。
参数
并行 GC 的线程数量,控制 STW 阶段 的 GC 线程数。作用在 初始标记、重新标记、Young GC 阶段
text
-XX:ParallelGCThreads
并发 GC 线程数,控制 CMS 并发阶段 的 GC 线程数。作用在:并发标记、并发清除。一般来说,并发线程数会少于并行线程数,避免 GC 线程过多抢占 CPU。
text
-XX:ConcGCThreads
常见经验
ConcGCThreads ≈ ParallelGCThreads / 4
CMS 触发阈值,设置老年代使用率达到多少时触发 CMS。CMS 需要提前触发,不能等老年代满了再回收,因为并发回收期间用户线程还会继续分配对象,并且会产生浮动垃圾。不能像原来那样满了才去收,因为是现在是并发去收,如果回收垃圾慢,产生快,明明内存够用,只不过是垃圾还没回收完导致 OOM,就不应该了,所以提前去收垃圾。
text
-XX:CMSInitiatingOccupancyFraction=<percent>
-XX:CMSInitiatingOccupancyFraction=70 # 老年代使用率达到 70% 左右时触发 CMS
固定触发阈值,让 JVM 按照 CMSInitiatingOccupancyFraction 设置的阈值触发 CMS。如果不加,JVM 可能会根据运行情况动态调整触发时机。和前面这个配置搭配着用
text
-XX:+UseCMSInitiatingOccupancyOnly
重新标记前触发 Young GC,作用是在 CMS 重新标记前 先触发一次 Young GC。为什么呢?原因是虽然 CMS 回收的是老年代,但是新生代对象也可能引用老年代对象,所以判断老年代对象是否存活时,还需要考虑新生代中是否存在指向老年代的引用。
text
-XX:+CMSScavengeBeforeRemark
CMS 的并发标记阶段,用户线程还在运行,因此对象引用关系可能一直在变化,例如:
- 新生代对象可能创建
- 新生代对象可能死亡
- 新生代对象可能引用老年代对象
- 老年代对象之间的引用也可能变化
目的
- 减少新生代对象数量
- 降低重新标记阶段的扫描压力
- 减少 remark 阶段 STW 时间
CMS 常见问题
-
抢占 CPU,降低吞吐量
CMS 的并发标记、并发清除阶段,GC 线程会和用户线程一起运行。所以 CMS 虽然减少了 STW 时间,但会抢占 CPU,降低系统吞吐量。
-
浮动垃圾
CMS 并发清理时,用户线程仍然运行。这期间新产生的垃圾,本轮 CMS 可能无法清理,只能等下一次 GC。这类垃圾称为:浮动垃圾。所以 CMS 必须提前触发,给浮动垃圾和用户线程继续分配对象预留空间。
-
内存碎片
CMS 使用 标记-清除算法 。它只清理垃圾对象,不整理内存,也不移动存活对象。因此回收后可能出现很多不连续的空闲空间,也就是内存碎片。
结果可能是:老年代总剩余空间足够但是找不到连续的大空间,从而导致大对象分配失败。
所以之前会有相关参数配置
-XX:+UseCMSCompactAtFullCollection意思是在 Full GC 时进行内存整理,比如用 Serial Old,缺点就是 STW 时间长 -
Concurrent Mode Failure
如果 CMS 并发清理过程中,用户线程一直申请堆空间导致老年代空间不足,就可能发生:
Concurrent Mode Failure常见原因:
text1. CMS 触发太晚 2. 用户线程分配对象太快 3. 新生代对象大量晋升到老年代 4. 老年代内存碎片过多 5. CMS 还没清理完,老年代空间就不够了发生后,JVM 会退化为一次更重的 Full GC,可能使用 Serial Old 对老年代进行回收和整理。所以会造成较长时间停顿(单线程,STW)。
和内存碎片有点像,不同的是内存碎片是因为空间不连续导致的,后者是 CMS 回收速度赶不上老年代消耗速度。
{% note info %}
CMS 学了这么久,可以扔一边了,因为现在一般用更好的 G1 垃圾回收器。
{% endnote %}
G1
定义:Garbage First
- 2004 论文发布
- 2009 JDK 6u14 体验
- 2012 JDK 7u4 官方支持
- 2017 JDK 9 把G1作为默认垃圾回收器,并且废弃了 CMS 垃圾回收器
G1 最主要的设计目标是:将 STW 停顿的时间和分布变成可预期以及可配置的。事实上, G1 是一款软实时垃圾收集器, 也就是说可以为其设置某项特定的性能指标。可以指定: 在任意 xx 毫秒的时间范围内, STW 停顿不得超过 x 毫秒。 如: 任意 1 秒暂停时间不得超过 5 毫秒. Garbage First GC 会尽力达成这个目标(有很大的概率会满足, 但并不完全确定)。
适用场景
- 同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是 200 ms
- 超大堆内存,会将堆划分为多个大小相等的 Region(性能上堆内存小的时候,CMS 和 G1 差不多,堆内存大的时候 G1 优势更大)
G1主要通过 Evacuation(转移) 回收空间:把选中区域里的存活对象复制到新的区域,并且这个过程中完成压缩(减少碎片)
每个 Region 可以是 Eden、Survivor、Old、Free、Humongous 任何一个区域(Humongous 专门用来存储大文件)
相关 JVM 参数
-XX:+UseG1GCJDK9 之前需要手动开启-XX:G1HeapRegionSize=size(必须是 1,2,4,8 这种大小)-XX:MaxGCPauseMillis=time
G1 垃圾回收阶段

三个阶段 Young Collection --> Young Collection + CM --> Mixed Collection
分别对应
- 新生代空间不足
- 老年代空间不足
- 混合收集
Young Collection

- 把堆划分成了一个个 Region,每个 Region 都可以作为 Eden,Survivor,老年代。
- 图里面的
E代表 Eden,白色的部分代表空闲区域 - Eden 区域也会设置大小,当超过这个区域大小上限后会触发 STW,采用多个 GC 线程并行回收

会以复制的算法把 Eden 中存活的对象去拷贝到幸存区

如果幸存区满了,或者该晋升了,会把幸存区中存活对象的拷贝到老年区,同时把 Eden 和幸存区中 Survivor From 的存活对象放到 Survivor To,然后交换 From 和 To
Young Collection + CM
Concurrent Start Young Collection --> CM 是并发标记

- 当老年代占用堆空间比例达到阈值时,会启动一次并发标记周期,由下面的 JVM 参数决定
-XX:InitiatingHeapOccupancyPercent=percent(默认45%)老年代占用堆空间 45% 时触发,回收流程如下- 先来一次 Young Collection,这次和之前的还不一样,它会顺带做初始标记(STW)
- STW 结束,用户线程恢复,后台开始 Concurrent Mark 并发标记
- 最终标记(Remark)会 STW --> 并发标记阶段可能会漏掉一些对象(并行的)
- Cleanup 统计 Region 回收价值,为 Mixed Collection 做准备
Mixed Collection

会对 E、S、O 进行全面垃圾回收
并发标记结束后,G1 就知道哪些 Old Region 垃圾多,回收收益高,然后进入 Mixed Collection 阶段
- 拷贝存活(Evacuation)会 STW --> 也就是开始清理了(并行执行的)
{% note info %}
图中黑色线代表复制算法,红色线是标记整理算法
{% endnote %}
-XX:MaxGCPauseMillis=ms 最大 GC 暂停时间,为了满足这个时间限制,会选择老年代里面回收价值最高的 Region 进行回收,这也是为什么叫 Garbage First 的原因。
{% note info %}
混合收集目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器有这种行为。
{% endnote %}
整体流程

-
Young GC (完全 STW)
Eden Region 满了,触发 Young GC
→ STW
→ 回收年轻代 Region: Eden + Survivor
→ 存活对象复制到 Survivor / Old Region
→ 原来的 Eden / Survivor Region 清空
-
Young GC + Initial Mark (完全 STW)
堆占用达到
InitiatingHeapOccupancyPercent=percent阈值,触发一次带 Initial Mark 的 Young GC→ STW
→ 在这次暂停中同时做两件事:
-
正常回收年轻代 Region
-
完成 Initial Mark,标记从 GC Roots 直接可达的对象
→ 为后续 Concurrent Mark 做准备
-
-
Concurrent Mark (并发标记)
STW 结束,用户线程恢复
→ GC 线程和用户线程并发运行
→ GC 线程从 Initial Mark 标记到的对象继续沿引用链扫描
→ 标记堆中可达对象,并统计各个 Region 的存活率
→ 重点找出哪些 Old Region 垃圾比例高,适合作为后续 Mixed GC 的候选 Region
-
Remark (重新标记) STW
→ 短暂 STW
→ 修正并完成最终标记结果
-
Cleanup (短暂 STW,部分并发)
有一部分是并发的: 例如空堆区的回收,还有大部分的存活率计算
→ 短暂 STW,后续部分清理可并发进行
→ 统计 Region 存活率和回收价值
→ 回收完全空的 Region
→ 筛选出垃圾较多、值得回收的 Old Region,作为 Mixed GC 候选 Region
-
Mixed GC (完全 STW)
→ STW
→ 回收全部年轻代 + 一部分垃圾多的老年代 Region
→ Evacuation:把活对象复制到新 Region
→ 原 Region 清空
-
回到 Young GC
→ 重新,如果 Eden Region 满才开始 Young GC,堆占用达到 IHOP 阈值,开启新一轮的操作
Full GC
- SerialGC
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足发生的垃圾收集 - full gc
- ParallelGC
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足发生的垃圾收集 - full gc
- CMS
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足 - 正常情况下只有 major GC,老年代回收,并发收集慢时退化为 full gc
- G1
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足 - 正常是 mixed gc(全部年轻代+部分老年代),如果 G1 来不及回收(产生对象速度大于垃圾回收速度),或没有足够空 Region 让对象复制/晋升,退化为一次 STW 的 full gc
《Java hotspot G1 GC 的一些关键技术》--美团技术团队
Young Collection 跨代引用

新生代回收的时候,需要注意可能有些老年代会引用到新生代,所以还需要检查扫描老年代来确定根对象,逐层向下才能知道新生代到底还是否可达。但是如果每次全量扫描又很耗时,所以有了卡表和 RSet(Remembered Set)。
卡表是说把 Region(不止老年代) 划分成大小相同的 Card,每个大小默认是512B,每个 Region 都有一个自己的 RSet,RSet 是一个 HashTable,Key 是别的 Region 的起始地址,Value 是一个集合,里面的元素是 Card Table 的 Index 集合。比如下面 Region2 的 RSet 中的一条记录就是 Region1 --> { #2 } (这个记录也可以是 Region1 --> { #2, #5, #8 },因为一个 Region 可能有多个 Card 指向我)

假如说引用关系发生了变化,比如 (Region1)oldObj.field = youngObj(Region2)。那么G1以后 Young GC Region2 的时候,就必须知道 Region1 里有人引用了 Region2,不然就可能误回收 youngObj。当发生引用写入之后,JVM会自动帮我们在这个后面插入一个额外逻辑,post-write barrier,判断两个是不是一个 Region,如果是跨 Region,那么就需要记录 Region1 里有 Card 可能引用了 Region2。假设 oldObj 在 Region1 的第二个 Card,那么 post-write barrier 就会记住 Region1 #2 脏了,标记一下。但是这个时候 JVM 只知道这个 Card 发生过引用写入,但是具体是哪个它不知道,随后把这个 dirty Card 放到脏卡队列。
{% note warning %}
注意:oldObj.field = youngObj 这个赋值操作是已经发生了的,因为我知道对象地址,改引用值还是要改的,RSet 只是在辅助垃圾回收
{% endnote %}
处理脏卡队列 --> Concurrent Refinement Threads 线程来处理(因为引用改的比较频繁,如果每次都更新RSet影响业务了)
- 取出来一个脏卡
- 需要遍历这个卡里面所有的对象,看看它的引用指向哪个 Region
- 更新这些 Region 的 RSet
Young GC 用 RSet --> 假设要回收 Region2
如果没有 RSet,就需要扫描整个老年代,找有没有对象引用 Region2,但是有了 RSet,只需要扫描那些引用它的 Region 的那些卡就可以了
{% note warning %}
但是此时也只知道其他的 Region 的哪些卡引用了 Region2,所以还需要扫描那些卡中哪些对象引用了 Region2 的哪些对象,来判断要不要回收。所以 RSet 只是相当于帮忙缩小了扫描范围。并且 RSet 是记录别的 Region 里有没有对象引用了我(不止老年代),不需要记录自身,因为 GC 的时候本身也会扫描自己的活对象。
{% endnote %}

{% note danger no-icon %}
String 对象的根对象是 String.class 吗?
不是,String.class 是 Class 对象,也是一种根对象,但是它并不会指向自己的实例(这样 String 对象就不会被释放了),String 对象头里面的 klass pointer 会指向 String.class,也就是说实例知道自己是哪个类,但是类不知道自己有多少实例。
普通 String 对象靠什么活着?
-
线程栈引用,比如线程栈的局部变量
javapublic void test(){ String s = new String("a"); } -
静态字段引用
javaclass Holder { static String name = new String("a"); }这个时候引用链就是
textGC Root: Holder.class | v static field name | v String 对象 -
老年代对象引用新生代,比如
javastatic List<String> list = new ArrayList<>(); // 如果 list 已经进入老年代 list.add(new String("abc")); -
字符串常量池
{% endnote %}
Remark
重新标记阶段
本节抄自《深入理解Java虚拟机》
前置知识: 三色标记
- 白色:表示对象尚未被垃圾收集器访问过。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
最后扫描完应该只剩下黑色和白色,白色就是垃圾
并发标记阶段可能出现的问题由重新标记来解决。收集器在标记的时候,同时用户线程也在修改引用关系。那就可能出现两种情况
- 一种是把原本消亡的对象错误标记为存活(比如 A -> B,B 被标记完黑色了,但是之后用户线程又改了引用 A 不指向 B 了,但是 B 对象此时还是标记为存活,实际已经没了,这种不会出错,顶多这次垃圾回收不了了,下次再回收)
- 另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误

也就是说,当且仅当以下两个条件同时满足时,会产生"对象消失"的问题,即原本应该是黑色的对象被误标为白色:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:{% label 增量更新 orange %}(Incremental Update)和{% label 原始快照 orange %}(Snapshot At The Beginning,SATB)。
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
- 举例倒数第二个图中,黑色为 A,灰色为 B,白色为 C,当灰色指向白色的被删除的时候,会把 C 保存起来,表示已标记,之后就不会清空了!!!也就是说无论有没有 A 指向 C 都会保留下来。这个时候 C 属于浮动垃圾,下一回合再回收。相当于按照并发标记开始时的对象图快照来判断存活。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。
对 G1 的优化
JDK 8u20 字符串去重
优点:节省大量内存
缺点:略微多占用了 cpu 时间,新生代回收时间略微增加
java
String s1 = new String(new char[]{'h','e','l','l','o'});
String s2 = new String(new char[]{'h','e','l','l','o'});
-XX:+UseStringDeduplication 进行开启
JDK 8u20 的 G1 字符串去重不是在 String 创建时立刻执行,而是在 GC 过程中,G1 扫描存活对象时发现某些"值得去重"的 String,比如已经存活过几次 GC、可能进入老年代的字符串,就把这些 String 对象的引用放入 JVM 内部的去重队列。之后后台的去重线程从队列中取出字符串,计算它内部 char[] 的哈希值,并到字符串去重表中查找是否已有相同内容的字符数组。如果没有,就把当前 char[] 记录到表中,作为后续共享的候选;如果有相同哈希的数组,还会逐字符比较(因为可能会哈希冲突,相当于 equals 比较),确认内容完全一致后,把当前 String 的 value 属性指向表中已有的那个 char[]。这样多个不同的 String 对象仍然是不同对象,但它们底层可以共享同一个字符数组,原来多余的 char[] 失去引用后会在后续 GC 中被回收,从而节省内存。
注意:与 String.intern() 不一样
String.intern()关注的是字符串对象,让相同内容的字符串使用同一个引用- 而字符串去重关注的是
char[],不同的String还是不同的引用,但是指向的char[]是同一份 - 在 JVM 内部,使用了不同的字符串表 --> 这个也会存一个字符数组表,为了理解,可以记为
Map<Integer, List<char[]>> dedupTable,哈希值作为键,字符数组作为值(注意,可能有哈希冲突,所以可能存多个字符数组)
JDK 8u40 并发标记类卸载
JDK 8u40 之后,G1 支持在并发标记结束后进行类卸载。并发标记会找出堆中所有存活对象,同时追踪这些对象对应的类和类加载器;如果某个类加载器已经不可达,那么它加载的所有类也可以被认为不再使用,并在 Remark 阶段进行卸载,释放对应的 Metaspace 元数据。这个机制主要对自定义类加载器、动态生成类、热部署等场景有意义,因为系统类加载器、启动类加载器通常会一直存活到 JVM 退出。-XX:+ClassUnloadingWithConcurrentMark 默认开启;
🐯 条件比较苛刻
JDK 8u60 回收巨型对象
其实除了 Eden、Survivor、Old 三种 Region 之外,还有一个 Humongous Region 用来存储巨型对象(一个对象大于 Region 的一半),从逻辑上 Humongous 属于老年代

巨型对象是大小达到或超过一个 G1 Region 一半的对象。它不会进入普通 Eden,而是直接分配到老年代的一组连续 Humongous Region 中;如果对象很大,就可能占用多个连续 Region。G1 对普通对象回收时通常会采用复制/转移,但对巨型对象不会搬动,只判断它是否还存活;如果不可达,就直接回收它占用的整段 Region。因为巨型对象占用空间大,回收收益高,所以 G1 会尽量优先识别可回收的巨型对象。通常情况下,巨型对象会在并发标记结束后的 Cleanup 阶段,或者 Full GC 时被回收;但从 JDK 8u60 起,G1 也可以在 Young GC 时尝试提前回收某些巨型对象,特别是没有或几乎没有有效 incoming references 的巨型对象。这里的 incoming references 是指 G1 通过 remembered set 等结构判断是否还有其他区域、GC Roots 等地方引用这个巨型对象;如果确认没有有效引用,就可以在 Young GC 时直接回收它。
{% note warning %}
都有谁可能指向一个对象???
存放在栈里面的局部变量,或者是堆里的对象的字段。所以前面说的可能有老年代对象指向新生代,是指可能老年代中有对象的一个属性指向这个新生代对象。
静态变量、JNI 引用、JVM 内部引用不属于普通 Java 方法栈。它们作为 GC Roots,也间接或直接指向堆中的对象。
{% endnote %}
JDK 9 并发标记起始时间的调整
G1 的并发标记必须在堆空间耗尽前完成,因为只有标记完成后,G1 才知道哪些老年代 Region 垃圾多,后面才能通过 Mixed GC 回收老年代空间。JDK 9 之前,G1 主要通过 -XX:InitiatingHeapOccupancyPercent 设置固定阈值,默认约 45%,阈值太低会导致并发标记过早、浪费 CPU,阈值太高又可能导致标记还没完成堆就满了,从而退化成 Full GC。JDK 9 开始引入 Adaptive IHOP,InitiatingHeapOccupancyPercent 更多作为初始值,G1 会根据历史数据采样,比如并发标记耗时、标记期间老年代分配速度等,动态调整下次并发标记的启动阈值,尽量保证并发标记和后续 Mixed GC 能在堆被占满前及时发生。
也就是什么呢,比如我有 1000MB 空间,预计标记期间会增加 200MB,JVM 又留了 100MB 安全空间,这样就是在使用了 70% 的时候来触发。
GC 调优
类加载与字节码技术

类文件结构
执行 javac -parameters HelloWorld.java,编译为 HelloWorld.class 后是这样的
-parameters意思是编译时把方法参数名也保存到.class文件里,如果不加,你的name, age变量名可能就是arg0, arg1
根据 JVM 规范,类文件结构如下
text
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];
}
uX 的 X 代表占用几个字节
魔数
0~3 字节,表示它是否是【class】类型文件
0000000 ca fe ba be 00 00 00 34 00 1f 0a 00 06 00 11 09
不同的文件有不同的魔数,Java 的 class 文件的魔数是 ca fe ba be(咖啡宝贝)
版本
0000000 ca fe ba be 00 00 00 34 00 1f 0a 00 06 00 11 09,
00 00 是 minor version,是 0
00 3D 是 major version,是 61,代表 Java 17。00 34(52) 代表 Java 8
常量池
常量池长度
8~9 字节,表示常量池长度,00 1f(31)表示常量池有 #1~#30 项,其中 #0 项不计入,也没有值
常量池 cp_info
常量池的项目类型
| tag 值 | 常量类型 | 结构 | 总字节数 |
|---|---|---|---|
1 |
CONSTANT_Utf8 |
tag + length + bytes |
1 + 2 + N |
3 |
CONSTANT_Integer |
tag + bytes |
1 + 4 |
4 |
CONSTANT_Float |
tag + bytes |
1 + 4 |
5 |
CONSTANT_Long |
tag + high_bytes + low_bytes |
1 + 4 + 4 |
6 |
CONSTANT_Double |
tag + high_bytes + low_bytes |
1 + 4 + 4 |
7 |
CONSTANT_Class |
tag + name_index |
1 + 2 |
8 |
CONSTANT_String |
tag + string_index |
1 + 2 |
9 |
CONSTANT_Fieldref |
tag + class_index + name_and_type_index |
1 + 2 + 2 |
10 |
CONSTANT_Methodref |
tag + class_index + name_and_type_index |
1 + 2 + 2 |
11 |
CONSTANT_InterfaceMethodref |
tag + class_index + name_and_type_index |
1 + 2 + 2 |
12 |
CONSTANT_NameAndType |
tag + name_index + descriptor_index |
1 + 2 + 2 |
15 |
CONSTANT_MethodHandle |
tag + reference_kind + reference_index |
1 + 1 + 2 |
16 |
CONSTANT_MethodType |
tag + descriptor_index |
1 + 2 |
17 |
CONSTANT_Dynamic |
tag + bootstrap_method_attr_index + name_and_type_index |
1 + 2 + 2 |
18 |
CONSTANT_InvokeDynamic |
tag + bootstrap_method_attr_index + name_and_type_index |
1 + 2 + 2 |
19 |
CONSTANT_Module |
tag + name_index |
1 + 2 |
20 |
CONSTANT_Package |
tag + name_index |
1 + 2 |
解释哈,就是在常量池长度后面紧跟着的就是常量池的信息,结构就是 #1, #2, #3 这种顺序,但是每个常量占用的字节不一样,所以根据 tag 值来判断下面紧接着会有几个字符属于这个常量的信息
0000000 ca fe ba be 00 00 00 34 00 1f 0a 00 06 00 11 09
第 #1 项 0a(对应 CONSTANT_Methodref) 表示一个 Method 引用,00 06(6) 和 00 11(17) 表示它引用了常量池中的 #6 和 #17 项来获得这个方法的【所属类】和【方法名和方法描述符】
0000000 ca fe ba be 00 00 00 34 00 1f 0a 00 06 00 11 09
第 #2 项 09 表示一个 Field 引用,00 12 #18 表示【所属类】00 13 #19 表示【字段名和字段描述符】
0000000 ca fe ba be 00 00 00 34 00 1f 0a 00 06 00 11 09
0000020 00 12 00 13 08 00 14 0a 00 15 00 16 07 00 17 07
第 #3 项 08 表示一个字符串常量引用,00 14 #20 表示它引用了常量池中 #20 项
0000020 00 12 00 13 08 00 14 0a 00 15 00 16 07 00 17 07
第 #4 项 0a 表示一个 Method 引用,00 15 #21 00 16 #22 表示引用常量池的 #21 和 #22 来获得这个方法的【所属类】和【方法名和方法描述符】
0000020 00 12 00 13 08 00 14 0a 00 15 00 16 07 00 17 07
第 #5 项 07 表示一个 Class 引用,00 17 #23 表示它引用了常量池的 #23 项
0000020 00 12 00 13 08 00 14 0a 00 15 00 16 07 00 17 07
第 #6 项 07 表示一个 Class 引用,00 18 #24 表示它引用了常量池的 #24 项
0000020 00 12 00 13 08 00 14 0a 00 15 00 16 07 00 17 07
0000040 00 18 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
第 #7 项 01 表示一个 utf8 串,00 06 代表长度 6 个字节,3c 69 6e 69 74 3e 是【<init>】
0000040 00 18 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
第 #8 项 01 表示一个 utf8 串,00 03 代表长度 3 个字节,28 29 56 是【()V】其实就是无参、无返回值
0000040 00 18 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
第 #9 项 01 表示一个 utf8 串,00 04 代表长度 4 个字节,43 6f 64 65 是【Code】
0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
第 #10 项 01 表示一个 utf8 串,00 0f 表示长度是 15 个字节,后面的字节表示字符串【LineNumberTable】
0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 04 6d 61 69
第 #11 项 01 表示一个 utf8 串,00 04 表示长度是 4 个字节,后面的字节表示字符串【main】。
0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 04 6d 61 69
0000120 6e 01 00 16 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67
第 #12 项 01 表示一个 utf8 串,00 16 表示长度是 20 个字节,后面的字节表示字符串【([Ljava/lang/String;)V】
0000120 6e 01 00 16 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67
0000140 2f 53 74 72 69 6e 67 3b 29 56 01 00 10 4d 65 74
第 #13 项 01 表示一个 utf8 串,00 10 表示长度是 16 个字节,后面的字节表示字符串【MethodParameters】
0000140 2f 53 74 72 69 6e 67 3b 29 56 01 00 10 4d 65 74
0000160 68 6f 64 50 61 72 61 6d 65 74 65 72 73 01 00 04
第 #14 项 01 表示一个 utf8 串,00 04 表示长度是 4 个字节,后面的字节表示字符串【args】
0000160 68 6f 64 50 61 72 61 6d 65 74 65 72 73 01 00 04
0000200 61 72 67 73 01 00 0a 53 6f 75 72 63 65 46 69 6c
第 #15 项 01 表示一个 utf8 串,00 0a 表示长度是 10 个字节,后面的字节表示字符串【SourceFile】
0000200 61 72 67 73 01 00 0a 53 6f 75 72 63 65 46 69 6c
0000220 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64 2e 6a
第 #16 项 01 表示一个 utf8 串,00 0f 表示长度是 15 个字节,后面的字节表示字符串【HelloWorld.java】。
0000220 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64 2e 6a
0000240 61 76 61 0c 00 07 00 08 07 00 19 0c 00 1a 00 1b
第 #17 项 0c 表示一个名字和类型常量,00 07 表示 name_index = #7,00 08 表示 descriptor_index = #8。
0000240 61 76 61 0c 00 07 00 08 07 00 19 0c 00 1a 00 1b
第 #18 项 07 对应类引用,00 19 表示 name_index = #25
0000240 61 76 61 0c 00 07 00 08 07 00 19 0c 00 1a 00 1b
第 #19 项 0c 表示一个名字和类型常量。00 1a 表示 name_index = #26。00 1b 表示 descriptor_index = #27。
0000240 61 76 61 0c 00 07 00 08 07 00 19 0c 00 1a 00 1b
第 #20 项 01 表示一个 utf8 串。00 0b 表示长度是 11 个字节。后面的字节表示字符串【hello world】。
0000260 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64 07 00
第 #21 项 07 表示类引用。00 1c 表示 name_index = #28。
0000260 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64 07 00
0000300 1c 0c 00 1d 00 1e 01 00 11 63 6f 6d 2f 6c 68 2f
第 #22 项 0c 表示一个名字和类型常量。00 1d 表示 name_index = #29。00 1e 表示 descriptor_index = #30。
0000300 1c 0c 00 1d 00 1e 01 00 11 63 6f 6d 2f 6c 68 2f
第 #23 项 01 表示一个 utf8 串。00 11 表示长度是 17 个字节。后面的字节表示字符串【com/lh/HelloWorld】。
0000300 1c 0c 00 1d 00 1e 01 00 11 63 6f 6d 2f 6c 68 2f
0000320 48 65 6c 6c 6f 57 6f 72 6c 64 01 00 10 6a 61 76
第 #24 项 01 表示一个 utf8 串。00 10 表示长度是 16 个字节。后面的字节表示字符串【java/lang/Object】。
0000320 48 65 6c 6c 6f 57 6f 72 6c 64 01 00 10 6a 61 76
0000340 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74 01 00 10
第 #25 项 01 表示一个 utf8 串。00 10 表示长度是 16 个字节。后面的字节表示字符串【java/lang/System】。
0000340 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74 01 00 10
0000360 6a 61 76 61 2f 6c 61 6e 67 2f 53 79 73 74 65 6d
第 #26 项 01 表示一个 utf8 串。00 03 表示长度是 3 个字节。后面的字节表示字符串【out】。
0000400 01 00 03 6f 75 74 01 00 15 4c 6a 61 76 61 2f 69
第 #27 项 01 表示一个 utf8 串。00 15 表示长度是 21 个字节。后面的字节表示字符串【Ljava/io/PrintStream;】
0000400 01 00 03 6f 75 74 01 00 15 4c 6a 61 76 61 2f 69
0000420 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 3b 01 00
第 #28 项 01 表示一个 utf8 串。00 13 表示长度是 19 个字节。后面的字节表示字符串【java/io/PrintStream】。
0000420 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 3b 01 00
0000440 13 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74 53 74
0000460 72 65 61 6d 01 00 07 70 72 69 6e 74 6c 6e 01 00
第 #29 项 01 表示一个 utf8 串。00 07 表示长度是 7 个字节。后面的字节表示字符串【println】。
0000460 72 65 61 6d 01 00 07 70 72 69 6e 74 6c 6e 01 00
第 #30 项 01 表示一个 utf8 串。00 15 表示长度是 21 个字节。后面的字节表示字符串【(Ljava/lang/String;)V】。
0000460 72 65 61 6d 01 00 07 70 72 69 6e 74 6c 6e 01 00
0000500 15 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72
0000520 69 6e 67 3b 29 56 00 21 00 05 00 06 00 00 00 00
{% note info %}
utf8 串值将字符串的十六进制转化为ASCII字符即可。
{% endnote %}
使用 javap -v HelloWorld.class 可以看到如下
java
Constant pool:
#1 = Methodref #6.#17 // java/lang/Object."<init>":()V
#2 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #20 // hello world
#4 = Methodref #21.#22 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #23 // com/lh/HelloWorld
#6 = Class #24 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 MethodParameters
#14 = Utf8 args
#15 = Utf8 SourceFile
#16 = Utf8 HelloWorld.java
#17 = NameAndType #7:#8 // "<init>":()V
#18 = Class #25 // java/lang/System
#19 = NameAndType #26:#27 // out:Ljava/io/PrintStream;
#20 = Utf8 hello world
#21 = Class #28 // java/io/PrintStream
#22 = NameAndType #29:#30 // println:(Ljava/lang/String;)V
#23 = Utf8 com/lh/HelloWorld
#24 = Utf8 java/lang/Object
#25 = Utf8 java/lang/System
#26 = Utf8 out
#27 = Utf8 Ljava/io/PrintStream;
#28 = Utf8 java/io/PrintStream
#29 = Utf8 println
#30 = Utf8 (Ljava/lang/String;)V
访问标识与继承信息
0000520 69 6e 67 3b 29 56 00 21 00 05 00 06 00 00 00 00
21 表示该类的访问标志,表示是一个公共的类(0x0020 + 0x0001)
访问标志
| 标志名 | 值 | 含义 |
|---|---|---|
ACC_PUBLIC |
0x0001 |
声明为 public;可以从包外访问。 |
ACC_FINAL |
0x0010 |
声明为 final;不允许有子类。 |
ACC_SUPER |
0x0020 |
当使用 invokespecial 指令调用父类方法时,需要对父类方法进行特殊处理。 |
ACC_INTERFACE |
0x0200 |
表示这是一个接口,而不是类。 |
ACC_ABSTRACT |
0x0400 |
声明为 abstract;不能被实例化。 |
ACC_SYNTHETIC |
0x1000 |
声明为合成的;源码中不存在,由编译器生成。 |
ACC_ANNOTATION |
0x2000 |
声明为注解类型。 |
ACC_ENUM |
0x4000 |
声明为枚举类型。 |
05 表示根据常量池中 #5 找到本类全限定名 com/lh/HelloWorld
0000520 69 6e 67 3b 29 56 00 21 00 05 00 06 00 00 00 00
06 表示根据常量池中 #6 找到父类全限定名 java/lang/Object
0000520 69 6e 67 3b 29 56 00 21 00 05 00 06 00 00 00 00
表示接口的数量,本类为 0
0000520 69 6e 67 3b 29 56 0 21 00 05 00 06 00 00 00 00
Field 信息
表示成员变量数量,本类为 0
0000520 69 6e 67 3b 29 56 0 21 00 05 00 06 00 00 00 00
描述符标识字符含义
| FieldType | 类型 | 含义 |
|---|---|---|
B |
byte |
有符号字节 |
C |
char |
Unicode 字符码点,位于基本多文种平面,使用 UTF-16 编码 |
D |
double |
双精度浮点数 |
F |
float |
单精度浮点数 |
I |
int |
整数 |
J |
long |
长整数 |
L ClassName ; |
引用类型 | ClassName 类的一个实例 |
S |
short |
有符号短整数 |
Z |
boolean |
true 或 false |
[ |
引用类型 | 一个数组维度 |
这部分有需要可以看看《深入理解Java虚拟机》
Method 信息
表示方法数量,本类为 2,一个构造函数+一个main方法
0000540 00 02 00 01 00 07 00 08 00 01 00 09 00 00 00 1d
一个方法由访问修饰符,名称,参数描述,方法属性数量,方法属性组成,结构如下
text
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
text
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
text
Code_attribute {
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
字段访问标志
| 标志名称 | 标志值 | 含义 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 字段是否 public |
| ACC_PRIVATE | 0x0002 | 字段是否 private |
| ACC_PROTECTED | 0x0004 | 字段是否 protected |
| ACC_STATIC | 0x0008 | 字段是否 static |
| ACC_FINAL | 0x0010 | 字段是否 final |
| ACC_VOLATILE | 0x0040 | 字段是否 volatile |
| ACC_TRANSIENT | 0x0080 | 字段是否 transient |
| ACC_SYNTHETIC | 0x1000 | 字段是否由编译器自动产生 |
| ACC_ENUM | 0x4000 | 字段是否 enum |
构造函数
0000540 00 02 00 01 00 07 00 08 00 01 00 09 00 00 00 1d
0000560 00 01 00 01 00 00 00 05 2a b7 00 01 b1 00 00 00
0000600 01 00 0a 00 00 00 06 00 01 00 00 00 03 00 09 00
0000620 0b 00 0c 00 02 00 09 00 00 00 25 00 02 00 01 00
0000640 00 00 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 01
- 红色代表访问修饰符(本类中是 public)
- 蓝色代表引用了常量池 #07 项作为方法名称 【
<init>】 - 绿色代表引用了常量池 #08 项作为方法参数描述【
()V】 - 紫色代表方法属性数量,本方法是 1
- 橙色代表方法属性
-
00 09表示引用了常量池 #09 项,发现是【Code】属性 --> 存放这个方法里面真正的 JVM 字节码内容 -
00 00 00 1d表示此属性长度是 29 --> 剩下的就是这里面的内容 -
00 01表示【操作数栈】最大深度 -
00 01表示【局部变量表】最大槽(slot)数 -
00 00 00 05表示方法体内的字节码长度,这里是 5 -
2a b7 00 01 b1是真正的字节码指令text2a aload_0 b7 00 01 invokespecial #1 b1 return -
00 00表示异常表长度,这里是没有异常 -
00 01表示方法子属性数量,注意是Code内部的属性,这里是 1 -
00 0a表示引用了常量池 #10 项,发现是【LineNumberTable】属性 --> 也按照attribute_info解析00 00 00 06表示此属性的总长度00 01表示【LineNumberTable】长度,一条行号记录00 00表示【字节码】行号,00 03表示【Java源码】行号,表示字节码偏移量 0 开始,对应 Java 源码第 3 行
-
main 方法
0000600 01 00 0a 00 00 00 06 00 01 00 00 00 03 00 09 00
0000620 0b 00 0c 00 02 00 09 00 00 00 25 00 02 00 01 00
0000640 00 00 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 01
0000660 00 0a 00 00 00 0a 00 02 00 00 00 05 00 08 00 06
0000700 00 0d 00 00 00 05 01 00 0e 00 00 00 01 00 0f 00
0000720 00 00 02 00 10
- 红色代表访问修饰符(public static)0x0008 + 0x0001
- 蓝色代表引用了常量池 #11 项作为方法名称 【
main】 - 绿色代表引用了常量池 #12 项作为方法参数描述【
([Ljava/lang/String;)V】--> 也就是public static void main (String[]) - 紫色代表方法属性数量,本方法是 2,分别是 Code 和 MethodParameters
- 橙色代表方法属性 Code 和 MethodParameters
- Code
-
00 09表示引用了常量池 #09 项,发现是【Code】属性 --> 存放这个方法里面真正的 JVM 字节码内容 -
00 00 00 25表示此属性长度是 37 -
00 02表示【操作数栈】最大深度 -
00 01表示【局部变量表】最大槽(slot)数 -
00 00 00 09表示方法体内的字节码长度,这里是 9 -
b2 00 02 12 03 b6 00 04 b1是真正的字节码指令textb2 00 02 getstatic #2 12 03 ldc #3 b6 00 04 invokevirtual #4 b1 return -
00 00表示异常表长度,这里是没有异常 -
00 01表示方法子属性数量,这里是 1 -
00 0a表示引用了常量池 #10 项,发现是【LineNumberTable】属性 --> 也按照attribute_info解析00 00 00 06表示此属性的总长度00 02表示【LineNumberTable】长度,两条行号记录00 00表示【字节码】行号,00 05表示【Java源码】行号,表示字节码偏移量 0 开始,对应 Java 源码第 5 行00 08表示【字节码】行号,00 06表示【Java源码】行号,表示字节码偏移量 8 开始,对应 Java 源码第 6 行
-
- MethodParameters
00 0d表示引用了常量池 #13 项,发现是【MethodParameters】属性 --> 是因为编译使用了javac -parameters来保留方法参数名00 00 00 05表示这个属性长度是 501表示parameters_count,表示这个方法有 1 个参数,也就是String[] args00 0e表示名字引用,这里是 #14,查常量池是 args00 00表示 access_flags,为 0 说明没有特殊标志,比如final什么的
- Code
附加属性
0000700 00 0d 00 00 00 05 01 00 0e 00 00 00 01 00 0f 00
0000720 00 00 02 00 10
00 01表示附加属性数量00 0f表示引用了常量池的 #15 项,即 【SourceFile】00 00 00 02表示此属性的长度00 10表示引用了常量池的 #16 项,即 【HelloWorld.java】
字节码指令
入门
接着上一节,研究一下两组字节码指令,一个是 public cn.itcast.jvm.t5.HelloWorld(); 构造方法的字节码指令
text
2a b7 00 01 b1
- 2a => aload_0 加载 slot 0 的局部变量,即 this,做为下面的 invokespecial 构造方法调用的参数
- b7 => invokespecial 预备调用构造方法,哪个方法呢?
- 00 01 引用常量池中 #1 项,即【 Method
java/lang/Object."<init>":()V】 - b1 表示返回
另一个是 public static void main(java.lang.String[]); 主方法的字节码指令
text
b2 00 02 12 03 b6 00 04 b1
- b2 => getstatic 用来加载静态变量,哪个静态变量呢?
- 00 02 引用常量池中 #2 项,即【Field
java/lang/System.out:Ljava/io/PrintStream;】 - 12 => ldc 加载参数,哪个参数呢?
- 03 引用常量池中 #3 项,即 【String
hello world】 - b6 => invokevirtual 预备调用成员方法,哪个方法呢?
- 00 04 引用常量池中 #4 项,即【Method
java/io/PrintStream.println:(Ljava/lang/String;)V】 - b1 表示返回

aload_0 这种形式只是为了方便我们观察,真正存储的是十六进制
javap 工具
自己分析类文件结构很麻烦,所以 Oracle 提供了 javap 工具来反编译 class 文件
java
(base) ice@jimodebingkeledeMac-mini lh % javap -v HelloWorld.class
Classfile /Users/ice/Desktop/cola/code/Java/JVM/src/main/java/com/lh/HelloWorld.class
Last modified 2026-5-15; size 469 bytes
MD5 checksum d8f8c6ac0069ceffea4add66e981a267
Compiled from "HelloWorld.java"
public class com.lh.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#17 // java/lang/Object."<init>":()V
#2 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #20 // hello world
#4 = Methodref #21.#22 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #23 // com/lh/HelloWorld
#6 = Class #24 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 MethodParameters
#14 = Utf8 args
#15 = Utf8 SourceFile
#16 = Utf8 HelloWorld.java
#17 = NameAndType #7:#8 // "<init>":()V
#18 = Class #25 // java/lang/System
#19 = NameAndType #26:#27 // out:Ljava/io/PrintStream;
#20 = Utf8 hello world
#21 = Class #28 // java/io/PrintStream
#22 = NameAndType #29:#30 // println:(Ljava/lang/String;)V
#23 = Utf8 com/lh/HelloWorld
#24 = Utf8 java/lang/Object
#25 = Utf8 java/lang/System
#26 = Utf8 out
#27 = Utf8 Ljava/io/PrintStream;
#28 = Utf8 java/io/PrintStream
#29 = Utf8 println
#30 = Utf8 (Ljava/lang/String;)V
{
public com.lh.HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
MethodParameters:
Name Flags --> 这个是表示特殊标志,比如 final
args
}
SourceFile: "HelloWorld.java"
图解方法执行流程
源代码
java
package com.lh;
public class Demo3_1 {
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}
字节码
java
Classfile /Users/ice/Desktop/cola/code/Java/JVM/src/main/java/com/lh/Demo3_1.class
Last modified 2026-5-15; size 439 bytes
MD5 checksum e8b3cd885bb186f18392168a591b0757
Compiled from "Demo3_1.java"
public class com.lh.Demo3_1
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#16 // java/lang/Object."<init>":()V
#2 = Class #17 // java/lang/Short
#3 = Integer 32768
#4 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #20.#21 // java/io/PrintStream.println:(I)V
#6 = Class #22 // com/lh/Demo3_1
#7 = Class #23 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 main
#13 = Utf8 ([Ljava/lang/String;)V
#14 = Utf8 SourceFile
#15 = Utf8 Demo3_1.java
#16 = NameAndType #8:#9 // "<init>":()V
#17 = Utf8 java/lang/Short
#18 = Class #24 // java/lang/System
#19 = NameAndType #25:#26 // out:Ljava/io/PrintStream;
#20 = Class #27 // java/io/PrintStream
#21 = NameAndType #28:#29 // println:(I)V
#22 = Utf8 com/lh/Demo3_1
#23 = Utf8 java/lang/Object
#24 = Utf8 java/lang/System
#25 = Utf8 out
#26 = Utf8 Ljava/io/PrintStream;
#27 = Utf8 java/io/PrintStream
#28 = Utf8 println
#29 = Utf8 (I)V
{
public com.lh.Demo3_1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #3 // int 32768
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 10
line 9: 17
}
SourceFile: "Demo3_1.java"
流程
.class 文件读取到 JVM 流程如下
常量池载入运行时常量池

把 class 文件中的常量池加载到运行时常量池。
这里看字节码可以看到,10 这个值是直接 bipush 的,没有放入常量池,但是 Short.MAX_VALUE + 1 这个值是存到常量池了
普通 int 字面量加载规则大概是:
java
-1 ~ 5 // 用 iconst_m1 ~ iconst_5
-128 ~ 127 // 用 bipush
-32768 ~ 32767 // 用 sipush
其他 int 常量 // 用 ldc,从常量池加载
所以可以看到 -32768 ~ 32767 都差不多是写到字节码里面,不放入常量池,但是其他范围的都要放入常量池
{% note info %}
运行时常量池是属于方法区的哈,这里只是为了方便演示,单独拿出来了
{% endnote %}
方法字节码载入方法区

main 线程开始运行,分配栈帧内存

图里面绿色的代表局部变量表,浅蓝色代表操作数栈,大小是多少呢?stack=2, locals=4,这两个来决定
执行引擎开始执行字节码
bipush 10
- 将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令还有
- sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)--> sipush bipush 都是从字节码里面取的值,后面直接跟的是值,
bipush后面的值是一个字节(byte),sipush后面需要跟两个字节(short) - ldc 将一个 int 压入操作数栈 --> 把常量池的一个数压入操作数栈
- ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
- 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池
- sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)--> sipush bipush 都是从字节码里面取的值,后面直接跟的是值,

istore_1
将操作数栈顶数据弹出,存入局部变量表的 slot 1,1 指代槽位编号

{% note info %}
这里 bipush 10 + istore_1 对应 Java 源代码的 int a = 10;
{% endnote %}
ldc #3
从常量池加载 #3 数据到操作数栈
- 注意
Short.MAX_VALUE是32767,所以32768 = Short.MAX_VALUE + 1实际是在编译期间计算好的

istore_2

iload_1

iload_2

iadd

istore_3

getstatic #4


是把 System.out 的引入放入操作数栈
iload_3

invokevirtual #5
- 找到常量池 #5 项
- 定位到方法区 java/io/PrintStream.println:(I)V 方法
- 生成新的栈帧(分配 locals、stack等)--> 因为是方法,所以会生成新的栈帧
- 传递参数 32778,执行新栈帧中的字节码,打印内容

- 执行完毕,弹出栈帧(操作数和引用)
- 由于
println返回 void,不会压入返回值,因此 main 操作数栈为空

return
- 完成 main 方法调用,弹出 main 栈帧
- 程序结束
练习-分析a++
java
public class Test {
public static void main(String[] args) {
int a = 10;
int b = a++ + ++a + a--;
System.out.println(a); // 11
System.out.println(b); // 34
}
}
怎么计算呢?从左到右运算
第一,a++ 是 10,计算完后 a 是 11
第二,++a 是 12,计算完后 a 是 12
第三,a-- 是 12,计算完后 a 是 11
加和就是 10 + 12 + 12 = 34
然后我们从字节码角度分析流程
{% hideToggle 字节码 %}
java
Classfile /Users/ice/Desktop/cola/code/Java/JVM/src/main/java/com/lh/Demo3_1.class
Last modified 2026-5-16; size 428 bytes
MD5 checksum 362c99d08f48208e683279c910dc216a
Compiled from "Demo3_1.java"
public class com.lh.Demo3_1
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#14 // java/lang/Object."<init>":()V
#2 = Fieldref #15.#16 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #17.#18 // java/io/PrintStream.println:(I)V
#4 = Class #19 // com/lh/Demo3_1
#5 = Class #20 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 SourceFile
#13 = Utf8 Demo3_1.java
#14 = NameAndType #6:#7 // "<init>":()V
#15 = Class #21 // java/lang/System
#16 = NameAndType #22:#23 // out:Ljava/io/PrintStream;
#17 = Class #24 // java/io/PrintStream
#18 = NameAndType #25:#26 // println:(I)V
#19 = Utf8 com/lh/Demo3_1
#20 = Utf8 java/lang/Object
#21 = Utf8 java/lang/System
#22 = Utf8 out
#23 = Utf8 Ljava/io/PrintStream;
#24 = Utf8 java/io/PrintStream
#25 = Utf8 println
#26 = Utf8 (I)V
{
public com.lh.Demo3_1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 10
2: istore_1
3: iload_1
4: iinc 1, 1
7: iinc 1, 1
10: iload_1
11: iadd
12: iload_1
13: iinc 1, -1
16: iadd
17: istore_2
18: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
21: iload_1
22: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
25: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
28: iload_2
29: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
32: return
LineNumberTable:
line 5: 0
line 6: 3
line 8: 18
line 9: 25
line 10: 32
}
SourceFile: "Demo3_1.java"
{% endhideToggle %}
- 注意 iinc 指令是直接在局部变量 slot 上进行运算
- a++ 和 ++a 的区别是先执行 iload 还是 先执行 iinc
条件判断
| 指令 | 助记符 | 含义 |
|---|---|---|
| 0x99 | ifeq | 判断是否 == 0 |
| 0x9a | ifne | 判断是否 != 0 |
| 0x9b | iflt | 判断是否 < 0 |
| 0x9c | ifge | 判断是否 >= 0 |
| 0x9d | ifgt | 判断是否 > 0 |
| 0x9e | ifle | 判断是否 <= 0 |
| 0x9f | if_icmpeq | 两个int是否 == |
| 0xa0 | if_icmpne | 两个int是否 != |
| 0xa1 | if_icmplt | 两个int是否 < |
| 0xa2 | if_icmpge | 两个int是否 >= |
| 0xa3 | if_icmpgt | 两个int是否 > |
| 0xa4 | if_icmple | 两个int是否 <= |
| 0xa5 | if_acmpeq | 两个引用是否 == |
| 0xa6 | if_acmpne | 两个引用是否 != |
| 0xc6 | ifnull | 判断是否 == null |
| 0xc7 | ifnonnull | 判断是否 != null |
- byte,short,char 都会按 int 比较,因为操作数栈都是 4 字节
- goto 用来进行跳转到指定行号的字节码
源码
java
public static void main(String[] args) {
int a = 0;
if(a == 0) {
a = 10;
} else {
a = 20;
}
}
字节码
text
0: iconst_0
1: istore_1
2: iload_1
3: ifne 12
6: bipush 10
8: istore_1
9: goto 15
12: bipush 20
14: istore_1
15: return
那 long、float、double 呢?
因为他们不是占用一个栈槽,所以有专门的指令,int 那种类型有直接判断是否相等、大于小于的指令,但是 long 不是
java
6: lload_1
7: lload_3
8: lcmp
9: ifle 19
lload 把数据压入操作数栈,lcmp 比较两个数,栈上压入结果 -1/0/1,然后调用 ifle 来判断结果
同理 float 和 double 分别用的是 fcmpl/fcmpg、dcmpl/dcmpg
循环控制指令
while
java
int a = 0;
while (a < 10) {
a++;
}
对应字节码
text
0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return
do while
java
int a = 0;
do {
a++;
} while (a < 10);
对应字节码
text
0: iconst_0
1: istore_1
2: iinc 1, 1
5: iload_1
6: bipush 10
8: if_icmplt 2
11: return
for
java
for (int i = 0; i < 10; i++) {
}
字节码
text
0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return
{% note success %}
比较 while 和 for 的字节码,能发现它们是一模一样的
{% endnote %}
分析 i=i++
java
int i = 1;
i = i++;
System.out.println(i); // 1
对应字节码
txt
0: iconst_1 # 取常数 1 到操作数栈
1: istore_1 # 把操作数栈顶的值pop到 slot1(取出来并且删除了)
2: iload_1 # 把 slot1 的值加载到操作数栈 --> 操作数栈栈顶值为 1
3: iinc 1, 1 # 对 slot1 的值加 1 操作
6: istore_1 # 把操作数栈顶的值pop到 slot1(取出来并且删除了)
iload_1 + iinc 1,1 是对应的 i++ 操作
构造方法
有两种构造方法,一个类构造方法,一个实例的构造方法
<cinit>()V
用来静态变量方法的构造
java
static int i = 10;
static {
i = 20;
}
static {
i = 30;
}
编译器会按从上至下的顺序,收集所有 static 静态代码块和静态成员赋值的代码,合并为一个特殊的方法 <cinit>()V
text
0: bipush 10
2: putstatic #2 // Field i:I
5: bipush 20
7: putstatic #2 // Field i:I
10: bipush 30
12: putstatic #2 // Field i:I
15: return
<cinit>()V 方法会在类加载的初始化阶段被调用
既然说从上到下的顺序,我们测试一下,代码顺序调整如下
java
static {
i = 20;
}
static {
i = 30;
}
static int i = 10;
再编译、反编译看字节码
text
0: bipush 20
2: putstatic #2 // Field i:I
5: bipush 30
7: putstatic #2 // Field i:I
10: bipush 10
12: putstatic #2 // Field i:I
15: return
还真是,变量 i 的结果就是 10 了
{% note info %}
在执行第一个代码块的时候,JVM知道静态变量 i 是存在的,并且类型是 int,只是还没初始化,所以静态代码块是可以执行的
{% endnote %}
<init>()V
java
public class Demo3_1 {
private String a = "s1";
{
b = 20;
}
private int b = 10;
{
a = "s2";
}
public Demo3_1(String a, int b) {
this.a = a;
this.b = b;
}
public static void main(String[] args) {
Demo3_1 d = new Demo3_1("s3", 30);
System.out.println(d.a); // s3
System.out.println(d.b); // 30
}
}
编译器会按从上至下的顺序,收集所有 {} 代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后
java
public com.lh.Demo3_1(java.lang.String, int); // 生成的新的构造方法
descriptor: (Ljava/lang/String;I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String s1
7: putfield #3 // Field a:Ljava/lang/String;
10: aload_0
11: bipush 20
13: putfield #4 // Field b:I
16: aload_0
17: bipush 10
19: putfield #4 // Field b:I
22: aload_0
23: ldc #5 // String s2
25: putfield #3 // Field a:Ljava/lang/String;
28: aload_0 // --------------------------------- 原始构造方法
29: aload_1
30: putfield #3 // Field a:Ljava/lang/String;
33: aload_0
34: iload_2
35: putfield #4 // Field b:I
// --------------------------------- 原始构造方法
38: return
{% note success %}
类加载阶段(静态成员)
- 父类静态成员和静态代码块
- 子类静态成员和静态代码块
对象创建阶段
- 父类实例成员变量 + 实例代码块(按出现顺序)
- 父类构造方法
- 子类实例成员变量 + 实例代码块(按出现顺序)
- 子类构造方法
{% endnote %}
方法调用
看看不同的方法调用所对应的字节码指令
java
public class Demo3_1 {
public Demo3_1() {}
private void test1() { }
private final void test2() { }
public void test3() { }
public static void test4() { }
public static void main(String[] args) {
Demo3_1 d = new Demo3_1();
d.test1();
d.test2();
d.test3();
d.test4();
Demo3_1.test4();
}
}
对应字节码
java
0: new #2 // class com/lh/Demo3_1
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: aload_1
21: pop
22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V
28: return
- new 是创建【对象】,给对象分配堆内存,执行成功会将【对象引用】压入操作数栈
- dup 是复制操作数栈栈顶的内容,本例即为【对象引用】,为什么需要两份引用呢,一个是要配合 invokespecial 调用该对象的构造方法
"<init>":()V(会消耗掉栈顶一个引用),另一个要配合 astore_1 赋值给局部变量 - 最终方法(final),私有方法(private),构造方法都是由 invokespecial 指令来调用,属于静态绑定(因为这些方法一定不会发生多态)
- 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态(因为在编译器还不知道这个方法是不是重写的父类之类的)
- 成员方法与静态方法调用的另一个区别是,执行方法前是否需要【对象引用】
- 比较有意思的是
d.test4();是通过【对象引用】调用一个静态方法,可以看到在调用 invokestatic 之前执行了 pop 指令,把【对象引用】从操作数栈弹掉了😂,所以通过对象调用静态方法会产生两个没必要的字节码指令 - 还有一个执行 invokespecial 的情况是通过 super 调用父类方法
多态原理
java
/**
* 演示多态原理,注意加上下面的 JVM 参数,禁用指针压缩
* -XX:-UseCompressedOops -XX:-UseCompressedClassPointers
*/
public class Demo3_10 {
public static void test(Animal animal) {
animal.eat();
System.out.println(animal.toString());
}
public static void main(String[] args) throws IOException {
test(new Cat());
test(new Dog());
System.in.read();
}
}
abstract class Animal {
public abstract void eat();
@Override
public String toString() {
return "我是" + this.getClass().getSimpleName();
}
}
class Dog extends Animal {
@Override
public void eat() {
System.out.println("啃骨头");
}
}
class Cat extends Animal {
@Override
public void eat() {
System.out.println("吃鱼");
}
}
运行代码
停在 System.in.read() 方法上,然后运行 jps 获取该Java进程 id
运行 HSDB 工具
需要设置 JAVA_HOME 变量
cmd
sudo java -cp $JAVA_HOME/lib/sa-jdi.jar sun.jvm.hotspot.HSDB
进入图形界面后 File->Attach to HotSpot process,输入进程id
查找某个对象
打开 Tools -> Find Object By Query
输入 select from cn.itcast.jvm.t3.bytecode.Dog d 点击 Execute 执行

因为只创建了一个对象,所以就是这个对象,它的地址
查找对象内存结构
点击超链接可以看到对象的内存结构,此对象没有任何属性,因此只有对象头的 16 字节,前 8 字节是 MarkWord,后 8 字节就是对象的 Class 指针但目前看不到它的实际地址

查看对象 Class 的内存地址
可以通过 Windows -> Console 进入命令行模式,执行
cmd
mem 0x00000001299b4978 2
mem 有两个参数,参数 1 是对象地址,参数 2 是查看 2 行(即 16 字节)
结果中第二行 0x000000001b7d4028 即为 Class 的内存地址
查看类的 vtable
方法1:Alt+R 进入 Inspector 工具,输入刚才的 Class 内存地址,看到如下界面

方法2:或者 Tools -> Class Browser 输入 Dog 查找,可以得到相同的结果

无论通过哪种方法,都可以找到 Dog Class 的 vtable 长度为 6,意思就是 Dog 类有 6 个虚方法(多态相关的,final,static,private 不会列入)
那么这 6 个方法都是谁呢?从 Class 的起始地址开始算,偏移 0x1b8 就是 vtable 的起始地址(定义好的结构),进行计算得到:
text
0x000000001b7d4028
1b8 +
---------------------
0x000000001b7d41e0
通过 Windows -> Console 进入命令行模式,执行
cmd
mem 0x000000001b7d41e0 6
0x000000001b7d41e0: 0x000000001b3d1b10
0x000000001b7d41e8: 0x000000001b3d15e8
0x000000001b7d41f0: 0x000000001b7d35e8
0x000000001b7d41f8: 0x000000001b3d1540
0x000000001b7d4200: 0x000000001b3d1678
0x000000001b7d4208: 0x000000001b7d3fa8 --> 指向实际的方法调用
就得到了 6 个虚方法的入口地址,左边是 vtable 的位置内存地址,右边是方法的入口地址
验证方法地址
通过 Tools -> Class Browser 查看每个类的方法定义,比较可知
java
Dog - public void eat() @0x000000001b7d3fa8
Animal - public java.lang.String toString() @0x000000001b7d35e8;
Object - protected void finalize() @0x000000001b3d1b10;
Object - public boolean equals(java.lang.Object) @0x000000001b3d15e8;
Object - public native int hashCode() @0x000000001b3d1540;
Object - protected native java.lang.Object clone() @0x000000001b3d1678;
对号入座,发现
eat()方法是 Dog 类自己的toString()方法是继承 String 类的finalize(),equals(),hashCode(),clone()都是继承 Object 类的
总结

当执行 invokevirtual 指令时,
- 先通过栈帧中的对象引用找到对象
- 分析对象头,找到对象的实际 Class
- Class 结构中有 vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了
- 查表得到方法的具体地址
- 执行方法的字节码
异常处理
try-catch
java
public class Demo_11 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
}
}
}
字节码文件
java
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 12
8: astore_2
9: bipush 20
11: istore_1
12: return
Exception table:
from to target type
2 5 8 Class java/lang/Exception
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 8
locals = [ class "[Ljava/lang/String;", int ]
stack = [ class java/lang/Exception ]
frame_type = 3 /* same */
- 可以看到多出来一个 Exception table 的结构,
[from, to)是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号 - 8 行的字节码指令 astore_2 是将异常对象引用存入局部变量表的 slot 2 位置
多个 single-catch 块的情况
java
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (ArithmeticException e) {
i = 30;
} catch (NullPointerException e) {
i = 40;
} catch (Exception e) {
i = 50;
}
}
java
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 26
8: astore_2
9: bipush 30
11: istore_1
12: goto 26
15: astore_2
16: bipush 40
18: istore_1
19: goto 26
22: astore_2
23: bipush 50
25: istore_1
26: return
Exception table:
from to target type
2 5 8 Class java/lang/ArithmeticException
2 5 15 Class java/lang/NullPointerException
2 5 22 Class java/lang/Exception
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/ArithmeticException;
16 3 2 e Ljava/lang/NullPointerException;
23 3 2 e Ljava/lang/Exception;
0 27 0 args [Ljava/lang/String;
2 25 1 i I
因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用
multi-catch 的情况
java
public class Demo_11 {
public static void main(String[] args) {
try {
Method test = Demo_11.class.getMethod("test");
test.invoke(null);
} catch (NoSuchMethodException | IllegalAccessException |
InvocationTargetException e) { // 多个异常
e.printStackTrace();
}
}
public static void test() {
System.out.println("ok");
}
}
字节码
java
Code:
stack=3, locals=2, args_size=1
0: ldc #7 // class com/lh/Demo_11
2: ldc #9 // String test
4: iconst_0
5: anewarray #11 // class java/lang/Class
8: invokevirtual #13 // Method java/lang/Class.getMethod:(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;
11: astore_1
12: aload_1
13: aconst_null
14: iconst_0
15: anewarray #2 // class java/lang/Object
18: invokevirtual #17 // Method java/lang/reflect/Method.invoke:(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
21: pop
22: goto 30
25: astore_1
26: aload_1
27: invokevirtual #29 // Method java/lang/ReflectiveOperationException.printStackTrace:()V
30: return
Exception table:
from to target type
0 22 25 Class java/lang/NoSuchMethodException
0 22 25 Class java/lang/IllegalAccessException
0 22 25 Class java/lang/reflect/InvocationTargetException
LocalVariableTable:
Start Length Slot Name Signature
12 10 1 test Ljava/lang/reflect/Method;
26 4 1 e Ljava/lang/ReflectiveOperationException;
0 31 0 args [Ljava/lang/String;
其实就是 target 目标是一样的,并且你看 LocalVariableTable 还很智能,test 和 e 复用一个槽位,因为这两个变量的生命周期不重叠,test 在 try 块里面,e 在 catch 块里面,JVM 编译器会智能复用同一个 slot 来节省栈帧空间,只要类型匹配(比如都是引用)就可以。

finally
java
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
}
java
Code:
stack=1, locals=4, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: bipush 30
7: istore_1
8: goto 27
11: astore_2 // 存异常到 slot2
12: bipush 20
14: istore_1
15: bipush 30
17: istore_1
18: goto 27
21: astore_3 // catch any -> slot3
22: bipush 30
24: istore_1
25: aload_3 // <- slot 3
26: athrow // throw
27: return
Exception table:
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any // 除了 Exception 外其他都异常类型,比如 Error
11 15 21 any // catch 块里面的也要捕获
LocalVariableTable:
Start Length Slot Name Signature
12 3 2 e Ljava/lang/Exception;
0 28 0 args [Ljava/lang/String;
2 26 1 i I
-
finally 块的内容都会复制到
try块或者catch块后面
try块和catch块后面都加了java22: bipush 30 24: istore_1 -
catch块中如果有异常还会进入finally块执行完后再抛出异常,这就是捕获范围外的异常了(不是 Exception 了)
finally 中出现了 return
java
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
public static int test() {
try {
return 10;
} finally {
return 20;
}
}
对应字节码
java
public static int test();
descriptor: ()I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=0
0: bipush 10 // <- 10 放入栈顶
2: istore_0 // 10 -> slot 0 (从栈顶移除了)
3: bipush 20 // <- 20 放入栈顶
5: ireturn // 返回栈顶 int(20)
6: astore_1 // catch any -> slot 1
7: bipush 20 // <- 20 放入栈顶
9: ireturn // 返回栈顶 int(20)
Exception table:
from to target type
0 3 6 any
- 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以 finally 的为准
- 字节码中第 2 行,
istore_0,看下个例子 - 跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如果在 finally 中出现了 return,会吞掉异常😱😱😱,可以试一下下面的代码

异常没有了,i = 1 / 0 那个异常被吞掉了,结果正常返回!!!(里肯定不捕获了,捕获了异常不就自己处理了,正常来说应该如下

finally 对返回值的影响
java
public class Demo_11 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}
}
对应字节码如下
java
Code:
stack=1, locals=3, args_size=0
0: bipush 10 // <- 10 放入栈顶
2: istore_0 // 10 -> i
3: iload_0 // <- i(10)
4: istore_1 // 10 -> slot 1,暂存至 slot 1,目的是为了固定返回值
5: bipush 20 // <- 20 放入栈顶
7: istore_0 // 20 -> i
8: iload_1 // <- slot 1(10) 载入 slot 1 暂存的值
9: ireturn // 返回栈顶的 int(10)
10: astore_2
11: bipush 20
13: istore_0
14: aload_2
15: athrow
Exception table:
from to target type
3 5 10 any
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I
可以看到 try 块要返回的内容会先存起来一份(istore_1),因为要先执行完 finally 块才能 return。这里也因为在 finally 里面没有 return,所以可以正常 throw 异常。
synchronized
java
public class Demo_11 {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
}
对应字节码
java
Code:
stack=2, locals=4, args_size=1
0: new #2 // new Object
3: dup
4: invokespecial #1 // invokespecial <init>:()V
7: astore_1 // lock引用 -> lock
8: aload_1 // <- lock (synchronized开始)
9: dup
10: astore_2 // lock引用 -> slot 2
11: monitorenter // monitorenter(lock引用)
12: getstatic #3 // <- System.out
15: ldc #4 // <- "ok"
17: invokevirtual #5 // invokevirtual println: (Ljava/lang/String;)V
20: aload_2 // <- slot 2(lock引用)
21: monitorexit // monitorexit(lock引用)
22: goto 30
25: astore_3 // any -> slot 3
26: aload_2 // <- slot 2(lock引用)
27: monitorexit // monitorexit(lock引用)
28: aload_3
29: athrow
30: return
Exception table:
from to target type
12 22 25 any
25 28 25 any
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 lock Ljava/lang/Object;
lock引用会被复制一份,第一份用来加锁,第二份用来解锁- 如果有异常会先解锁,再抛出异常
{% note info %}
如果把锁加在方法上面,从字节码层面看不出来,不显示加锁解锁过程。
{% endnote %}
比如
java
public static synchronized void say() {
System.out.println("say");
}
对应字节码不包含加锁解锁信息
java
public static synchronized void say();
descriptor: ()V
flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=0, args_size=0
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #21 // String say
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
编译器处理
所谓的 语法糖 ,其实就是指 java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利
注意,以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件 jclasslib 等工具。另外,编译器转换的结果直接就是 class 字节码,只是为了便于阅读,给出了 几乎等价 的 java 源码方式,并不是编译器还会转换出中间的 java 源码,切记。
默认构造器
java
public class Candy1 {
}
编译成 class 后的代码
java
public class Candy1 {
// 这个无参构造是编译器帮助我们加上的
public Candy1() {
super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":()V
}
}
自动拆装箱
这个特性是 JDK5 开始加入的,代码片段 1
java
public class Candy2 {
public static void main(String[] args) {
Integer x = 1;
int y = x;
}
}
但是这段代码在JDK 5之前是无法编译通过的,必须改写为如下形式,代码片段 2
java
public class Candy02 {
public static void main(String[] args) {
Integer x = Integer.valueOf(1);
int y = x.intValue();
}
}
显然之前版本的代码太麻烦了,需要在基本类型和包装类型之间相互转换(尤其是集合类中的操作都是包装类型),因此这些转换的事情在JDK 5以后都由编译器在编译阶段完成。即代码片段1都会在编译阶段转换成代码片段2
泛型集合取值
泛型也是JDK 5开始加入的特性,但Java在编译泛型后会执行泛型擦除的动作,即泛型信息在编译为字节码后就丢失了,实际的类型都当做Object类型来处理
java
public class Candy03 {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10); // 实际调用的是 List.add(Object e)
Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index);
}
}
所以在取值时,编译器真正生成的字节码中,还需要额外做一个类型转换的操作
java
// 需要将 Object 转为 Integer
Integer x = (Integer)list.get(0);
如果前面的 x 遍历修改为 int 基本类型,那么最终生成的字节码为
java
int x = ((Integer)list.get(0)).intValue();
还好这些麻烦事都不用自己做
擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 仍然保留了方法参数泛型的信息(看下面的 LocalVariableTypeTable 的 slot1 能看到 list 变量类型)
从下面字节码的第 14 行,我们可以清楚的看到 add 方法其实添加的是 Object 类型对象
从下面字节码的第 22 行,我们可以清楚的看到 get 方法的返回值也是 Object 类型对象
同时第 27 行是将类型强制转换为 Integer
java
public com.demo.Candy03();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/demo/Candy03;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: bipush 10
11: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
14: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
19: pop
20: aload_1
21: iconst_0
22: invokeinterface #6, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
27: checkcast #7 // class java/lang/Integer
30: astore_2
31: return
LineNumberTable:
line 8: 0
line 9: 8
line 10: 20
line 11: 31
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 args [Ljava/lang/String;
8 24 1 list Ljava/util/List;
31 1 2 x Ljava/lang/Integer;
LocalVariableTypeTable:
Start Length Slot Name Signature
8 24 1 list Ljava/util/List<Ljava/lang/Integer;>;
局部变量的泛型信息用反射拿不到,只能够获取到方法类型参数的泛型和方法返回值泛型的信息
java
public class Candy03 {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Method test = Candy03.class.getMethod("test", List.class, Map.class);
Type[] types = test.getGenericParameterTypes(); // 获取泛型参数类型信息
for (Type type : types) {
if (type instanceof ParameterizedType) { // 判断是不是泛型类型
ParameterizedType parameterizedType = (ParameterizedType) type;
System.out.println("原始类型 - " + parameterizedType.getRawType());
Type[] arguments = parameterizedType.getActualTypeArguments();
for (int i = 0; i < arguments.length; i++) {
System.out.printf("泛型参数[%d] - %s\n", i, arguments[i]);
}
}
}
System.out.println("返回类型 - " + test.getGenericReturnType());
}
public Set<Integer> test(List<String> list, Map<Integer, Object> map) {
return null;
}
}
输出
text
原始类型 - interface java.util.List
泛型参数[0] - class java.lang.String
原始类型 - interface java.util.Map
泛型参数[0] - class java.lang.Integer
泛型参数[1] - class java.lang.Object
返回类型 - java.util.Set<java.lang.Integer>
可变参数
可变参数也是JDK 5开始加入的新特性,示例代码如下
java
public class Candy04 {
public static void main(String[] args) {
foo("hello", "world");
}
private static void foo(String... args) {
String[] array = args;
System.out.println(array);
}
}
可变参数 String... args 其实是一个 String[] args,同样Java编译器会在编译期间将上述代码转换为
java
public class Candy04 {
public static void main(String[] args) {
foo(new String[]{"hello", "world"});
}
public static void foo(String[] args) {
String[] array = args; // 直接赋值
System.out.println(array);
}
}
{% note warning %}
如果调用 foo() 时没有提供任何参数,那么则等价为 foo(new String),创建了一个空的数组,而不是传一个 null 进去
{% endnote %}
foreach循环
仍然是JDK 5开始引入的语法糖,数组的循环
java
public class Candy05 {
public static void main(String[] args) {
int[] array = {1, 2, 3, 4, 5}; // 数组的赋初值的简化,也是语法糖 new int[]{1, 2, 3, 4, 5}
for (int a : array) {
System.out.println(a);
}
}
}
会被编译器转换为
java
public class Candy05 {
public Candy05() {
}
public static void main(String[] args) {
int[] array = new int[]{1, 2, 3, 4, 5};
for(int i = 0; i < array.length; ++i) {
int e = array[i];
System.out.println(e); // 按下标便利循环
}
}
而集合的循环
java
public class Candy06 {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
for (Integer integer : list) {
System.out.println(integer);
}
}
}
实际上会被编译器转换为对迭代器的调用
java
public class Candy06 {
public Candy06() {
}
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Iterator iterator = list.iterator();
while (iterator.hasNext()){
Integer next = (Integer) iterator.next();
System.out.println(next);
}
}
}
{% note warning %}
foreach循环写法,能够配合数组,以及所有实现了 Iterable 接口的集合类一起使用,其中 Iterable 用来获取集合的迭代器 Iterator
{% endnote %}
switch字符串
从JDK 7开始,switch可以作用于字符串和枚举类,这个功能其实也是语法糖,例如
java
public class Candy07 {
public static void choose(String str) {
switch (str) {
case "hello": {
System.out.println("h");
break;
}
case "world": {
System.out.println("w");
break;
}
}
}
}
{% note warning %}
swtich配合Spring和枚举使用时,变量不能为null,原因分析完语法糖转换后的代码,自然就清楚了
{% endnote %}
会被编译器转换为
java
public class Candy07 {
public Candy07() {
}
public static void choose(String str) {
byte x = -1;
switch (str.hashCode()) {
case 99162322: // hello 的 hashCode
if (str.equals("hello")) {
x = 0;
}
break;
case 113318802: // world 的 hashCode
if (str.equals("world")) {
x = 1;
}
}
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}
可以看到,执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串转换为相应 byte 类型,第二遍才是利用 byte 进行比较
那为什么第一遍既要比较 hashCode 又利用 equals 比较呢?hashCode 是为了提高效率,减少可能的比较,而 equals 是为了防止哈希冲突,例如 BM 和 C. 这两个字符串的 hashCode 值都是 2123,例如下面的代码
java
public static void choose(String str) {
switch (str) {
case "BM": {
System.out.println("h");
break;
}
case "C.": {
System.out.println("w");
break;
}
}
}
会被编译器转换为
java
public static void choose(String str) {
byte x = -1;
switch (str.hashCode()) {
case 2123: // hashCode 值可能相同,需要进一步用 equals 比较
if (str.equals("C.")) {
x = 1;
} else if (str.equals("BM")) {
x = 0;
}
default:
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}
switch枚举
switch枚举的例子(是 switch 中如果是对枚举,那么 switch(枚举) 解糖后的内容,原始代码
java
enum Sex {
MALE, FEMALE
}
java
public static void foo(Sex sex){
switch (sex){
case MALE:
System.out.println("男");
break;
case FEMALE:
System.out.println("女");
break;
}
}
转换后的代码
java
public class Candy08 {
/**
* 定义一个合成类(仅 jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
*/
static class Candy08$1 {
// 数组大小即为枚举元素个数,里面存储case用来对比的数字
static int[] $SwitchMap$com$lh$Sex = new int[2];
static {
$SwitchMap$com$lh$Sex[Sex.MALE.ordinal()] = 1;
$SwitchMap$com$lh$Sex[Sex.FEMALE.ordinal()] = 2;
}
}
public static void foo(Sex sex) {
int x = Candy08$1.$SwitchMap$com$lh$Sex[sex.ordinal()];
switch (x) {
case 1:
System.out.println("男");
break;
case 2:
System.out.println("女");
break;
}
}
}
枚举类
JDK 7 新增了枚举类,以前面的性别枚举为例
java
enum Sex {
MALE, FEMALE
}
转换后的代码
java
public final class Sex extends Enum<Sex> {
public static final Sex MALE;
public static final Sex FEMALE;
private static final Sex[] $VALUES;
static {
MALE = new Sex("MALE", 0);
FEMALE = new Sex("FEMALE", 1);
$VALUES = new Sex[]{MALE, FEMALE};
}
/**
* Sole constructor. Programmers cannot invoke this constructor.
* It is for use by code emitted by the compiler in response to
* enum type declarations.
*
* @param name - The name of this enum constant, which is the identifier
* used to declare it.
* @param ordinal - The ordinal of this enumeration constant (its position
* in the enum declaration, where the initial constant is
* assigned
*/
private Sex(String name, int ordinal) {
super(name, ordinal);
}
public static Sex[] values() {
return (Sex[])$VALUES.clone();
}
public static Sex valueOf(String name) {
return (Sex)Enum.valueOf(Sex.class, name);
}
}
Sex被声明为一个final类,它继承了Enum<Sex>类,Enum是Java中定义枚举的抽象类。MALE和FEMALE是Sex类的两个枚举值,它们被定义为静态常量。- 除此之外,还有一个私有的、final 的
Sex类型数组$VALUES,它用于存储Sex类的所有枚举值。在类的静态块中,$VALUES数组被初始化为一个包含MALE和FEMALE的数组。 - 构造函数
Sex(String name, int ordinal)是私有的,这意味着无法在类的外部使用这个构造函数来创建Sex的实例。只有 Java 编译器生成的代码才能调用这个构造函数来创建Sex的实例。 values()和valueOf(String name)是从Enum类继承的两个静态方法。values()方法返回一个包含Sex类所有枚举值的数组,valueOf(String name)方法返回指定名称的枚举值。
try-with-resources
JDK 7 开始新增了对需要关闭的自愿处理的特殊语法 try-with-resources
java
try (资源变量 = 创建资源对象) {
} catch() {
}
其中资源对象需要实现 AutoCloseable 接口,例如 InputStream、OutputStream、Connection、Statement、ResultSet 等接口都实现了 AutoCloseable 接口(Closeable 是 AutoCloseable 的一个子接口,也可以),使用 try-with-resources 可以不用写 finally 语句块,编译器会帮助我们生成关闭资源代码,例如
java
public class Candy09 {
public static void main(String[] args) {
try (InputStream is = new FileInputStream("d:\\tmp.test")) {
System.out.println(is);
} catch (IOException e) {
e.printStackTrace();
}
}
}
会被编译器转换为
java
public class Candy09 {
public Candy09() {
}
public static void main(String[] args) {
try {
// 创建资源 is
InputStream is = new FileInputStream("d:\\tmp.txt");
Throwable t = null;
try {
System.out.println(is);
} catch (Throwable e1) {
// t 是 try 块中出现的异常
t = e1;
throw e1; // 异常继续向上抛
} finally {
// 判断了资源不为空
if (is != null) {
// 如果 try 块有异常
if (t != null) {
// 因为关闭资源可能也有异常,所以还要 try
try {
is.close();
} catch (Throwable e2) {
// 如果 close 出现异常,作为被压制异常添加
t.addSuppressed(e2);
}
} else {
// 如果 try 块没有异常,close 出现的异常会抛出,也就是下面 catch 块中的 e
is.close();
}
}
}
} catch (IOException e) {
e.printStackTrace();
} // 业务异常 Throwable 不是 IOException 类型,所以外层 catch 捕获不到内部的 throw e1;
// 但是如果资源关闭有问题,会被捕获,但是如果业务和关闭都有问题,是捕获不到的,因为关闭异常被压制了
}
}
为什么要设计一个 addSuppressed(Throwable e)(添加被压制异常)的方法呢?
这是为了防止异常信息的丢失(想想 try-with-resources 生成的 finally 中如果抛出了异常)
java
public class Test {
public static void main(String[] args) {
try (MyResource resource = new MyResource()) {
int i = 1/0;
} catch (Exception e) {
e.printStackTrace();
}
}
}
class MyResource implements AutoCloseable {
public void close() throws Exception {
throw new Exception("close 异常");
}
}
输出如下,两个异常信息都不会丢失
text
java.lang.ArithmeticException: / by zero
at com.demo.Test.main(Test.java:6)
Suppressed: java.lang.Exception: close 异常
at com.demo.MyResource.close(Test.java:14)
at com.demo.Test.main(Test.java:7)
方法重写时的桥接方法
方法重写时,对返回值分两种情况
- 父类与子类的返回值完全一致
- 子类返回值可以是父类返回值的子类(比较绕口,直接看下面的例子来理解)
java
class A {
public Number m() {
return 1;
}
}
class B extends A {
@Override
// 父类 A 方法的返回值是 Number 类型,子类B方法的返回值是 Integer 类型,Integer 是 Number 的子类
public Integer m() {
return 2;
}
}
那么对于子类,编译器会做如下处理
java
class B extends A {
public Integer m() {
return 2;
}
// 此方法才是真正重写了父类 public Number m() 方法
public synthetic bridge Number m() {
// 调用 public Integer m()
return m();
}
}
其中的桥接方法比较特殊,仅对Java虚拟机可见,并且与原来的 public Integer m() 没有命名冲突,可以用反射代码来验证
java
for (Method m : B.class.getDeclaredMethods()) {
System.out.println(m);
}
会输出
java
public java.lang.Integer test.candy.B.m()
public java.lang.Number test.candy.B.m()
{% note success %}
桥接方法在字节码里是真正存在的方法,所以反射能看到
{% endnote %}
匿名内部类
java
public class Candy10 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok");
}
};
}
}
转换后代码
java
// 额外生成的类
final class Candy10$1 implements Runnable { // 外部类$1 编号
Candy10$1() {
}
public void run() {
System.out.println("ok");
}
}
public class Candy10 {
public static void main(String[] args) {
Runnable runnable = new Candy10$1();
}
}
对于匿名内部类,它的底层实现是类似于普通内部类的,只不过没有命名而已。在生成匿名内部类的 class 文件时,Java编译器会自动为该类生成一个类名,在原始类名上加后缀1,如果有多个匿名内部类,则2、$3以此类推
引用局部变量的匿名内部类,原始Java代码
java
public class Candy11 {
public static void test(final int x){
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok" + x);
}
};
}
}
转换后代码
java
// 额外生成的类
final class Candy11$1 implements Runnable {
int val$x;
Candy11$1(int x) { // 构造方法传递参数
this.val$x = x;
}
public void run() {
System.out.println("ok:" + this.val$x);
}
}
public class Candy11 {
public static void test(final int x) {
Runnable runnable = new Candy11$1(x);
}
}
{% note info %}
注意:这也解释了为什么匿名内部类引用局部变量时,局部变量必须为 final 的,因为在创建 Candy$11 对象时,将 x 的值赋给了 val$x 属性,所以 x 不应该再发生变化了,如果变化,那么 val$x 属性没有机会再跟着一起变化,因为构造函数只调用一次,所以通过 final 这种方式保证内外的一致性
{% endnote %}
类加载阶段
加载
-
将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 Java 类,Java 没办法直接访问
instanceKlassinstanceKlass的重要 field 有- _java_mirror:Java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴露给 Java 使用。作为 Java 通过 C++ 知道 Class 的桥梁
- _super:父类
- _fields:成员变量
- _methods:方法
- _constants:常量池
- _class_loader:类加载器
- _vtable:虚方法表
- _itable:接口方法表
-
如果这个类还有父类没有加载,先加载父类
-
加载和链接可能是交替运行的
{% note info %}
instanceKlass 这样的元数据是存储在方法区(1.8后是在元空间内),但 _java_mirror 是存储在堆中
可以通过 HSDB 工具查看
{% endnote %}

- JVM 加载类后,会把这个类的元信息放到方法区。JDK8 中就是元空间,元空间使用本地内存,不在 Java 堆中。
- 对于普通类,HotSpot 中对应的类元数据结构主要是
InstanceKlass。InstanceKlass里面保存类名、父类、字段信息、方法信息、运行时常量池、vtable、itable 等。 - 同时,JVM 会在 Java 堆中创建一个 java.lang.Class 对象,也就是我们平时说的 Person.class。元空间中的
InstanceKlass通过 _java_mirror 指针指向这个 Class 对象。Class 对象也能关联回元空间中的 Klass 元数据。 - 普通 new 出来的对象在堆中。对象头里有 klass pointer,它指向元空间中的
InstanceKlass,而不是指向堆里的 Class 对象。 - 所以普通实例方法调用、多态分派时,JVM 通常通过对象头里的 klass pointer 找到元空间中的 Klass,再根据方法表、虚方法表、接口方法表等信息找到真正要调用的方法。
- Class 对象的作用是给 Java 层使用。比如 Person.class、
obj.getClass()、Class.forName()、反射获取字段、方法、构造器等,都是通过 Class 对象作为入口,再关联到 JVM 内部的 Klass 元数据。
链接
1 验证
验证文件是否合法安全,是否符合 JVM 规范,安全性检查。
比如使用支持二进制的编辑器修改 HelloWorld.class 的魔数 ca fe ba be,在控制台运行后会报错
bash
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 3405691578 in class file cn/itcast/jvm/t5/HelloWorld
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
2 准备
为 static 变量分配空间,设置默认值(没有值的会设置好默认值)
static变量在 JDK 7 之前存储于 instanceKlass 末尾,从JDK 7开始,存储于 Java 堆中 Class 末尾static变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成,也就是<cinit>- 如果
static变量是 final 的基本类型以及字符串常量,那么编译阶段值就确定了,所以赋值在准备阶段完成 - 如果
static变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成,也就是<cinit>
java
public class Demo_1 {
static int a;
static int b = 10;
static final int c = 20;
static final String d = "hello";
static final Object e = new Object();
}
java
static int a;
descriptor: I
flags: (0x0008) ACC_STATIC
static int b;
descriptor: I
flags: (0x0008) ACC_STATIC
static final int c;
descriptor: I
flags: (0x0018) ACC_STATIC, ACC_FINAL
ConstantValue: int 20 // 确认了值了
static final java.lang.String d;
descriptor: Ljava/lang/String;
flags: (0x0018) ACC_STATIC, ACC_FINAL
ConstantValue: String hello // 确认了值了
static final java.lang.Object e;
descriptor: Ljava/lang/Object;
flags: (0x0018) ACC_STATIC, ACC_FINAL
static {};
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: bipush 10
2: putstatic #7 // Field b:I
5: new #2 // class java/lang/Object
8: dup
9: invokespecial #1 // Method java/lang/Object."<init>":()V
12: putstatic #13 // Field e:Ljava/lang/Object;
15: return
LineNumberTable:
line 5: 0
line 8: 5
{% note success %}
编译期常量:已写入字节码常量池,不依赖 <cinit>,在准备阶段用常量池直接初始化,也就是 static final int c = 20;
- 因为编译器只是编译了,还没放到内存来跑,但是这个值已经知道是什么了,写在常量池了,所以在准备阶段就已经确定了
准备阶段:所有静态字段分配内存,设置默认值(即使有赋值的也会先设置一个默认值),int -> 0,引用 -> null
初始化阶段:<cinit> -> 显式赋值 + static 块,赋值普通静态字段和对象引用,final 非编译期常量字段(上面的 static final Object e = new Object())
{% endnote %}
3 解析
将常量池中的符号引用解析为直接引用,通过两段代码对比来展示
java
public class Load02 {
public static void main(String[] args) throws ClassNotFoundException, IOException {
ClassLoader classloader = Load02.class.getClassLoader();
// loadClass 方法不会导致类的解析和初始化
Class<?> c = classloader.loadClass("com.demo.C");
System.in.read();
}
}
class C {
D d = new D();
}
class D {
}
默认情况下,类的加载都是懒惰式的,如果用到了类 C,但是没用到类 D,那么类 D 是不会主动加载的
使用 loadClass 方法不会导致类的解析和初始化

可以看到类 D 现在是 UnresolvedClass,也就是未经解析的类,在常量池中仅仅是一个符号,现在还不知道类 D 在内存的位置
现在用另外一段代码来跑
java
public class Load02 {
public static void main(String[] args) throws ClassNotFoundException, IOException {
new C(); // 会发生类的解析和初始化,并且同时也会用 D
System.in.read();
}
}
class C {
D d = new D();
}
class D {
}

可以看到此时类 D 已经加载成功了,同时在类 C 的常量池中也可以解析类 D 的地址
初始化
初始化即调用 <cinit>()V 方法,虚拟机来保证这个类的构造方法的线程安全
发生时机
概括来说,类初始化时【懒惰的】
- main 方法所在的类,总会被首先初始化(毕竟是程序入口)
- 首次访问这个类的静态变量或静态方法时,会进行初始化
- 子类初始化,如果父类还没未初始化,则父类也会进行初始化,并且先进行父类初始化
- 子类访问父类的静态变量,只会触发父类的初始化
- 默认的
Class.forName会导致初始化 - new 会导致初始化
不会导致类初始化的情况
- 访问类的
static final静态常量(基本类型和字符串) 不会触发初始化 --> 准备阶段就赋值了 - 调用
类对象.class不会触发初始化,因为这个在类加载时 Class 就已经有了 - 创建该类的数组不会被初始化
- 类加载器的
loadClass方法 Class.forName的参数 2 为 false 时(initalize = false)
代码示例
只要没执行静态代码块,就代表没进行初始化
会触发初始化的情况
-
main 方法所在的类,总会被首先初始化(毕竟是程序入口)
javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { } }输出
main init -
首次访问这个类的静态变量或静态方法时,会进行初始化
javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { System.out.println(A.a); } } class A { static int a = 0; static { System.out.println("a init"); } }输出
main inita init0 -
子类初始化,如果父类还没未初始化,则父类也会进行初始化,并且先进行父类初始化
javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { System.out.println(B.c); } } class A { static int a = 0; static { System.out.println("a init"); } } class B extends A { final static double b = 5.0; static boolean c = false; static { System.out.println("b init"); } }输出
main inita initb initfalse -
子类访问父类的静态变量,只会触发父类的初始化
javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { System.out.println(B.a); } } class A { static int a = 0; static { System.out.println("a init"); } } class B extends A { final static double b = 5.0; static boolean c = false; static { System.out.println("b init"); } }输出
main inita init0 -
默认的
Class.forName会导致初始化javaClass.forName("com.lh.load.B");输出
main inita initb init
不会触发初始化的情况
-
访问类的 static final 静态常量(基本类型和字符串) 不会触发初始化
javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { System.out.println(B.b); } } class A { static int a = 0; static { System.out.println("a init"); } } class B extends A { final static double b = 5.0; static boolean c = false; static { System.out.println("b init"); } }输出
main init5.0 -
调用
类对象.class不会触发初始化javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { System.out.println(B.class); } } class A { static int a = 0; static { System.out.println("a init"); } } class B extends A { final static double b = 5.0; static boolean c = false; static { System.out.println("b init"); } }输出
main initclass com.lh.load.B -
创建该类的数组不会被初始化 --> 数组的对象头是指向 ArrayKlass,数组元素是引用槽位,所以大小跟类的类型没关系,但是基本类型数组可不一样哈
javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { System.out.println(new B[0]); } } class A { static int a = 0; static { System.out.println("a init"); } } class B extends A { final static double b = 5.0; static boolean c = false; static { System.out.println("b init"); } }输出
main init[Lcom.lh.load.B;@45ee12a7 -
类加载器的
loadClass方法不会触发初始化javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); classLoader.loadClass("com.lh.load.B"); } } class A { static int a = 0; static { System.out.println("a init"); } } class B extends A { final static double b = 5.0; static boolean c = false; static { System.out.println("b init"); } }输出
main init -
Class.forName的参数 2 为 false 时(initalize = false)javapublic class Load03 { static { System.out.println("main init"); } public static void main(String[] args) throws ClassNotFoundException { ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); // 不会初始化类 B, 但是会加载 A、B Class.forName("com.lh.load.B", false, classLoader); } } class A { static int a = 0; static { System.out.println("a init"); } } class B extends A { final static double b = 5.0; static boolean c = false; static { System.out.println("b init"); } }输出
main init
练习
从字节码分析,使用 a b c 这三个变量,是否会导致初始化
java
public class Load4 {
public static void main(String[] args) {
System.out.println(E.a);
System.out.println(E.b);
System.out.println(E.c);
}
}
class E {
public static final int a = 10;
public static final String b = "hello";
public static final Integer c = 20;
}
ab不会导致 E 的初始化,c会导致 E 的初始化a和b是基本类型和字符串常量,而c是包装类型,底层还要调用Integer.valueOf()方法来装箱,只能推迟到初始化阶段运行,字节码如下
java
public static final int a;
descriptor: I
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 10
public static final java.lang.String b;
descriptor: Ljava/lang/String;
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: String hello
public static final java.lang.Integer c;
descriptor: Ljava/lang/Integer;
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
static {};
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: bipush 20
2: invokestatic #7 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
5: putstatic #13 // Field c:Ljava/lang/Integer;
8: return
LineNumberTable:
line 14: 0
典型应用
懒惰初始化的单例模式,核心就是用的时候才创建对象,而不是类加载时就创建,那就是别用下面两种方式就行呗
java
// 这两种方式对应的就是饿汉式,类加载的时候就创建实例
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton(){}
}
public class Singleton {
private static Singleton instance;
private Singleton(){}
static {
instance = new Singleton();
}
}
下面说几种常用的实现方式
普通懒汉式 线程不安全
java
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
多个线程同时进来都判断 instance == null,就会创建多个 Singleton 对象。A 和 B 线程用的可能就不是同一个 Singleton 对象了。
synchronized 方法 线程安全,性能一般
java
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
虽然保证了创建单例的时候线程安全,但是获取单例的时候也加锁了,也就是说 A 和 B 线程不能同时获得单例,只能排队获得,实际上对象创建完成后就不需要加锁了。
双重检查锁 DCL 常用
java
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这个到后面有序性会讲为什么需要加 volatile,不是要解决可见性,而是解决有序性
静态内部类 推荐,线程安全
java
public class Singleton {
private Singleton() {
}
private static class LazyHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
Singleton 类加载时,不会立刻加载 LazyHolder 类,只有第一次调用 getInstance() 访问 LazyHolder.INSTANCE 时,LazyHolder 类才会被初始化,INSTANCE 才会被创建。类初始化过程由 JVM 保证线程安全,同一个类加载器下不会出现多个线程同时初始化 LazyHolder 并创建多个 Singleton 的情况。
枚举单例 最安全
java
public enum Singleton {
INSTANCE;
public void doSomething() {
System.out.println("do something");
}
}
线程安全、防反射破坏、防反序列化破环
{% note warning %}
也不太懂,后面再学
{% endnote %}
整体来说,推荐 静态内部类 方式
类加载器
以 JDK8 为例
| 名称 | 加载哪的类 | 说明 |
|---|---|---|
| Bootstrap ClassLoader | JAVA_HOME/jre/lib | 无法直接访问(C++代码写的) |
| Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap,显示为 null |
| Application ClassLoader | classpath(自己程序的类) | 上级为 Extension |
| 自定义类加载器 | 自定义 | 上级为 Application |
当 JVM 需要加载一个类时,它会首先委托父类加载器去加载这个类,如果父类加载器无法加载这个类,就会由当前类加载器来加载。如果所有的父类加载器都无法加载这个类,那么就会抛出 ClassNotFoundException 异常。
启动类加载器
Bootstrap ClassLoader 是所有类加载器中最早的一个,负责加载 JRE/lib 下的核心类库,如 java.lang.Object、java.lang.String 等。
我们先编写一个 F 类
java
package com.lh.load;
public class F {
static {
System.out.println("F init");
}
}
然后跑一下下面的测试
java
package com.lh.load;
public class Load5_1 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Class.forName("com.lh.load.F"); // 输出 F init
System.out.println(aClass.getClassLoader()); // jdk.internal.loader.ClassLoaders$AppClassLoader@251a69d7
}
}
咱们自己的类,所以是输出 AppClassLoader
然后我们试一下下面的
java
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Class.forName("java.lang.Object");
System.out.println(aClass.getClassLoader()); // null
}
输出的结果是 null,因为引导类加载器是由 JVM 的实现者用 C/C++ 等语言编写的,而不是由 Java 编写的。在 Java 虚拟机的实现中,启动类加载器不是 Java 对象,也没有对应的 Java 类,因此它的 ClassLoader 属性为 null。
{% note info %}
有一个比较超纲的小知识点,可以通过 -Xbootclasspath 修改 Bootstrap ClassLoader 的类搜索路径,让某些类由启动类加载器加载。比如
bash
java -Xbootclasspath/a:. cn.itcast.jvm.t3.load.Load5_1
重点在 -Xbootclasspath/a:.,意思是把当前目录 . 追加到 Bootstrap ClassLoader 的搜索路径后面。也就是说原来 Bootstrap ClassLoader 只加载 Java 核心类,现在加了这个参数也会去当前目录找 class 文件。
修改 bootclasspath 搜索路径有三种写法
-
替换 bootclasspath
bashjava -Xbootclasspath:<new bootclasspath>这种太危险了,替换了你原来
Object、String这种核心类都找不到了 -
追加到后面
/abashjava -Xbootclasspath/a:<追加路径>把当前目录追加到 Bootstrap ClassLoader 搜索路径后面,搜索顺序就是 JDK 原本的核心路径,然后是我们追加的路径,所以如果核心类库里已经有某个类,会优先用 JDK 自带的。
-
追加到前面
/pbashjava -Xbootclasspath/p:<追加路径>这样搜索路径就反过来了,这样就可以覆盖某些核心类,比如你写了个 String 类,在包
java.lang下,你追加到前面就会替换 String 核心类!!!很容易把 JVM 搞崩。开发中一般都不用,一般是开发 JVM 的人会用
{% endnote %}
扩展类加载器
我们编写一个 G 类
java
package com.lh.load;
public class G {
static {
System.out.println("Ext G init");
}
}
然后把这个 class 打成 jar 包
bash
jar -cvf my.jar com/lh/load/G.class
jar命令,Java 自带的打包工具-c,create,创建新的 jar 包-v,verbose,显示打包过程-f,file 指定要输出的 jar 文件名
然后跟上要生成的 jar 包名以及要打进去的文件
{% note warning %}
一个思考小细节,打包的时候,.class 文件里面的路径是 com.lh.load,所以这个文件所在的路径必须是 com/lh/load/G.class,不然类加载器根据类名找对应的文件找不到。
{% endnote %}
生成的 my.jar 文件我们放到 JAVA_HOME/jre/ext 目录下。

可以用解压缩文件查看里面是否打包正确

没问题
{% note info %}
macOS 系统安装的 temurin8 是在 JAVA_HOME/jre/lib/ext。另外从 JDK9 开始就是模块化了,Extension ClassLoader 机制被废弃了
{% endnote %}
然后我们把我们原来的 G 输出改一下
java
package com.lh.load;
public class G {
static {
System.out.println("App G init");
}
}
跑一下测试试试
java
public class Load5_1 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Class.forName("com.lh.load.G");
System.out.println(aClass.getClassLoader());
}
}

读取的是 ext 路径下的,不是 App 中的。
双亲委派机制
所谓双亲委派机制,就是指调用类加载器的 loadClass 方法时,查找类的规则
{% note warning %}
这个双亲委派是从英文翻译过来的,感觉这个双亲翻译成上级更合适,因为它们之间并没有继承关系
{% endnote %}
ClassLoader 中的 loadClass() 方法的源码
java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 检查类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 如果类没有被加载,则委托给父 ClassLoader 加载
if (parent != null) {
c = parent.loadClass(name, false); // --> 往上递归找父类
} else {
// 如果没有上级了(ExtClassLoader),就委派给 BootstrapClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 啥也不做,只捕获
}
if (c == null) {
// 从自身这里找
// 1 如果自身是 Ext,那就从那个 ext 文件夹找
// 2 如果自身是 app,就从当前项目找
// 找不到就抛出 ClassNotFoundException 异常,被上层的 catch 块捕获
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
比如 Class.forName("com.lh.load.G") 整体流程就是
text
AppClassLoader.loadClass("G")
|
|-- 先问父加载器 ExtClassLoader
|
|-- ExtClassLoader 先问 BootstrapClassLoader
|
|-- Bootstrap 找不到 com.lh.load.G
|
|-- ExtClassLoader 自己去 ext 目录找
|
|-- 找不到,抛 ClassNotFoundException
|
|-- AppClassLoader 自己去 classpath 找
|
|-- 找到 target/classes/com/lh/load/G.class
线程上下文类加载器
我们在使用 JDBC 时,都需要加载 Driver 驱动,但是我们好像并没有显示的调用 Class.forName 来加载 Driver 类,也就是
java
Class.forName("com.mysql.jdbc.Driver");
那么实际上是如何加载这个驱动的呢?我们来追踪一下源码,这里只看最核心的部分
java
public class DriverManager {
// 注册驱动的集合
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
// 初始化驱动
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
}
我们试着输出一下 DirverManager 的类加载器是谁
java
System.out.println(DriverManager.class.getClassLoader());
输出的结果是 null,那么说明它是由 Bootstrap ClassLoader 加载的,那么按理说应该是去 JAVA_HOMT/jre/lib 下搜索驱动类。
但 JAVA_HOMT/jre/lib 显然没有 mysql-connector-java-5.7.31.jar 包,在 DriverManager 的静态代码块中,是如何正确加载 com.mysql.jdbc.Driver 的呢?继续来看看 loadInitialDrivers() 方法的源码
java
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
// If the driver is packaged as a Service Provider, load it.
// Get all the drivers through the classloader
// exposed as a java.sql.Driver.class service.
// ServiceLoader.load() replaces the sun.misc.Providers()
// 1. 使用 ServiceLoader 机制加载驱动,即 SPI
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
/* Load these drivers, so that they can be instantiated.
* It may be the case that the driver class may not be there
* i.e. there may be a packaged driver with the service class
* as implementation of java.sql.Driver but the actual class
* may be missing. In that case a java.util.ServiceConfigurationError
* will be thrown at runtime by the VM trying to locate
* and load the service.
*
* Adding a try catch block to catch those runtime errors
* if driver not available in classpath but it's
* packaged as service and that service is there in classpath.
*/
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
println("DriverManager.initialize: jdbc.drivers = " + drivers);
// 2. 使用 jdbc.drivers 定义的驱动名加载驱动
if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
// 这里的 ClassLoader.getSystemClassLoader() 就是应用程序类加载器
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
先看 2,它最后是使用的 Class.forName 完成类的加载和初始化,关联的是应用类加载器,因此可以顺利完成驱动类的加载
{% note info %}
打破了双亲委派模式,不用 Bootstrap 去 lib 下找 jar 包,而是自己选择使用 AppClassLoader 去项目下找 jar 包来加载
{% endnote %}
再看 1,它就是大名鼎鼎的 Service Provider Interface(SPI)SPI 机制
约定如下,在 jar 包的 META-INF/services 包下,以接口全限定名名为文件,文件内容是实现类的名称,这里面是有两种实现类
服务的提供者 mysql 要声明好我要给哪个类提供服务,具体实现是什么,也就是刚刚说的这个规则

这样就可以使用如下代码遍历来得到实现类
java
ServiceLoader<接口类型> allImpls = ServiceLoader.load(接口类型.class);
Iterator<接口类型> iter = allImpls.iterator();
while(iter.hasNext()){
iter.next();
}
体现的是面向接口编程 + 解耦的思想,在下面的一些框架中都运用了此思想
- JDBC
- Servlet 初始化器
- Spring 容器
- Dubbo(对 SPI 进行了扩展)
接着看 ServiceLoader.load 方法
java
public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取线程上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
线程上下文类加载器是当前线程使用的类加载器,默认就是应用类加载器,它内部又是由 Class.forName 调用了线程上下文类加载器完成类加载,具体代码在 ServiceLoader 的内部类 LazyIterator 中
java
private class LazyIterator
implements Iterator<S>
{
Class<S> service;
ClassLoader loader;
Enumeration<URL> configs = null;
Iterator<String> pending = null;
String nextName = null;
private LazyIterator(Class<S> service, ClassLoader loader) {
this.service = service;
this.loader = loader;
}
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
String fullName = PREFIX + service.getName();
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
} catch (IOException x) {
fail(service, "Error locating configuration files", x);
}
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
pending = parse(service, configs.nextElement());
}
nextName = pending.next();
return true;
}
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service,
"Provider " + cn + " not a subtype");
}
try {
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service,
"Provider " + cn + " could not be instantiated",
x);
}
throw new Error(); // This cannot happen
}
public boolean hasNext() {
if (acc == null) {
return hasNextService();
} else {
PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
public Boolean run() { return hasNextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public S next() {
if (acc == null) {
return nextService();
} else {
PrivilegedAction<S> action = new PrivilegedAction<S>() {
public S run() { return nextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public void remove() {
throw new UnsupportedOperationException();
}
}
自定义类加载器
什么时候需要自定义类加载器?
-
从非 classpath 路径加载类
自定义类加载器可用于加载非 Classpath 路径中的类文件,例如外部配置文件夹、网络资源或其他自定义路径。这种需求在一些动态扩展或插件化的场景中比较常见。
java// 一个简单的自定义类加载器示例 public class NetworkClassLoader extends ClassLoader { private String serverUrl; @Override protected Class<?> findClass(String name) throws ClassNotFoundException { // 1. 从网络下载 class 字节码 byte[] bytes = downloadFromServer(serverUrl + "/" + name + ".class"); if (bytes == null) { throw new ClassNotFoundException(name); } // 2. 调用 defineClass 将字节数组转为 Class 对象 return defineClass(name, bytes, 0, bytes.length); } } -
在应用程序中使用的类可以通过接口来使用,而不是直接引用类。这种做法可以减少应用程序之间的依赖,从而提高代码的灵活性和可维护性。同时,这种做法也使得框架的设计更加清晰和可扩展。Spring JDBC、SLF4J、JDBC Driver 都是这个套路
-
在 Tomcat 容器中,每个 Web 应用程序都使用自己的类加载器,从而避免了不同 Web 应用程序之间的类冲突问题。也就是类隔离
Tomcat 的类加载器层级:
textBootstrap(JDK 核心类) ↓ System(Tomcat 自身的 jar,catalina.jar 等) ↓ Common(所有应用共享的 jar,放 tomcat/lib/) ↓ WebApp1 加载器 WebApp2 加载器 WebApp3 加载器 (/webapp1/WEB-INF/lib/*.jar) (/webapp2/WEB-INF/lib/*.jar)具体场景:
WebApp1:使用 Spring 5.0 + log4j 1.2
WebApp2:使用 Spring 6.0 + logback
如果没有隔离,两个应用的不同版本 jar 就会冲突(同一个类出现在 classpath 两次,JVM 只能加载第一个)。Tomcat 通过每个应用独立的
WebappClassLoader解决这个问题 ------ 每个应用的类在自己的ClassLoader命名空间中,互相完全看不见。--> 具体实现是违反双亲委派,优先自己加载,找不到才向上委派
下面我们试着自己定义一个类加载器
{% tabs 自定义类加载器 , 1 %}
创建一个 H 类,把编译后的 H.class 文件放在 /Users/ice/Desktop/cola/com/lh/load 目录下,并且把 H.java 删除
java
package com.lh.load;
public class H {
static {
System.out.println("H init");
}
}
自定义一个 ClassLoader 类
java
class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// class 文件路径
final String path = "/Users/ice/Desktop/cola/" + name.replace('.', '/') + ".class";
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
Files.copy(Paths.get(path), outputStream);
byte[] bytes = outputStream.toByteArray();
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(path + "未找到: " + e);
}
}
}
调用自定义的类加载器加载 H 类
java
public class Load6 {
public static void main(String[] args) throws Exception {
MyClassLoader classLoader = new MyClassLoader();
Class<?> cl1 = classLoader.loadClass("com.lh.load.H");
Class<?> cl2 = classLoader.loadClass("com.lh.load.H");
System.out.println(cl1 == cl2); // true
MyClassLoader classLoader2 = new MyClassLoader();
Class<?> cl3 = classLoader2.loadClass("com.lh.load.H");
System.out.println(cl1 == cl3); // false
cl1.newInstance(); // H init
}
}
{% note warning %}
如果不删除 H.java 文件会存在什么问题?
因为我们没有重写 loadClass 方法,所以没有打破双亲委派,我们自定义的 ClassLoader 的父加载器是 AppClassLoader,所以我们使用 classLoader.loadClass 加载的时候都是用的 AppClassLoader,所以他们加载的都是类路径下的 H,都是用的 AppClassLoader,是同一个类加载器,所以最后两个输出都是 true
{% endnote %}
{% endtabs %}
{% note danger %}
在 JVM 中,一个类的唯一标识不仅是它的全限定类名,还包括加载它的类加载器。
{% endnote %}
运行期优化
即时编译
分层编译
java
public class JIT1 {
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
new Object();
}
long end = System.nanoTime();
System.out.printf("%d\t%d\n", i, (end - start));
}
}
}
{% hideToggle 输出结果 %}
text
0 29208
1 22542
2 21500
3 20625
4 20458
5 19542
6 20375
7 28625
8 19708
9 19542
10 19541
11 19833
12 20166
13 22333
14 20417
15 24417
16 20083
17 21209
18 25000
19 143541
20 22666
21 22083
22 23166
23 25667
24 56792
25 32167
26 21917
27 20708
28 20625
29 19750
30 27542
31 21417
32 22584
33 20584
34 19625
35 20250
36 21709
37 20750
38 20542
39 20291
40 20083
41 20833
42 21750
43 20542
44 20834
45 36625
46 21292
47 24250
48 20916
49 20833
50 19417
51 21417
52 21459
53 21625
54 21541
55 25666
56 21459
57 21625
58 20583
59 23792
60 22333
61 21584
62 22458
63 21541
64 21958
65 24000
66 22167
67 21042
68 21334
69 53417
70 48708
71 29041
72 75291
73 75209
74 21291
75 76791
76 22917
77 52375
78 22083
79 46958
80 32500
81 40875
82 20916
83 20333
84 20417
85 20250
86 20750
87 20791
88 20667
89 20791
90 20500
91 20709
92 20542
93 20500
94 20208
95 20459
96 20500
97 20750
98 19959
99 21166
100 21500
101 21000
102 20625
103 58625
104 52833
105 235084
106 24458
107 15375
108 18750
109 14208
110 13334
111 13541
112 11500
113 17750
114 163791
115 20333
116 10792
117 7500
118 7250
119 6875
120 4875
121 6083
122 5333
123 8042
124 4709
125 5500
126 6042
127 5583
128 4458
129 8125
130 22334
131 25250
132 21625
133 14791
134 10625
135 8417
136 7208
137 15041
138 8334
139 14125
140 13500
141 5833
142 6833
143 7500
144 5750
145 6250
146 6084
147 6583
148 5917
149 5792
150 6166
151 4458
152 5916
153 5917
154 5125
155 5375
156 5583
157 5167
158 5500
159 3917
160 6125
161 5583
162 6083
163 4916
164 6375
165 5375
166 16125
167 6000
168 5791
169 17625
170 11792
171 6459
172 15125
173 12750
174 14750
175 8417
176 5959
177 5875
178 5250
179 6542
180 6875
181 5959
182 5541
183 5458
184 5500
185 5500
186 5334
187 5541
188 6208
189 5167
190 5833
191 5833
192 6667
193 5959
194 16625
195 6875
196 16083
197 260625
198 7666
199 5625
{% endhideToggle %}
可以看到循环到 107 次附近明显加快了,到117 次之后又加快了。这是为什么呢?
JVM 将执行状态分为 5 个层次
0层:解释执行(Interpreter)
在 0 层,JVM 使用解释器来直接解释 Java 字节码,并执行程序。这种方式简单但效率较低,因为解释器需要逐条解释字节码指令,并执行它们,每次执行时都需要对字节码进行解析1层:使用 C1 即时编译器编译执行(不带 profilling)
在 1 层,JVM 会使用即时编译器(JIT)将 Java 字节码编译成本地机器码,然后直接执行机器码。这种方式相比于解释器,可以提供更高的执行速度。C1 即时编译器适合编译执行热点代码,即被频繁执行的代码2层:使用 C1 即时编译器编译执行(带基本的 profilling)
在 2 层,JVM 会收集一些基本的执行状态数据,即 profilling。例如方法的调用次数、循环的回边次数等,然后根据这些数据来决定哪些代码块需要被编译执行。这种方式可以更加精确地编译热点代码,从而提高程序的执行速度3层:使用 C1 即时编译器编译执行(带完全的 profilling)
在 3 层,JVM 会收集更加详细的执行状态数据,例如内联调用的次数、方法的参数类型等,以便更好地优化代码。这种方式可以进一步提高程序的执行速度,但同时也会增加编译的开销4层:使用 C2 即时编译器编译执行
在 4 层,JVM 会使用更高级别的即时编译器(C2)来对代码进行优化,包括对循环、分支和递归等结构的优化。C2 编译器的编译时间比 C1 长,但编译出来的代码执行速度更快。
{% note info %}
profilling 是指在运行过程中收集一些程序执行的状态数据,例如方法的调用次数、循环的回边次数
{% endnote %}
即时编译器(JIT)和解释器的区别
- 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- JIT 是将一些字节码编译为机器码,并存入
Code Cache,下次遇到相同的代码,直接执行,无需再次编译 - 解释器是逐条解释平台无关的 Java 字节码,并通过当前平台上的 JVM 实现执行对应操作
- JIT 会将热点字节码编译为当前平台特定的本地机器码,从而提高执行效率
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采用解释器执行的方法运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。执行效率上简单比较一下:Interceptor < C1 < C2
上面代码中最后的耗时都在 5500 附近,这是 C2 即时编译器做了逃逸分析,因为上面的代码中,我们仅仅是创建了 Object 对象,而并没有使用它,也就是没有逃逸出当前作用域。在进行逃逸分析时,JVM 会分析对象是否可能被线程外的代码引用,如果对象不会逃逸出当前方法的作用域,那么 JVM 可能会将对象的分配优化为栈上分配,甚至直接标量替换,不真正创建对象,从而避免了堆内存的分配和垃圾回收的压力。
比如
java
public int test() {
Point p = new Point();
p.x = 1;
p.y = 2;
return p.x + p.y;
}
JVM 优化后可能变成类似
java
public int test() {
int x = 1;
int y = 2;
return x + y;
}
将对象分配在栈上的优点
- 快速分配和回收:栈内存的分配和回收都非常快,比堆内存要快得多。如果对象可以在栈上分配,那么它的分配和回收都可以更快,从而提高程序的性能。
- 减少垃圾回收:在 Java 中,对象的分配和回收是由垃圾回收器来完成的。如果对象可以在栈上分配,那么它就不会对堆内存的使用和垃圾回收产生影响,从而可以减少垃圾回收的频率和时间,提高程序的性能。
我们可以添加VM参数-XX:-DoEscapeAnalysis关闭逃逸分析,然后再次执行代码,观察耗时情况
我们可以添加 VM 参数 -XX:-DoEscapeAnalysis 关闭逃逸分析,然后再次执行代码,观察耗时情况,结果就不列举了,基本上最低的耗时在 10000 以上了,比较高了
方法内联
java
private static int square(int i) {
return i * i;
}
System.out.println(square(9));
如果发现 square 是热点方法,且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝,并粘贴到调用者的位置
java
System.out.println(9 * 9);
还能够进行常量折叠(constant folding)的优化
java
System.out.println(81);
下面验证一下,看一下输出耗时
{% hideToggle 输出耗时 %}
text
0 81 30791
1 81 26958
2 81 13000
3 81 12750
4 81 12709
5 81 157208
6 81 13791
7 81 14541
8 81 22083
9 81 12958
10 81 12708
11 81 12666
12 81 12625
13 81 14292
14 81 19000
15 81 13250
16 81 38208
17 81 30084
18 81 29666
19 81 21792
20 81 13083
21 81 13709
22 81 13875
23 81 15666
24 81 14667
25 81 15166
26 81 13625
27 81 13292
28 81 13084
29 81 13083
30 81 12917
31 81 12875
32 81 12917
33 81 13750
34 81 13709
35 81 13958
36 81 12667
37 81 69667
38 81 13750
39 81 13500
40 81 13333
41 81 13500
42 81 13542
43 81 13833
44 81 12708
45 81 13459
46 81 13000
47 81 39167
48 81 12917
49 81 13125
50 81 13209
51 81 12958
52 81 12916
53 81 12958
54 81 13666
55 81 13042
56 81 12959
57 81 13292
58 81 13125
59 81 24250
60 81 13084
61 81 14333
62 81 14250
63 81 12958
64 81 13208
65 81 13875
66 81 13291
67 81 33750
68 81 14667
69 81 13875
70 81 63833
71 81 15000
72 81 15833
73 81 14666
74 81 14500
75 81 14125
76 81 13917
77 81 15334
78 81 15042
79 81 28583
80 81 14292
81 81 14250
82 81 14250
83 81 14167
84 81 415625
85 81 2125
86 81 1834
87 81 3000
88 81 8708
89 81 1584
90 81 1375
91 81 1416
92 81 1375
93 81 1375
94 81 1375
95 81 2416
96 81 1375
97 81 1375
98 81 1375
99 81 1375
100 81 1416
101 81 1417
102 81 1958
103 81 2500
104 81 1416
105 81 1416
106 81 1417
107 81 1375
108 81 1917
109 81 14666
110 81 2042
111 81 16000
112 81 1834
113 81 4000
114 81 3875
115 81 3833
116 81 2083
117 81 16667
118 81 2125
119 81 1958
120 81 5584
121 81 2125
122 81 3958
123 81 2083
124 81 2000
125 81 1959
126 81 2000
127 81 3958
128 81 6042
129 81 3833
130 81 2083
131 81 1917
132 81 2083
133 81 2042
134 81 2000
135 81 1959
136 81 1197083
137 81 126208
138 81 42
139 81 0
140 81 83
141 81 0
142 81 0
143 81 41
144 81 0
145 81 42
146 81 41
147 81 0
148 81 42
149 81 0
150 81 83
151 81 42
152 81 0
153 81 42
154 81 42
155 81 42
156 81 42
157 81 125
158 81 42
159 81 83
160 81 42
161 81 84
162 81 0
163 81 0
164 81 0
165 81 0
166 81 0
167 81 41
168 81 42
169 81 83
170 81 83
171 81 167
172 81 42
173 81 42
174 81 83
175 81 84
176 81 42
177 81 42
178 81 0
179 81 84
180 81 0
181 81 41
182 81 0
183 81 42
184 81 0
185 81 0
186 81 0
187 81 0
188 81 0
189 81 0
190 81 83
191 81 84
192 81 0
193 81 83
194 81 83
195 81 0
196 81 0
197 81 167
198 81 125
199 81 42
{% endhideToggle %}
最后耗时为 0,就是进行了常量折叠的优化,我们可以添加 VM 参数 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 打印内联信息,可以看到我们的 square 方法被标记为了热点代码

同一个 square 出现多次,一般是因为分层编译的原因,JVM 会对热点方法进行多次编译,每次编译都会重新打印內联决策
同时也可以禁止某个方法的内联 -XX:CompileCommand=dontinline,*JIT2.square,不能进行常量折叠优化了,速度不会到达 0
* 的含义是任意包下的
字段优化
针对静态变量或者成员变量读写操作的优化
JMH 基准测试参考:https://openjdk.org/projects/code-tools/jmh/
添加依赖如下
xml
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
</dependency>
编写基准测试代码
java
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 1)
@State(Scope.Benchmark)
public class Benchmark1 {
int[] elements = randomInts(1_000);
private static int[] randomInts(int size) {
Random random = ThreadLocalRandom.current();
int[] values = new int[size];
for (int i = 0; i < size; i++) {
values[i] = random.nextInt();
}
return values;
}
@Benchmark
public void test1() {
for (int i = 0; i < elements.length; i++) {
doSum(elements[i]);
}
}
@Benchmark
public void test2() {
int[] local = this.elements;
for (int i = 0; i < local.length; i++) {
doSum(local[i]);
}
}
@Benchmark
public void test3() {
for (int element : elements) {
doSum(element);
}
}
static int sum = 0;
@CompilerControl(CompilerControl.Mode.INLINE)
static void doSum(int x) {
sum += x;
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Benchmark1.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
}
@Warmup注解表示在基准测试运行之前需要进行预热,以使 JVM 达到最佳运行状态。在这个例子中,预热进行了2次,每次持续1秒钟。@Measurement注解表示运行5次基准测试,每次持续1秒钟。@State注解定义了 Benchmark1 类的实例作用域为Scope.Benchmark,表示这个类的实例可以在不同的测试方法之间共享,并保持在整个基准测试运行期间的状态。- 这个类包含了三个测试方法:
test1、test2和test3。这些测试方法执行相同的操作,即对数组elements中的所有元素进行求和操作,但使用不同的方法来访问数组中的元素。test1使用了数组索引,test2使用了本地数组变量,而test3使用了foreach循环。
启用 doSum 的方法内联 @CompilerControl(CompilerControl.Mode.INLINE) 允许方法内联,测试结果如下

我们这里重点关注的是 Score,现在开启了 doSum 的内联,这三种遍历方式的性能没有显著差异,那现在禁用 doSum 方法的内联 @CompilerControl(CompilerControl.Mode.DONT_INLINE),测试结果如下

这三种遍历方式的性能与之前相比,都下降了一个数量级,test2 和 test3 的性能差异不大,test1 的性能明显要差一点,这是为什么呢?
因为 doSum 方法是否内联,会影响 elements 成员变量的读取的优化
如果 doSum 方法内联了,那么刚刚的 test1 方法会被优化成下面的样子(伪代码)
java
@Benchmark
public void test1() {
int[] local = this.elements;
int len = local.length;
for (int i = 0; i < len; i++) { // 后续 999 次 求长度 <- local
sum += local[i]; // 1000 次取下标 i 的元素 <- local
}
}
- 少了一部分方法调用开销,不用每次都调用
doSum - 优化
this.elements,减少重复读取this.elements引用,减少重复读取this.elements.length
{% note danger %}
为什么内联可以优化?
java
@Benchmark
public void test2() {
int[] local = this.elements;
for (int i = 0; i < local.length; i++) {
doSum(local[i]);
}
}
和
java
@Benchmark
public void test1() {
for (int i = 0; i < elements.length; i++) {
doSum(elements[i]);
}
}
什么区别?
后者呢,有个问题,后者 elements 属于对象,那是不是可能存在另外的方法改动 elements,比如 this.elements = new int[2000],这样数组长度就变化了,所以对于 test1 而言,这个长度我需要用到的时候每次都去读取。前者呢,我把这个数组引用缓存起来了,是放在栈里面的,虽然说对 elements 数组元素改值还是能感知到的,比如 elements[0] 赋值了,那 local[0] 也知道,但是如果数组长度发生变化 this.elements = new int[2000],不止长度变了,这是创建了一个新数组,引用给 this.elements,但是 local 引用还不变,指向的是原来的数组,但是 elements 指向的新数组。所以如果把数组引用缓存到 local,长度是不是就一直不会变化了,test2 很容易被优化成
java
int[] local = this.elements;
int len = local.length;
for (int i = 0; i < len; i++) {
doSum(local[i]);
}
所以省去了 999 次读取数组长度。那后面说的 1000 是啥呢?是我们读 elements 实际上是先从 this 读取到 elements 引用值,因为 elements 像前面说的可能被重新赋值,所以每次要从 this 先读 elements,再读 elements[i]。花双倍的价钱,但是如果是保存到 local,就不用每次先读引用再读值了。因为 local 是局部变量,保存到栈里面,不可能被外部的方法什么的改变值。
{% endnote %}
反射优化
示例代码
java
import java.lang.reflect.Method;
public class Reflect1 {
public static void foo() {
System.out.println("foo...");
}
public static void main(String[] args) throws Exception {
Method foo = Reflect1.class.getMethod("foo");
for (int i = 0; i <= 16; i++) {
System.out.printf("%d\t", i);
foo.invoke(null);
}
System.in.read();
}
}
- 定义了一个名为
foo的静态方法,该方法只是简单地输出一条字符串。 - 在
main方法中,使用Reflect1类的getMethod方法获取名为foo的Method对象,之后进行反射调用。 - 接着使用循环调用反射方法,循环次数从
0到16,每次循环都调用反射获取的Method对象的invoke方法,传入null作为静态方法的调用者。因为foo方法是静态方法,所以调用者可以为null。 - 最后使用
System.in.read()方法暂停程序的运行,以便我们可以观察程序的输出结果。
java
class NativeMethodAccessorImpl extends MethodAccessorImpl {
private final Method method;
private DelegatingMethodAccessorImpl parent;
private int numInvocations;
NativeMethodAccessorImpl(Method var1) {
this.method = var1;
}
public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException {
if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(), this.method.getExceptionTypes(), this.method.getModifiers());
this.parent.setDelegate(var3);
}
return invoke0(this.method, var1, var2);
}
void setParent(DelegatingMethodAccessorImpl var1) {
this.parent = var1;
}
private static native Object invoke0(Method var0, Object var1, Object[] var2);
}
前 15 次调用使用的是 NativeMethodAccessorImpl 实现的 MethodAccessor,该实现类使用 JNI 调用底层的 C/C++ 代码实现方法调用。由于 NativeMethodAccessorImpl 的实现开销较大,因此前 15 次的反射调用的性能相对较差。
java
private static int inflationThreshold = 15;
而第 16 次调用则采用了 GeneratedMethodAccessor1 实现的 MethodAccessor,这个实现类通常是使用 Java 字节码动态生成的,因此方法调用的性能比 NativeMethodAccessorImpl 更好。这是因为在第 15 次调用时,生成了一个新的 MethodAccessorImpl 实现类(MethodAccessorGenerator),并在下一次方法调用时使用该实现类,即第 16 次调用。
{% note warning %}
新生成了 MethodAccessor 之后还用原来的 native 方法吗?
普通反射比较慢,它需要做权限检查、涉及装箱、间接调用等额外成本,因为方法是通用的,所以要考虑的比较多,并且是 C++ 代码,JIT 没办法优化。当调用次数多了之后,就生成一个专门的只针对这个方法调用的的 Java 类来进行反射调用这个方法,速度会更快一点,但是启动比较慢。就不会调用原来的 native 方法了。
为什么一开始不生成这个访问器?
生成访问器本身有额外成本,包括生成字节码、加载类、验证类、占用 Metaspace 等,所以如果只调用一两次,直接生成反而不划算。
那为什么不一开始就用 Java 代码来 invoke 而是用 native?
普通 Java 代码本身也没法写出一个真正通用的 Method 调用器,因为 Java 的方法调用指令需要在字节码中确定目标方法,而反射的目标方法是在运行期由 Method 对象决定的。native 层可以根据 JVM 内部的方法元数据完成通用调用,而生成的访问器则是在目标 Method 已经确定后生成的专用调用器。
{% endnote %}
{% note success %}
反射调用 Method.invoke() 底层有 inflation 机制。刚开始反射调用会通过 NativeMethodAccessorImpl 的 native 方法 invoke0() 执行,这样可以避免一开始就生成字节码访问器的成本。当同一个反射调用次数超过阈值后,JVM 会动态生成 GeneratedMethodAccessor 这类 Java 字节码访问器,并把委托对象切换过去。之后 Method.invoke() 入口不变,但底层不再走 native invoke0(),而是走生成的 Java 方法,因此可以减少 native 调用边界成本,也更容易被 JIT 优化。
{% endnote %}
内存模型
【Java 内存模型】是 Java Memory Model,和前面说的 Java 内存结构不一样
JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障
JUC 里面也会详细讲一讲,这里可以不用看
{% note warning %}
这一章和前面没啥关联
{% endnote %}
原子性
问题分析
两个线程对初始值为 0 的静态变量,一个做自增,一个做自减,各做 5000 次,那么最终结果是 0 吗?
java
public class JMM01 {
static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i++;
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
多次运行以上的结果可能是正数、负数、零。为什么呢?
因为 Java 中对静态变量的自增、自减操作并不是原子操作,比如下面代码
java
public class JMM02 {
static int i = 0;
public static void main(String[] args) {
i++;
i--;
}
}
编译后的字节码文件
java
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field i:I
3: iconst_1
4: iadd
5: putstatic #2 // Field i:I
8: getstatic #2 // Field i:I
11: iconst_1
12: isub
13: putstatic #2 // Field i:I
16: return
对于 i++ 而言(注意 i 为静态变量),实际上会产生如下字节码指令
java
getstatic #2 // 获取静态常量 i 的值
iconst_1 // 准备常量 1
iadd // 加法(如果是局部变量,则调用的是iinc)
putstatic #2 // 将修改后的值存入静态变量 i
而对于 i---- 而言,也是类似的操作
java
getstatic #2 // 获取静态常量 i 的值
iconst_1 // 准备常量 1
isub // 减法
putstatic #2 // 将修改后的值存入静态变量 i
在多线程环境下,这些指令可能会被 CPU 交错的执行,就会导致我们看到的结果出现问题
Java 的内存模型如下,完成静态变量的自增、自减需要在主存与线程内存中进行数据交换

出现负数的情况
java
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
出现正数的情况
java
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
解决方法
使用 synchronized(同步关键字),语法如下
java
synchronized(obj) {
要作为原子操作的代码
}
解决上面的问题,在 i++ 和 i-- 操作处加锁
java
public class JMM03 {
static int i = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i++;
}
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
可以把 obj 想象成一间房间,线程 t1、线程 t2 想象成两个人。当线程 t1 执行到 synchronized(obj) 时,就好比 t1 进入了房间,并反手锁住了门,在门内执行 i++ 操作。此时如果 t2 也运行到了 synchronized(obj),它发现门被锁住了,只能在门外等待
当 t1 执行完 synchronized 块内的代码,此时才会解开门上的锁,从房间出来,t2 线程此时才可以进入房间,并反手锁住门,执行它的 i-- 操作。
{% note info %}
上例中的 t1 和 t2 线程都必须使用 synchronized 锁住同一个 obj 对象,如果 t1 锁住的是 x 对象,t2 锁住的是 y 对象,就好比两个人进入了两个不同的房间,没法起到同步的效果
{% endnote %}
可见性
可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改的结果。在单线程环境下,修改变量的值和读取变量的值都是在同一个线程内进行的,所以不存在可见性问题。但是在多线程环境下,由于每个线程都有自己的缓存,所以可能出现一个线程修改了共享变量的值,但是其他线程还是看到原来的旧值的情况。
退不出的循环
先来看一个现象,main 线程对 run 变量的修改,对于 t 线程不可见,导致 t 线程无法停止
java
public class JMM04 {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
//TODO
}
});
t.start();
Thread.sleep(1000); // 休眠 1 秒
run = false; // 此时将run改为false,按理说上面的while循环应该会结束
}
}
那这是为什么呢?我们来分析一下(提示:结合一下上篇文章的 JIT 优化)
初始状态:t 线程刚开始就从主存读取到了 run 的值到工作内存

因为 t 线程要频繁的从主存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存的高速缓存中,减少对主存中 run 的访问,提高效率

1 秒过后,main 线程修改了 run 值,并同步至主存,但是 t 现在已经是从自己工作内存的高速缓存中读取的 run,结果永远是 true

解决方法
可见性问题的方法是通过使用 volatile 关键字来声明共享变量。在使用了 volatile 关键字声明的共享变量上进行读写操作时,JVM 会保证所有线程都能够看到该变量的最新值,从而解决可见性问题。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
java
public class JMM04 {
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
//TODO
}
});
t.start();
Thread.sleep(1000);
run = false;
}
}
此时程序运行 1 秒后就会停下来了。
但是注意 volatile 变量不能保证原子性,仅用在一个写线程,多个读线程的情况
java
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false
synchronized 语句块既可以保证代码的原子性,同时也可以保证代码块内变量的可见性。但缺点是 synchronized 属于重量级操作,性能相对较低
{% note warning %}
如果在前面示例的死循环中,加入一条输出指令 System.out.println() 会发现,即使不加 volatile 修饰符,线程 t 也正确看到 run 变量的修改了,这是为什么呢?
因为 System.out.println() 方法有 synchronized 修饰,具有同步锁的效果,它会强制刷新缓存,从而强制线程从主内存中读取变量的值。这与 volatile 的作用相似,可以保证线程获取到最新的变量值。 --> 这个现象是对的,但是原因好像不是因为这个,不太懂
java
public void println(int x){
synchronized(this) {
print(x);
newLine( );
}
}
{% endnote %}
有序性
诡异的结果
先来看一段代码
java
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void method1(Person p) {
if(ready) {
p.age = num + num;
} else {
p.age = 1;
}
}
// 线程2 执行此方法
public void method2(Person p) {
num = 2;
ready = true;
}
Person 是一个对象,有一个属性 age 用来保存结果,那么上面的代码会有几种可能?
- 线程 1 先执行,此时
ready = false,进入 else 分支,结果是 1 - 线程 2 先执行,
num = 2,ready = true,线程 1 执行是,ready = true,执行 if 分支,同时num = 2,结果是 4 - 线程 2 先执行,
num = 2,还没来得及执行ready = true,此时线程 1 执行,ready = false,进入 else 分支,结果是 1
但是其实还有一种可能,结果是 0
这种情况下:线程 2 先执行 ready = true,切回到线程 1,进入 if 分支,相加为 0,再切回线程 2 执行 num = 2
这种现象叫:指令重排
指令重排是指在编译器或者 JIT 编译器优化过程中,为了提高程序的性能而重新排列指令的执行顺序,以便在运行时更加高效地执行。指令重排并不会改变程序的语义,但它可能会改变程序的执行顺序,从而导致程序出现错误或异常。(单线程下指令重排不会出现问题,多线程下指令重排会影响正确性)
指令重排需要通过大量测试才能发现,借助 java 并发压测工具 jcstress
在 JVM 中,指令重排主要有以下三种类型:
- 编译器重排:编译器在生成目标代码时对指令进行重排,以提高代码的性能。
- 运行时重排:JIT 编译器在运行时对字节码进行优化,对指令进行重排,以提高程序的性能。
- 处理器重排:现代处理器具有乱序执行的能力,可以根据需要重新排列指令的执行顺序,以提高指令的执行效率。
指令重排的好处是可以提高程序的性能,但也有风险。如果重排不当,可能会导致程序出现错误或异常。为了避免这种情况,JVM 提供了一些机制,例如 volatile 关键字、synchronized 关键字、final 关键字等,以保证程序的正确性。
运行如下 maven 命令
bash
mvn archetype:generate -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=com.demo.jmm -DartifactId=com.demo.jmm.my-test-project -Dversion=1.0-SNAPSHOT
修改生成的测试方法
java
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
执行 maven clean install,生成 jar 包
使用 java -jar 命令启动测试,结果如下
text
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
2 matching test results.
[OK] com.demo.jmm.ConcurrencyTest
(JVM args: [-XX:-TieredCompilation])
Observed state Occurrences Expectation Interpretation
0 4,118 ACCEPTABLE_INTERESTING !!!!
1 100,677,962 ACCEPTABLE ok
4 65,352,081 ACCEPTABLE ok
[OK] com.demo.jmm.ConcurrencyTest
(JVM args: [])
Observed state Occurrences Expectation Interpretation
0 4,446 ACCEPTABLE_INTERESTING !!!!
1 70,399,953 ACCEPTABLE ok
4 64,934,892 ACCEPTABLE ok
可以看到,出现结果为 0 的次数有 4118 次,虽然次数相对较少,但毕竟还是出现了
解决方法
使用 volatile 修饰的变量,可以禁用指令重排
java
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
volatile boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
结果如下
text
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
0 matching test results.
有序性理解
JVM 会在不影响正确性的前提下,调整语句的执行顺序,来看一下下面的代码
java
static int i;
static int j;
//在某个线程内执行如下赋值操作(单线程)
i = ...; // 较为耗时的操作
j = ...l // 简单的 操作
可以看到,不管是先执行 i 还是先执行 j,对最终的结果都不会产生影响,所以上面两条语句的执行顺序可以任意的排列组合
这种特性被称之为指令重排,多线程下的指令重排会影响正确性,例如著名的 double-checked-locking 模式实现单例
java
public final class Singleton {
private Singleton(){}
private static Singleton INSTANCE = null;
public static Singleton getInstance(){
// 实例没创建,才会进入内部的 synchronized 代码块
if (INSTANCE == null){
synchronized (Singleton.class){
// 也许有其他线程已经创建实例,所以再判断一次
if (INSTANCE == null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现的特点是 懒惰实例化 。首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
但是在多线程环境下,上面的代码是有问题的,INSTANCE = new Singleton(); 对应的字节码如下
java
0: new #2 // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // Method "<init>":()V
7: putstatic #4 // Field
其中 4 和 7 两步的顺序不是固定的,也许 jvm 会优化为:先将引用地址赋给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1、t2 按如下时间序列执行
text
时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != null(synchronized块外),直接返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)
此时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的将是一个未完成初始化的单例
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排
happens-before
happens-before 规定了哪些写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结。抛开以下 happens-before 规则,JMM 不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读是可见的
{% note info %}
这是帮助我们确定可见性和有序性的一套准则,遵循它
{% endnote %}
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其他线程对该变量的读可见
java
static int x;
static Object m = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (m) {
x = 10;
}
}, "t1").start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
}, "t2").start();
}
线程 t1 先执行,在线程 t2 中,当获取了对象 m 的锁之后,线程可以读取到线程 t1 对变量 x 的写入结果。
线程对 volatile 变量的写,对接下来其他线程对该变量的读可见
java
volatile static int x;
public static void main(String[] args) {
new Thread(() -> {
x = 10;
}, "t1").start();
new Thread(() -> {
System.out.println(x);
}, "t2").start();
}
因为 x 由 volatile 修饰,在线程 t2 中,当读取变量 x 的值时,可以看到线程 t1 对变量 x 的最新写入结果,而不会读取到变量 x 的旧值。
线程 start 前对变量的写,对该线程开始后对该变量的读可见
java
static int x;
public static void main(String[] args) {
x = 10;
new Thread(() -> {
System.out.println(x);
}, "t2").start();
}
线程结束前对变量的写,对其他线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join() 等待它结束)
java
static int x;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
}
当主线程中读取变量 x 的值时,可以看到线程 t1 对变量 x 的写入结果。
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)
java
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println(x); // 10
break;
}
}
}, "t2");
t2.start();
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
x = 10;
t2.interrupt();
}, "t1").start();
while (!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x); // 10
}
以上代码创建了两个线程 t1 和 t2。线程 t2 在一个无限循环中不断检查自身的中断状态,如果发现自己被打断则打印变量 x 的值并跳出循环,线程 t1 会在 1 秒后修改变量 x 的值并打断线程 t2。
线程 t1 在打断线程 t2 之前对变量 x 的写操作对于其他线程得知线程 t2 被打断后的读操作可见。在本例中,线程 t1 在修改变量 x 的值并打断线程 t2 之前会先睡眠 1 秒,因此线程 t2 的循环会在线程 t1 修改变量 x 的值之后才会被打断。此时,线程 t2 中对变量 x 的读操作就能看到线程 t1 对变量 x 的修改。同理主线程也是
对变量默认值(0, false, null) 的写,对其它线程对该变量的读可见
{% note info %}
具有传递性,如果 x hb->y 并且 y hb-> z 那么有 x hb->z
这里的变量都是指成员变量或者静态成员变量
{% endnote %}
CAS 与原子类
CAS
CAS 即 Compare And Swap,它体现的是一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作
java
// 需要不断尝试
while(true) {
int 旧值 = 共享变量; // 比如当前共享变量是 0
int 结果 = 旧值 + 1; // 在旧值的基础上 +1 ,结果是 1
/*
此时如果别的线程将共享变量改为了 5,那么本线程的正确结果 1 就作废了
此时compareAndSwap 返回 false,重新尝试
直到compareAndSwap 返回 true,表示本线程做修改的同时,别的线程没有干扰
*/
if(compareAndSwap(旧值, 结果)) {
// 比较旧值和当前共享变量是否相等
// 成功,退出循环
}
}
获取共享变量时,为了保证该共享变量的可见性需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下
- 因为没有使用
synchronized,所以线程不会陷入阻塞,没有上下文切换,这是效率提升的因素之一 - 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响,因为 CAS 不像
synchronized,拿不到锁就阻塞,但是 CAS 是一直重试不会阻塞。 - CAS 在单核 CPU 下也可以使用,因为单核 CPU 也有线程上下文切换,仍然可能出现共享变量的并发修改问题。CAS 能保证比较和更新的原子性。但是 CAS 的问题在于失败后通常会自旋重试。在单核 CPU 下,如果某个线程持有资源但被切走,其他线程一直 CAS 自旋会浪费 CPU 时间片,因此 CAS 不适合长时间等待或竞争非常激烈的场景。对于这种场景,阻塞式锁可能更合适。
CAS 底层依赖于一个Unsafe类来直接调用操作系统底层的 CAS 指令,下面是直接使用Unsafe对象进行线程安全保护的一个例子
java
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class JMM06 {
public static void main(String[] args) throws InterruptedException {
DataContainer dc = new DataContainer();
int count = 5;
Thread t1 = new Thread(() -> {
for (int i = 0; i < count; i++) {
dc.increase();
}
});
t1.start();
t1.join();
System.out.println(dc.getData());
}
}
class DataContainer {
private volatile int data;
static final Unsafe unsafe;
static final long DATA_OFFSET;
static {
try {
// Unsafe 对象不能直接调用,只能通过反射获得
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
}
try {
// data 属性在 DataContainer 对象中的偏移量,用于 Unsafe 直接访问该属性
DATA_OFFSET = unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
} catch (NoSuchFieldException e) {
throw new Error(e);
}
}
public void increase() {
int oldValue;
while (true) {
// 获取共享变量旧值,可以在这一行加入断点,修改 data 调试来加深理解
oldValue = data;
// cas 尝试修改 data 为 旧值 + 1,如果期间旧值被别的线程改了,返回 false
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue + 1)) {
return;
}
}
}
public void decrease() {
int oldValue;
while (true) {
oldValue = data;
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue - 1)) {
return;
}
}
}
public int getData() {
return data;
}
}
我们真正写代码的时候不用自己写上面这个代码,因为 Java 已经帮我们封装了原子类
乐观锁与悲观锁
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗
synchronized是基于悲观锁的思想:最悲观的估计,得防着其他线程来修改共享变量,我上了锁你们都别想改,我改完了再解开锁,你们才有机会来
原子操作类
JUC(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean 等,它们底层就是采用 CAS 技术+volatile 来实现的
可以使用 AtomicInteger 改写之前的例子
java
public class JMM07 {
// 创建原子整数对象
private static AtomicInteger i = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException{
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndIncrement(); // 获取并且自增 i++
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndDecrement(); // 获取并且自增 i--
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// 最终的结果总是0
System.out.println(i);
}
}
synchronized 优化
Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word),Mark Word 平时存储这个对象的哈希码、分代年龄,当加锁时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程ID 等内容
轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么就可以使用轻量级锁来优化,就好比
学生 A(线程A)用课本占座,上了半节课就出门了(CPU 时间到了),回来一看,发现课本还在,说明没有竞争,继续上他的课
如果此时其他学生 B(线程B)来了,会告知学生 A(线程A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程
而重量级锁就不是用课本占座那么简单了,在学生 A 走之前,把座位用铁栅栏围了起来
假设有两个方法同步块,利用同一个对象加锁
java
static Object obj = new Object();
public static void method1(){
synchronized(obj) {
// 同步块 A
method2();
}
public static void method2(){
synchronized(obj) {
// 同步块 B
}
}
每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
| 线程 1 | 对象 Mark Word | 线程 2 |
|---|---|---|
| 访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
| CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
| 成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
| 执行同步块 A | 00(轻量锁)线程 1 锁记录地址 | - |
| 访问同步块 B,把 Mark 复制到线程 1 的锁记录 | 00(轻量锁)线程 1 锁记录地址 | - |
| CAS 修改 Mark 为线程 1 锁记录地址 | 00(轻量锁)线程 1 锁记录地址 | - |
| 失败(发现是自己的锁) | 00(轻量锁)线程 1 锁记录地址 | - |
| 锁重入 | 00(轻量锁)线程 1 锁记录地址 | - |
| 执行同步块 B | 00(轻量锁)线程 1 锁记录地址 | - |
| 同步块 B 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
| 同步块 A 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
| 成功(解锁) | 00(轻量锁)线程 1 锁记录地址 | - |
| - | 01(无锁) | - |
| - | 01(无锁) | 访问同步块 A,把 Mark 复制到线程 2 的锁记录 |
| - | 01(无锁) | CAS 修改 Mark 为线程 2 锁记录地址 |
| - | 00(轻量锁)线程 2锁记录地址 | 成功(加锁) |
| - | ... | ... |
锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法完成,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时就需要进行锁膨胀,将轻量级锁变为重量级锁
java
public static void method1(){
synchronized(obj) {
// 同步块 A
}
public static void method2(){
synchronized(obj) {
// 同步块 B
}
}
| 线程 1 | 对象 Mark Word | 线程 2 |
|---|---|---|
| 访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
| CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
| 成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | - |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 访问同步块,把 Mark 复制到线程 2 |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为线程 2 锁记录地址 |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 失败(发现别人已经占了锁) |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为重量锁 |
| 执行同步块 | 10(重量锁)重量锁指针 | 阻塞中 |
| 执行完毕 | 10(重量锁)重量锁指针 | 阻塞中 |
| 失败(解锁) | 10(重量锁)重量锁指针 | 阻塞中 |
| 释放重量锁,唤起阻塞线程竞争 | 01(无锁) | 阻塞中 |
| - | 10(重量锁) | 竞争重量锁 |
| - | 10(重量锁) | 成功(加锁) |
| - | ... | ... |
重量锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
- Java 7 之后不能控制是否开启自旋功能
自旋重试成功的情况
| 线程 1 (cpu 1 上) | 对象 Mark | 线程 2 (cpu 2 上) |
|---|---|---|
| - | 10(重量锁) | - |
| 访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
| 成功(加锁) | 10(重量锁)重量锁指针 | - |
| 执行同步块 | 10(重量锁)重量锁指针 | - |
| 执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
| 执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行完毕 | 10(重量锁)重量锁指针 | 自旋重试 |
| 成功(解锁) | 01(无锁) | 自旋重试 |
| - | 10(重量锁)重量锁指针 | 成功(加锁) |
| - | 10(重量锁)重量锁指针 | 执行同步块 |
| - | ... | ... |
自旋重试失败的情况
| 线程 1(cpu 1 上) | 对象 Mark | 线程 2(cpu 2 上) |
|---|---|---|
| - | 10(重量锁) | - |
| 访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
| 成功(加锁) | 10(重量锁)重量锁指针 | - |
| 执行同步块 | 10(重量锁)重量锁指针 | - |
| 执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
| 执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行同步块 | 10(重量锁)重量锁指针 | 阻塞 |
| - | ... | ... |
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的
hashCode也会撤销偏向锁 - 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
- 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用
-XX:-UseBiasedLocking禁用偏向锁
假设有两个方法同步块,利用同一个对象加锁
java
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized(obj) {
// 同步块 B
}
}
| 线程 1 | 对象 Mark |
|---|---|
| 访问同步块 A,检查 Mark 中是否有线程 ID | 101(无锁可偏向) |
| 尝试加偏向锁 | 101(无锁可偏向)对象 hashCode |
| 成功 | 101(无锁可偏向)线程ID |
| 执行同步块 A | 101(无锁可偏向)线程ID |
| 访问同步块 B,检查 Mark 中是否有线程 ID | 101(无锁可偏向)线程ID |
| 是自己的线程 ID,锁是自己的,无需做更多操作 | 101(无锁可偏向)线程ID |
| 执行同步块 B | 101(无锁可偏向)线程ID |
| 执行完毕 | 101(无锁可偏向)对象 hashCode |
其他优化
减少上锁时间
同步代码块中尽量短
减少锁的粒度
将一个锁拆分为多个锁提高并发度,例如
ConcurrentHashMapLongAdder分为base和cells两部分。没有并发争用的时候或者是cells数组正在初始化的时候,会使用 CAS 来累加值到base,有并发争用,会初始化cells数组,数组有多少个cell,就允许有多少线程并行修改,最后将数组中每个cell累加,再加上base就是最终的值LinkedBlockingQueue入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
锁粗化
多次循环进入同步块不如同步块内多次循环
另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)
java
new StringBuffer().append("a").append("b").append("c");
锁消除
JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
读写分离
- CopyOnWriteArrayList
- ConyOnWriteSet
