字节码与类加载
封面来源:由博主个人绘制,如需使用请联系博主。
参考链接:黑马程序员JVM快速入门
本文涉及的代码:mofan212/jvm-demo
除特别注明外,本文内容都基于 JDK 1.8

1. 类文件结构
Oracle Java 8 JVM 规范 class 文件格式:The class File Format
存在 HelloWorld.java:
1 | public class HelloWorld { |
使用 javac -parameters -d . HelloWorld.java 命令编译并保留方法中的参数信息。
再使用 od -t xC HelloWorld.class (这是 Linux 环境下的命令,在 Windows 环境下可以用 Git 里执行)查看字节码文件内容:
1 | 0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09 |
根据 JVM 规范,类文件结构如下:
1 | ClassFile { |
1.1 魔数
0~3 字节,表示是否是 class 类型的文件。
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
1.2 版本
4~7 字节,表示类的版本。
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
34 是十六进制,对应十进制 52,表示类的版本是 Java 8。
1.3 常量池
| Constant Type | Value |
|---|---|
CONSTANT_Class |
7 |
CONSTANT_Fieldref |
9 |
CONSTANT_Methodref |
10 |
CONSTANT_InterfaceMethodref |
11 |
CONSTANT_String |
8 |
CONSTANT_Integer |
3 |
CONSTANT_Float |
4 |
CONSTANT_Long |
5 |
CONSTANT_Double |
6 |
CONSTANT_NameAndType |
12 |
CONSTANT_Utf8 |
1 |
CONSTANT_MethodHandle |
15 |
CONSTANT_MethodType |
16 |
CONSTANT_InvokeDynamic |
18 |
8~9 字节,表示常量池长度:
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
23 十进制对应 35,表示常量池有 #1 ~ #34 项,#0 项不计入,也没有值。
#1
#1 项 0a 对应十进制 10,根据上表查询得知,表示 CONSTANT_Methodref,即方法信息。00 06 和 00 15(21) 表示它引用了常量池中 #6 和 #21 项来获取这个方法的所属类和方法名:
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
00 06 对应第 #6 项,表示 Class 信息,然后它又引用了第 #28 项,指明具体的 Class 是 java/lang/Object。
00 15 对应第 #21 项,表示方法名、参数类型和返回值类型。它分别引用 #7 项表名方法名是 <init>,即构造方法;然后又引用 #8 项,指定方法描述符为 ()V,即无参无返回值。
#6
#6 项 07 表示一个 Class 信息,00 1c(28) 表示它引用了常量池中 #28 项:
0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07 0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
#28 项 01 表示一个 utf8 串,00 10(16) 表示长度,指 java/lang/Object:
0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61 0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61
#21
#21 项 0c 表示名和类型,00 07 00 08 又引用了常量池的 #7 和 #8 项:
0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e
#7 项 01 表示一个 utf8 串,00 06 是长度,3c 69 6e 69 74 3e 表示 <init>(构造方法):
0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
#8 项 01 表示一个 utf8 串,00 03 是长度,28 29 56 表示 ()V,即无参、无返回值:
0000040 00 1c 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
后续常量池字节码内容继续按照此方式进行分析即可,建议:
- 参考中文 JVM 圣经,周志明老师的《深入理解 Java 虚拟机》
- 查看字节码文件使用 IDEA 插件 jClassLib
1.4 访问标识与继承信息
| Flag Name | Value | Interpretation |
|---|---|---|
ACC_PUBLIC |
0x0001 | Declared public; may be accessed from outside its package. |
ACC_FINAL |
0x0010 | Declared final; no subclasses allowed. |
ACC_SUPER |
0x0020 | Treat superclass methods specially when invoked by the invokespecial instruction. |
ACC_INTERFACE |
0x0200 | Is an interface, not a class. |
ACC_ABSTRACT |
0x0400 | Declared abstract; must not be instantiated. |
ACC_SYNTHETIC |
0x1000 | Declared synthetic; not present in the source code. |
ACC_ANNOTATION |
0x2000 | Declared as an annotation type. |
ACC_ENUM |
0x4000 | Declared as an enum type. |
00 21 (由上表中的 0x0001 和 0x0020 相加获得)表示该 class 是一个公共的类:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
00 05 表示根据常量池中的 #5 项找到 本类 的全限定名:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
00 06 表示根据常量池中的 #6 找到 父类 的全限定名:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
00 00 表示该类实现的接口数量,此处为 0:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
1.5 Field 信息
00 00 表示成员变量数量,此处为 0:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
| FieldType term | Type | Interpretation |
|---|---|---|
B |
byte |
signed byte |
C |
char |
Unicode character code point in the Basic Multilingual Plane, encoded with UTF-16 |
D |
double |
double-precision floating-point value |
F |
float |
single-precision floating-point value |
I |
int |
integer |
J |
long |
long integer |
L ClassName ; |
reference |
an instance of class ClassName |
S |
short |
signed short |
Z |
boolean |
true or false |
[ |
reference |
one array dimension |
1.6 Method 信息
00 02 表示方法数量,本类为 2(默认无参构造方法与 main 方法):
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
一个方法由访问修饰符、名称、参数描述、方法属性数量、方法属性组成。
构造方法
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01 0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01 0000720 00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00 0000740 00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00 0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00
- 红色代表访问修饰符
public - 蓝色代表引用了常量池
#07项作为方法名称,对应<init> - 绿色代表引用了常量池
#08项作为方法参数描述,对应()V,即无参无返回 - 黄色代表方法属性数量,本方法是 1
- 红色代表方法属性,其中:
00 09表示引用了常量池#09项,是 Code 属性00 00 00 2f表示此属性的长度是 4700 01表示 操作数栈 的最大深度00 01表示 局部变量表 最大槽(slot)数00 00 00 05表示字节码长度,本例是 52a b7 00 01 b1是字节码指令00 00 00 02表示方法细节属性数量,本例是 200 0a表示引用了常量池#10项,对应LineNumberTable属性(字节码行号与 Java 代码行号对应,方便调试)00 00 00 06表示此属性的总长度,本例是 600 01表示LineNumberTable长度00 00表示字节码行号,对应 Java 源码行号00 04
00 0b表示引用了常量池#11项,对应LocalVariableTable属性(局部变量表)00 00 00 0c表示此属性的总长度,本例是 1200 01表示LocalVariableTable长度00 00表示局部变量生命周期开始,相对于字节码的偏移量00 05表示局部变量覆盖的长度范围00 0c表示局部变量名称,本例引用了常量池#12项,对应this00 0d表示局部变量的类型,本例引用了常量池#13项,即this类型,当前类的类型00 00表示局部变量占有的槽位(slot)编号,本例是 0
main方法
main 方法分析方式与构造方法一致,不再赘述。
1.7 附加属性
0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00 0001120 00 00 02 00 14
00 01表示附加属性数量00 13表示引用了常量池#19项,即 SourceFile,表示字节码文件对应的 Java 源文件名称00 00 00 02表示此属性长度00 14表示引用了常量池#20项,即HelloWorld.java
2. 字节码指令
Oracle Java 8 JVM 规范指令集:The Java Virtual Machine Instruction Set
2.1 入门
调用父类
Object的构造方法
对应字节码指令:
2a b7 00 01 b1
- 查询 JVM 规范得知,
2a表示aload_0,即加载 slot 0 的局部变量(加载到操作数栈),对应this,作为后续执行invokespecial构造方法调用的参数 b7表示invokespecial,预备调用构造方法00 01表示需要调用的构造方法,查询常量池#1项,得到调用的构造方法信息b1表示返回
调用
main方法
对应字节码指令:
b2 00 02 12 03 b6 00 04 b1
- 查询 JVM 规范得知,
b2对应getstatic,用于加载静态变量 00 02引用常量池中#2项,表示getstatic需要加载的静态变量信息,简单来说是System.out12对应ldc(load constant),用于加载参数03引用常量池中#3项,即字符串常量Hello Worldb6对应invokevirtual,预备调用成员方法00 04引用常量池中#4项,即println方法b1表示返回
2.2 javap 工具
Oracle 提供了 javap 工具来反编译 class 文件:
1 | javap -v HelloWorld.class |
-v 参数表示输出 class 文件的详细信息。
2.3 图解方法执行流程
有如下 Java 代码:
1 | package indi.mofan; |
编译 Demo1 后,使用 javap -v class文件 查看字节码文件。
常量池载入运行时常量池
「常量池」指的是 class 文件中的 Constant pool,「运行时常量池」指的是 JVM 内存结构中「方法区」的一部分。

对于 short 范围内的整数不会存入运行时常量池中,而是与字节码指令一起存放。short 的最大值是 32767,加一后大于最大值,因此 32768 会被存放到运行时常量池中。
方法字节码载入方法区

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

通过 javap 命令查看的字节码文件中存在:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4
stack=2表示操作数栈的深度是 2locals=4表示局部变量表有 4 个槽位
执行引擎开始执行字节码
bipush 10 表示将一个 byte 压入操作数栈(由于操作数栈的宽度占 4 个字节,压入内容不足 4 个字节时,会补齐 4 个字节),类似的指令还有:
sipush将一个 short 压入操作数栈(其长度会补齐 4 个字节)ldc将一个 int 压入操作数栈ldc2_w将一个 long 压入操作数栈(因为 long 是 8 个字节,需要分两次压入)- 小数字和字节码指令存放在一起,超过 short 范围的数字会存入常量池

istore_1 表示弹出操作数栈栈顶数据,存入局部变量表的 slot 1:


ldc #3 表示从运行时常量池中加载 #3 数据到操作数栈。
注意:Short.MAX_VALUE 对应 32767,加一操作能够在编译器确定,因此运行时常量池中直接存放了 32768。

根据前面对 istore_1 的讲解,istore_2 表示弹出操作数栈栈顶数据,存入局部变量表的 slot 2:


接着从局部变量表里读取参与加法运算的两个数:
iload_1表示从局部变量表slot 1里读取数据iload_2表示从局部变量表slot 2里读取数据


然后执行 iadd 弹出堆操作数栈里的两个整型数据进行相加:

再把运算结果压入操作数栈顶:

istore_3 再弹出操作数栈栈顶数据,存入局部变量表的 slot 3:


到此,局部变量 a、b、c 均执行完毕。
getstatic #4 从运行时常量池中获取成员变量 System.out 的引用,然后把该对象的 引用 添加进操作数栈中:


在调用 println 方法前,需要先加载所需的参数,使用 iload_3 从局部变量表 slot 3 里读取数据:


使用 invokevirtual #5 调用方法打印数据:
- 找到运行时常量池
#5项 - 定位到方法区
java/io/PrintStream.println:(I)V方法 - 生成新的栈帧(分配 locals、stack 等)
- 传递参数,执行新栈帧中的字节码

目标方法执行完毕后,弹出栈帧。
清除 main 操作数栈的内容:

完成 main 方法调用后,弹出 main 栈帧,程序结束。
2.4 练习-分析 a++
目的:从字节码角度分析 a++ 相关题目。
源码:
1 | /** |
得到如下部分字节码:
1 | public static void main(java.lang.String[]); |
分析:
iinc指令是直接在局部变量slot上进行运算的a++和++a的区别是先执行iload还是先执行iinc
a++ 会被分解为两条字节码指令:
iload_1iinc 1,1

++a 也会被分解为两条字节码指令,内容也与 a++ 一样,但顺序不同:
-
iinc 1,1 -
iload_1

2.5 条件判断指令
| 指令 | 助记符 | 含义 |
|---|---|---|
| 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 | 两个引用是否 != |
byte、short和char都会按int比较,因为操作数栈都是 4 字节goto用来进行跳转到指定行号的字节码
1 | public class Demo1_3 { |
对应的部分字节码:
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 的比较,那应该怎么比较它们呢?
可以参考:lcmp
2.6 循环控制指令
循环控制指令还是先前的条件判断指令,例如 while 循环:
1 | public class Demo1_4 { |
对应的部分字节码:
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 循环:
1 | public class Demo1_5 { |
对应的部分字节码:
0: iconst_0 1: istore_1 2: iinc 1, 1 5: iload_1 6: bipush 10 8: if_icmplt 2 11: return
最后再看看 for 循环:
1 | public class Demo1_6 { |
对应的部分字节码:
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
比较 while 和 for 的字节码,会发现它们是一模一样的,殊途也能同归。
2.7 练习 - 判断结果
从字节码角度分析下列代码的运行结果:
1 | public class Demo1_7 { |
对应的部分字节码:
0: iconst_0 1: istore_1 2: iconst_0 3: istore_2 4: iload_1 5: bipush 10 7: if_icmpge 21 10: iload_2 11: iinc 2, 1 14: istore_2 15: iinc 1, 1 18: goto 4 21: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 24: iload_2 25: invokevirtual #3 // Method java/io/PrintStream.println:(I)V 28: return
执行完前 4 步后,局部变量表 slot 1 存放 i,slot 2 存放 x。
加载局部变量表 slot 1 的数据,压入操作数栈,再执行 bipush 10 将 10 也压入操作数栈,使用 if_icmpge 21 比较前者是否大于等于后者,如果是,调到第 21 步,否则依次执行下一步。
不满足 if_icmpge 时,加载 slot 2 的数据(即 x,此时为 0),局部变量表 slot 2 位置的数据(也是 x,但先前已经加载到操作数栈上的 x 不会受影响,操作数栈上的 x 依旧是 0)加一,弹出操作数栈的栈顶元素,写入 slot 2 的位置,也就是让操作数栈上的 0 覆盖先前在局部变量表上加一的 1,这样操作后,局部变量表 slot 2 的数据并没有发生任何变化。
后续继续循环时,slot 2 的数据最终总是 0,因此最终打印 x 时还是 0。
2.8 构造方法
<cinit>()V
1 | public class Demo1_8_1 { |
编译器会按从上到下的顺序,收集所有 static 静态代码块和静态变量赋值的代码,最终合并成一个特殊的方法 <cinit>()V,对应的部分字节码:
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 方法会在类加载的初始化阶段被调用。
因此上述代码里 i 最终的值为 30。
<init>()V
1 | public class Demo1_8_2 { |
编译器会按从上到下的顺序,收集所有 {} 代码块(初始化代码块)和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后。
对应的部分字节码:
0: aload_0 1: invokespecial #1 // super."<init>":()V 4: aload_0 5: ldc #2 // <- "s1" 7: putfield #3 // -> this.a 10: aload_0 11: bipush 20 // <- 20 13: putfield #4 // -> this.b 16: aload_0 17: bipush 10 // <- 10 19: putfield #4 // -> this.b 22: aload_0 23: ldc #5 // <- "s2" 25: putfield #3 // -> this.a 28: aload_0 // -------------------------------- 29: aload_1 // <- slot 1(a) "s3" | 30: putfield #3 // -> this.a | 33: aload_0 | 34: iload_2 // <- slot 2(b) 30 | 35: putfield #4 // -> this.a --------------------- 38: return LineNumberTable: ... LocalVariableTable: Start Length Slot Name Signature 0 39 0 this Lindi/mofan/Demo1_8_2; 0 39 1 a Ljava/lang/String; 0 39 2 b I
2.9 方法调用
1 | public class Demo1_9 { |
对应的部分字节码:
0: new #2 // class indi/mofan/Demo1_9 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
可以发现:
- 调用构造方法、私有方法、
final方法时,使用invokespecial指令 - 调用公共方法时,使用
invokevirtual指令 - 调用静态方法时,使用
invokestatic指令
其中,invokespecial 和 invokestatic 属于静态绑定,能够在编译期确定调用的目标方法,性能更高;而 invokevirtual 属于动态绑定,调用的公共方法可能是当前类的、也可能是父类的(方法重写),在运行时才能确定调用的目标方法,性能相对更低。
new一个对象
1 | Demo1_9 obj = new Demo1_9(); |
上述 Java 代码对应 4 步字节码:
new:在堆空间为需要创建的对象分配内存,分配成功后把对象引用放入操作数栈dup:复制操作数栈上的栈顶数据(现在操作数栈里有两份相同的引用)invokespecial:弹出操作数栈的栈顶数据(弹出一份引用)并调用构造方法astore_1:弹出操作数栈的栈顶数据(弹出另一份引用),并将其存入局部变量表的slot 1
使用实例对象调用静态方法
可以直接按照 类名.静态方法名() 的形式去调用静态方法,但使用 实例对象.静态方法名() 来调用静态方法也不会编译报错。
那它们在字节码指令层面上有什么区别吗?
20: aload_1 21: pop 22: invokestatic #7 // Method test4:()V 25: invokestatic #7 // Method test4:()V
20~22 对应使用 实例对象.静态方法名() 的形式来调用静态方法,25 对应使用 类名.静态方法名() 的形式来调用静态方法。
当使用 实例对象.静态方法名() 来调用静态方法时,先执行 aload_1 从将局部变量表中 slot 1 位置的数据加载到操作数栈中,由于调用静态方法并不需要实例对象,因此紧接着执行 pop 弹出操作数栈的栈顶元素,最后再使用 invokestatic 执行静态方法。
因此在日常编码时 不 推荐使用 实例对象.静态方法名() 的形式来调用静态方法,因为这会多出两条冗余的字节码指令。
2.10 多态的原理
运行测试代码
运行代码时添加 JVM 参数 -XX:-UseCompressedOops -XX:-UseCompressedClassPointers 禁用指针压缩:
1 | package indi.mofan; |
在终端使用 jps 命令,查看 Java 进程 id:
29744 Launcher 21364 Jps 12252 Main 26396 HSDB 29772 Demo1_10
目标进程 id 是 29772。
使用 HSDB 工具
进入 JDK 安装目录,执行:
1 | jdk 8 |
如果在 Windows 平台上运行时出现「‘.’ 不是内部或外部命令,也不是可运行的程序 或批处理文件。」错误,这是在 Windows 系统上使用了 Linux/Unix 风格的路径分隔符,把 / 改成 \ 即可:
1 | java -cp .\lib\sa-jdi.jar sun.jvm.hotspot.HSDB |
进入 HSDB - HotSpot Debugger 页面,点击右上角 File,再点击「Attach to HotSpot process…」并输入前面通过 jps 命令得到的进程 id:

点击 OK,耐心等待:

点击 HSDB 工具栏的 Tools,再点击「Find Object by Query」:

SOQL 的语法与 SQL 类似,比如现在需要查询 Java 进程中 indi.mofan.Demo1_10.Dog 对象:
1 | select d from indi.mofan.Demo1_10$Dog d |

查询后会返回 Dog 对象的内存地址,点击该内存地址:

前 8 个字节组成 Mark Word,对应上图中的 _mark,其中包含对象的 HashCode、锁标记信息等。
后 8 个字节是对象的类型指针,对象上图中的 _metadata._klass,可以看到该对象的类型是 indi.mofan.Demo1_10$Dog。
如果需要查看这两部分信息在内存中的详细信息,点击 HSDB 工具栏的 Windows,再点击「Console」,输入以下命令:
1 | mem 0x000001b60bf1d0e8 2 |
其中 0x000001b60bf1d0e8 是对象地址信息,2 是需要查看几个 Word 信息,这里需要查看全部对象头信息,共两部分,因此键入 2:

第一个表示 Mark Word 比较简单。第二个表示复制类型指针信息,即 0x000001b492985550。
点击 HSDB 工具栏的 Tools,再点击「Inspector」,输入刚刚复制的类型指针信息,然后回车:

这就是 Dog 类型在 JVM 虚拟机中的形式。
vtable
多态方法存在于 vtable(虚方法表)中。
vtable 在类结构信息的最后:

可以看到 vtable 没有具体的地址信息,但可以在类的内存偏移地址上加上 1B8 得到:
1 | 0x000001b492985550 + 1B8 = 0x000001B492985708 |
先前对 Dog 对象进行 Inspector 时能够看到 vtable 的长度是 6:

打开 Command Line,执行:
1 | mem 0x000001B492985708 6 |
其中:
0x000001B492985708是计算出的vtable内存地址6是vtable长度

可以看到 Dog 类的 vtable 里有 6 个动态方法。
点击 HSDB 工具栏的 Tools,再点击「Class Browser」查看类信息,确认 Dog 类中的动态方法是来自于哪:

注意: 如果 Dog 类中的 eat() 方法地址并不在命令行输出的 6 个动态方法中,可以尝试更换 JDK 重试,比如由 Zulu JDK8 更换为 Oracle JDK8,尽量使用 HotSpot JVM。


小结
当执行 invokevirtual 指令时:
- 先通过栈帧中的对象引用找到对象
- 分析对象头,找到对象的实际 Class
- Class 结构中有
vtable(在类加载的链接阶段根据方法的重写规则生成好) - 查表得到方法的具体地址
- 执行目标方法的字节码
2.11 异常处理
try-catch
1 | public class Demo1_11_1 { |
对应的部分字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
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
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/Exception;
0 13 0 args [Ljava/lang/String;
2 11 1 i I
StackMapTable: ...
使用 try-catch 块后,生成的字节码会多出一个 Exception table 结构。其中的 [from, to) 是一个前闭后开的检测范围,比如这里的 [2, 5) 表示检测第 2 行字节码到第 5 行字节码(不包括第 5 行),一旦这个范围的字节码执行时出现异常,再判断异常类型是否与 type 匹配,如果匹配,则跳到 target 对应的字节码行号。
第 8 行字节码是 astore_2,表示将异常对象 e 的引用存入局部变量表的 slot 2 位置。
多个 single-catch 块的情况
1 | public class Demo1_11_2 { |
对应的部分字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
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
LineNumberTable: ...
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
StackMapTable: ...
多个 catch 块与单个 catch 块类似,只不过由于只能进入 Exception table 中的一个分支,所以局部变量表 slot 2 会被复用。
multi-catch 的情况
1 | public class Demo1_11_3 { |
对应的部分字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: ldc #2 // class indi/mofan/Demo1_11_3
2: ldc #3 // String test
4: iconst_0
5: anewarray #4 // class java/lang/Class
8: invokevirtual #5 // 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 #6 // class java/lang/Object
18: invokevirtual #7 // 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 #11 // 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
LineNumberTable: ...
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;
StackMapTable: ...
multi-catch 与单个 single-catch、多个 single-catch 类似,只不过在 Exception table 里存在多个 from、to 和 target 相同、但 type 不同的异常信息。
finally 块
1 | public class Demo1_11_4 { |
对应的部分字节码:
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: iconst_0
1: istore_1 // 0 -> i
2: bipush 10 // try ----------------------------------------
4: istore_1 // 10 -> i |
5: bipush 30 // finally |
7: istore_1 // 30 -> i |
8: goto 27 // return -------------------------------------
11: astore_2 // catch Exception -> e -----------------------
12: bipush 20 // |
14: istore_1 // 20 -> i |
15: bipush 30 // finally |
17: istore_1 // 30 -> i |
18: goto 27 // return -------------------------------------
21: astore_3 // catch any -> slot 3 ------------------------
22: bipush 30 // finally |
24: istore_1 // 30 -> i |
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 // 剩余的异常类型,比如 Error
11 15 21 any // 剩余的异常类型,比如 Error
LineNumberTable: ...
LocalVariableTable: ...
根据字节码可知,整个代码分为 3 个分支:
- 先执行
try块的代码,然后执行finally块里的代码 - 如果执行
try块里的代码抛出了Exception,那么会执行catch块里的代码,最后再执行finally块里的代码 - 执行
try块或catch块里的代码时还可能抛出与Exception同级的错误,比如Error,它们无法被捕获,但最后也要执行finally块里的代码
综上,finally 块中的代码被复制了 3 份,分别放入 try 流程、catch 流程以及 catch 剩余的异常类型流程。
2.12 练习 - finally 面试题
finally出现了return
运行以下代码,控制台会输出什么呢?
1 | public class Demo1_12_1 { |
对应的部分字节码:
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
LineNumberTable: ...
StackMapTable: ...
-
由于
finally中的ireturn被插入到了所有可能的流程中,因此最终返回结果以finally中的为准 -
第 2 行字节码的
istore_0似乎没什么用,但真的是这样吗? -
与先前的例子相比,字节码中没有
athrow命令了,因此:如果在finally中出现了return,会吞掉异常,比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class Demo1_12_1_1 {
public static void main(String[] args) {
// 始终返回 20,且没有异常
System.out.println(test());
}
public static int test() {
try {
int i = 1 / 0;
return 10;
} finally {
return 20;
}
}
}
finally对返回值的影响
运行以下代码,控制台又会输出什么呢?
1 | public class Demo1_12_2 { |
对应的部分字节码:
public static int test();
descriptor: ()I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
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
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I
StackMapTable: ...
finally 块中的数据没有被 return,生成的字节码指令中有 athrow 指令,如果出现异常不会被吞。
2.13 synchronized
1 | public class Demo1_13 { |
对应的部分字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
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 (synchornized 开始)
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
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 lock Ljava/lang/Object;
StackMapTable: ...
注意: 方法级别的 synchronized 关键字不会在字节码指令中有所体现。
3. 编译期处理
所谓 语法糖,指 Java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换了一些代码,主要是为了减轻开发者的负担,算是 Java 编译器给开发者的一个额外「福利」。
注意, 本节代码分析将借助 javap、IDEA 的反编译功能、IDEA 的 jClassLib 插件等工具。编译器的转换结果就是 class 字节码,本节为了便于阅读,将给出几乎等价的 Java 源码,这并不表示编译器还会生成中间 Java 源码。
3.1 默认构造器
1 | public class Candy1 { |
编译后生成的字节码等价于:
1 | public class Candy1 { |
3.2 自动拆装箱
在 JDK 5 中添加了自动拆装箱特性:
1 | public class Candy2 { |
以上代码在 JDK 5 之前是无法编译通过的,必须改写为:
1 | public static void main(String[] args) { |
在 JDK 5 之前,包装类型和基本类型之间的转换需要手动处理(尤其是集合类中操作的都是包装类型),这非常麻烦,JDK 5 引入自动拆装箱后,这些手动处理的代码都可以由编译器在编译阶段完成。
3.3 泛型集合取值
泛型也是在 JDK 5 中加入的特性,但 Java 在编译泛型代码后会执行 泛型擦除 的动作,即泛型信息在编译为字节码之后就丢失了,实际类型都被当做 Object 类型来处理:
1 | public class Candy3 { |
所以在取值时,编译器真正生成的字节码中还要额外做一个类型转换的操作:
1 | // 将 Object 转换成 Integer |
如果前面的 x 变量类型修改为 int 基本类型,编译器还会自动拆箱:
1 | // 将 Object 转换为 Integer,然后再拆箱 |
擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 中仍然保留了方法参数泛型信息:
public indi.mofan.candy.Candy3();
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 11: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lindi/mofan/candy/Candy3;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) 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: ...
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;>;
使用反射,仍然能够获取到这些信息:
1 | public Set<Integer> test(List<String> list, Map<Integer, Object> map) { |
输出:
原始类型 - interface java.util.List 泛型参数[0] - class java.lang.String 原始类型 - interface java.util.Map 泛型参数[0] - class java.lang.Integer 泛型参数[1] - class java.lang.Object
可以使用上述方式获取方法参数、返回值的泛型信息。
3.4 可变参数
可变参数也是 JDK 5 中添加的新特性。
1 | public class Candy4 { |
可变参数 String... args 的本质是 String[] args,Java 编译器会在编译阶段将上述代码转换为:
1 | public static void foo(String[] args) { |
注意,如果调用了 foo() 则等价于 foo(new String[]{}),创建了一个空数组,而不是传入 null。
3.5 foreach 循环
foreach 循环也是 JDK 5 引入的语法糖:
1 | public class Candy5_1 { |
编译后生成的字节码等价于:
1 | public class Candy5_1 { |
如果是在集合上使用 foreach 循环呢?
1 | public class Candy5_2 { |
应用于集合的 foreach 循环会被编译期转换为对迭代器的调用,编译后生成的字节码等价于:
1 | public class Candy5_2 { |
foreach 循环的写法能够配合数组、实现了 Iterable 接口(提供获取 Iterator 的方式)的集合类一起使用。
3.6 switch 字符串
从 JDK 7 开始,switch 可以作用于字符串和枚举类:
1 | public class Candy6_1 { |
编译后生成的字节码等价于:
1 | public class Candy6_1 { |
在编译生成的字节码中执行了两次 switch,第一次根据字符串的 hashCode 和 equals 将字符串的转换为相应 byte 类型,第二次再利用 byte 进行比较。
为什么第一次时必须既比较 hashCode,又比较 equals 呢?
hashCode 是为了提高效率,减少比较次数;equals 是为了防止哈希冲突。
例如 BM 和 C. 两个字符串的 hashCode 值都是 2123:
1 | public class Candy6_2 { |
编译后生成的字节码等价于:
1 | public class Candy6_2 { |
3.7 switch 枚举
1 | public class Candy7 { |
编译后生成的字节码等价于:
1 | public class Candy7 { |
$MAP 是一个合成类,仅 JVM 使用,开发者不可见,用来映射枚举的 ordinal 与数组元素的关系。枚举的 ordinal 表示枚举对象的序号,从 0 开始,即 MALE.ordinal() = 0,FEMALE.ordinal() = 1。
3.8 枚举类
JDK 7 新增了枚举类,现有如下枚举:
1 | public enum Sex { |
编译后生成的字节码等价于:
1 | public final class Sex extends Enum<Sex> { |
3.9 try-with-resources
JDK 7 新增了对需要关闭的资源处理的特殊语法 try-with-resources:
1 | try (资源变量 = 创建资源变量) { |
其中资源对象需要实现 AutoCloseable 接口,例如 InputStream、OutputStream、Connection、Statement、ResultSet 等接口都实现了 AutoCloseable,使用 try-with-resources 可以不用写 finally 语句块,编译器会帮助生成关闭资源代码,例如:
1 | public class Candy9 { |
编译后生成的字节码等价于:
1 | public class Candy9 { |
为什么要设计一个 addSuppressed(Throwable e) (添加被压制异常)的方法呢?
为了防止异常信息的丢失。
如果 try-with-resources 生成的 finally 中如果抛出了异常:
1 | public class Candy9_2 { |
输出:
java.lang.ArithmeticException: / by zero at indi.mofan.candy.Candy9_2.main(Candy9_2.java:11) Suppressed: java.lang.Exception: close 异常 at indi.mofan.candy.Candy9_2$MyResource.close(Candy9_2.java:20) at indi.mofan.candy.Candy9_2.main(Candy9_2.java:12)
可以看到,两个异常信息都不会丢失。
3.10 方法重写时的桥接方法
方法重写时的返回值有两种情况:
-
父子类的返回值完全一致
-
子类返回值可以是父类返回值的子类,如:
1
2
3
4
5
6
7
8
9
10
11
12
13static class A {
public Number m() {
return 1;
}
}
static class B extends A {
// 子类 m 方法的返回值是 Integer,是父类 m 方法返回值 Number 的子类
public Integer m() {
return 2;
}
}
对于子类,Java 编译器会做以下处理:
1 | static class B extends A { |
桥接方法比较特殊,仅对 Java 虚拟机可见,并且与原来的 public Integer m() 没有命名冲突,可以用下面的反射代码来验证:
1 | public static void main(String[] args) { |
输出:
public java.lang.Integer indi.mofan.candy.Candy10$B.m() public java.lang.Number indi.mofan.candy.Candy10$B.m()
3.11 匿名内部类
有如下匿名内部类的表现形式:
1 | public class Candy11 { |
转换后的代码:
1 | final class Candy11$1 implements Runnable { |
1 | public class Candy11 { |
如果匿名内部类里引用了局部变量:
1 | public static void test(final int x) { |
转换后的代码:
1 | final class Candy11$1 implements Runnable { |
1 | public class Candy11 { |
这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:在创建 Candy11$1 对象时,将 x 的值赋值给了 Candy11$1 对象的 val$x 属性,所以 x 不应该再发生变化,如果变化,那么 val$x 属性没有机会再跟着一起变化。
4. 类加载阶段
4.1 加载
-
将类的字节码载入方法区中,内部采用 C++ 的
instanceKlass描述 Java 类,其中重要的 field 有:-
_java_mirrorJava 的类镜像,例如对String来说,就是String.class,作用是把 Klass 暴露给Java 使用 -
_super父类 -
_fields成员变量 -
_methods方法 -
_constants常量池 -
_class_loader类加载器 -
_vtable虚方法表 -
_itable接口方法表
-
-
如果这个类还有父类没加载,则先加载父类
-
加载和链接可能是交替进行的
注意:
instanceKlass这样的「元数据」是存储在方法区的(1.8 后的元空间内),但_java_mirror是存储在堆中的- 可以通过先前介绍的 HSDB 工具进行查看

4.2 链接
验证
验证类是否符合 JVM 规范,进行安全性检查。例如使用 UE 等支持二进制数据显示的编辑器修改一个 class 文件的魔数,执行 java 命令时会出现 ClassFormatError 错误。
准备:为
static变量分配空间,设置默认值:
static变量在 JDK7 之前存储于instanceKlass末尾,从 JDK7 开始,存储于_java_mirror末尾static变量的分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值 通常 在初始化阶段完成- 如果
static变量是final的基本类型和字符串字面量,其值在编译阶段就确定了,因此这类变量的赋值会在准备阶段完成 - 如果
static变量是final的引用类型,赋值就是在初始化阶段完成
解析
将常量池中的符号引用解析为直接引用。
1 | package indi.mofan.load; |
运行上述程序后,执行 jps 命令查看 Java 进程 id:
1 | 592 Main |
进入 JDK 安装目录,使用 CMD 执行以下命令打开 HSDB:
1 | .\bin\java.exe -cp .\lib\sa-jdi.jar sun.jvm.hotspot.HSDB |
按照前文中的方式,在「Attach to HotSpot process…」窗口里输入 Java 进程 id,即 30424。
点击 HSDB 中 Tools 菜单下的「Class Browser」,查看对应 Java 进行中有哪些类信息:
-
有类
C,查看其常量池信息:
-
但没有类
D
更改先前的示例程序,注释 loadClass() 方法,启用 new C();:
1 | public class Load2 { |
再次查看 C 类的常量池信息,此时可以看到 D 类的信息:

4.3 初始化
<cinit>()V 方法
初始化,即调用 <cinit>()V 方法(初始化静态变量和静态代码块),虚拟机会保证这个类的「构造方法」的线程安全。
发生的时机
概括地说,类初始化是「懒惰的」:
main()方法所在的类,总被优先初始化- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,触发父类的初始化,且在子类初始化之前进行初始化
- 子类访问父类静态变量,只触发父类的初始化,子类不会被初始化
- 调用
Class.forName() - 使用
new关键词
不会导致类初始化的情况:
- 访问类的
static final静态常量(基本类型和字符串)不会触发初始化 - 使用
类对象.class不会触发初始化 - 创建该类的数组不会触发初始化
- 调用类加载器的
loadClass()方法加载一个类 - 调用
Class.forName()方法,且第二个参数值为false时
1 | public class Load3 { |
5. 类加载器
以 JDK8 为例:
| 名称 | 加载哪的类 | 说明 |
|---|---|---|
| Bootstrap ClassLoader | JAVA_HOME/jre/lib | 无法直接访问 |
| Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap,显示为 null |
| Application ClassLoader | classpath | 上级为 Extension |
| 自定义类加载器 | 自定义 | 上级为 Application |
5.1 启动类加载器
使用 Bootstrap 类加载器加载类:
1 | public class F { |
执行:
1 | public class Load5_1 { |
执行上述代码时不能在 IDEA 内进行,需要使用命令行进入项目的输出目录,在 Maven 项目下就是所在模块的 \target\classes 目录。
使用以下命令执行:
1 | .\target\classes> java -Xbootclasspath/a:. indi.mofan.load.Load5_1 |
输出:
bootstrap F init null
输出的类加载器信息为 null,这表明使用了 Bootstrap 类加载器进行了类加载。
-Xbootclasspath/a:.的解析
-Xbootclasspath 表示设置 bootclasspath。
其中 /a:. 表示将当前目录追加至 bootclasspath 之后。
可以用这个方法替换掉一些核心类:
java -Xbootclasspath:<new bootclasspath>:使用新路径替换JAVA_HOME/jre/lib目录java -Xbootclasspath/a:<追加路径>:进行后追加java -Xbootclasspath/p:<追加路径>:进行前追加
5.2 扩展类加载器
新建 G 类:
1 | public class G { |
编译为 class 文件后,使用以下命令将其打包成 jar 文件:
1 | jar -cvf my.jar indi\mofan\load\G.class |
将打包得到的 my.jar 文件放入使用的 JDK 的 /jre/lib/ext 目录下。
修改 G 类,使得 /jre/lib/ext 目录下和当前项目目录下都存在一个 G 类:
1 | public class G { |
测试下:
1 | public class Load5_2 { |
输出:
ext G init sun.misc.Launcher$ExtClassLoader@379619aa
根据输出信息可以看到,使用拓展类加载器加载了拓展类路径下的 G 类,而非应用程序类路径下的 G 类。
5.3 双亲委派模型
参考 注解、类的加载、反射,不再赘述。
5.4 线程上下文类加载器
我们在使用 JDBC 时需要加载 Driver 驱动,但就算没有以下代码也能够让 com.mysql.jdbc.Driver 正确加载,这是为什么呢?
1 | Class.forName("com.mysql.jdbc.Driver"); |
这与 Java 的 SPI 机制有关,参考 Java 的类资源加载,不再赘述。
而所谓的「线程上下文类加载器」,即使用 Thread.currentThread().getContextClassLoader() 方法获取到的类加载器。在 Java 线程启动时,JVM 默认会将应用程序类加载器设置到该线程的上下文类加载器中。
5.5 自定义类加载器
参考 mofan-demo/classloader,不再赘述。
6. 运行时优化
6.1 即时编译
分层编译(TieredCompilation)
有以下代码:
1 | public static void main(String[] args) { |
运行程序后在控制台上能发现随着外部循环的执行,内部循环耗费的时间越来越少。
原因是什么呢?
JVM 将执行状态分成了 5 个层次:
-
0 层,解释执行(Interpreter)
-
1 层,使用 C1 即时编译器编译执行(不带 profiling)
-
2 层,使用 C1 即时编译器编译执行(带基本的 profiling)
-
3 层,使用 C1 即时编译器编译执行(带完全的 profiling)
-
4 层,使用 C2 即时编译器编译执行
profiling 是指在运行过程中收集一些程序执行状态的数据,例如「方法的调用次数」,「循环的回边次数」等。
即时编译器(JIT)与解释器的区别:
-
解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
-
JIT 是将一些字节码编译为字节码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
-
解释器是将字节码解释为针对所有平台都通用的机器码
-
JIT 会根据平台类型,生成平台特定的机器码
对于占据大部分的不常用代码,无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;对于仅占据小部分的热点代码,则可以将其编译成机器码,以达到理想的运行速度。在执行效率上进行简单的比较有 Interpreter < C1 < C2,总体目标是发现热点代码(这也是 HotSpot 名称的由来),然后进行优化。
先前示例代码中使用了一种名为「逃逸分析」的优化手段,用于判断新建的对象是否「逃逸」。以这段示例代码为例,简单来说就是判断内部循环新建的对象是否被外部循环使用,然后发现并没有被使用,那干脆就不创建这个对象了,于是随着外层循环的执行,耗费的时间越来越少,甚至出现数量级上的差距。
可以使用 JVM 参数 -XX:-DoEscapeAnalysis 来关闭逃逸分析。
方法内联(Inlining)
有以下代码:
1 | private static int square(final int i) { |
1 | System.out.println(square(9)); |
如果发现 square() 是热点代码,并且长度不太长,就会进行内联。所谓内联指的是将方法内的代码拷贝、粘贴到调用者的位置:
1 | System.out.println(9 * 9); |
还能够进行常量折叠(constant folding)的优化:
1 | System.out.println(81); |
看一个示例:
1 | public class JIT2 { |
随着外层循环的执行,耗时居然变成了 0,这是因为进行了「方法内联」和「常量折叠」的优化。
相关 JVM 参数:
-XX:+UnlockDiagnosticVMOptions:解锁一些诊断性的虚拟机选项(某些高级和诊断性功能是默认禁用的),仅在开发调试使用,不能在生产中使用,会导致 JVM 行为不稳定,甚至影响系统安全性和可靠性。-XX:+PrintInlining:在 JIT 编译过程中打印方法内联的详细信息,需要与-XX:+UnlockDiagnosticVMOptions一起使用,否则无法使用该参数。-XX:+PrintCompilation:打印 JIT 编译的时间、方法、编译层级等信息。-XX:CompileCommand=dontinline,*JIT2.square:禁用任意包下JIT2类中square()方法的方法内联优化
字段优化
本节需要使用到 JMH,参考 JMH。
有以下代码:
1 |
|
doSum() 方法未开启方法内联,输出的报告如下:
Benchmark Mode Cnt Score Error Units Benchmark1.test1 thrpt 5 693704.366 ± 18996.437 ops/s Benchmark1.test2 thrpt 5 756633.830 ± 23192.219 ops/s Benchmark1.test3 thrpt 5 754530.966 ± 22909.926 ops/s
可以看到,test1() 方法的得分稍低一些。
在上述示例中,doSum() 方法是否内联会影响 elements 成员变量读取的优化。
如果 doSum() 方法内联了,test1() 方法会被优化成类似下面的样子(伪代码):
1 |
|
可以节省 1999 次访问成员变量 elements 的操作,但如果 doSum() 方法没有进行内联,则不会进行上述优化。
6.2 反射优化
本节需要使用到 Arthas,参考 Arthas 快速入门。
存在以下代码:
1 | public class Reflect1 { |
共进行了 17 次反射调用。
观察耗时有:随着调用次数的进行,耗时减少,但在第 16 次的调用中,耗时陡增,第 17 次调用的耗时又恢复正常。
反射调用方法时,如果超过阈值(15),会在运行时生成方法访问器(MethodAccessorImpl 的实现类,可通过 Arthas 的 jad 命令查看),内部将反射的动态调用改为直接调用。
阈值由 ReflectionFactory.inflationThreshold() 方法返回结果控制,默认 15。
通过查看 ReflectionFactory 源码可知:
- 可以通过设置系统属性
sun.reflect.noInflation为true来禁用膨胀,以直接生成MethodAccessorImpl的实现类,但首次生成会比较耗时(这也是上述示例代码中第 16 次反射调用的耗时陡增的原因),如果仅反射调用一次,那不划算 - 可以通过设置系统属性
sun.reflect.inflationThreshold的值来修改膨胀阈值