简单读懂 Java 字节码
读懂字节码有助于更好的理解 Java 编译器的工作原理
Java 字节码(Java bytecode)是 Java 虚拟机所使用的指令集。Java 字节码可以划分为很多种类型,如加载常量指令、操作数栈专用指令、局部变量表访问指令、Java 相关指令、方法调用指令、数组相关指令、控制流指令,以及计算相关指令。
Java 虚拟机采用面向操作数栈的架构,在解释执行过程中,Java 虚拟机为每个 Java 方法分配栈帧(Stack Frame)。栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它存储了方法的操作数栈、局部变量表、动态连接、方法返回地址等信息。

字节码的执行过程实际上就是对局部变量表、操作数栈的一系列读写过程。
操作数栈
也称操作栈,后入先出(Last In First Out, LIFO)。操作数栈用来存放计算的操作数以及即将返回的结果。

通常来说,程序需要将变量从局部变量表加载至操作数栈中,进行一番运算之后再将结果存储回局部变量中。具体来说便是,执行每一条指令之前,Java 虚拟机要求该指令的操作数已被压入栈中;在执行指令时,Java 虚拟机会将指令所需的操作数从栈里弹出,并且将指令执行的结果重新压入栈中。正常情况下,操作数栈的压入弹出都是一条条指令完成的,唯一的例外情况是在抛异常时,Java 虚拟机会清除操作数栈上的所有内容,而后将异常实例压入栈上。
以加法指令 iadd
为例,假设在执行指令之前,栈顶的两个元素分别是 int 型 1 和 2,那么 iadd 指令会先将这两个 int 数据弹出栈,而后将计算得到的结果 int 型 3 压入栈中。操作数栈的变化如下图:

由于 iadd 指令只消耗栈顶的两个元素,因此,对于栈顶两个以外的元素,即图中的问号,iadd 指令并不关心它们是否存在,更加不会对其进行修改。
在 Java 字节码中,有一部分指令可以直接将常量加载到操作数栈上。以 int 型为例,虚拟机既可以通过 iconst
指令加载 -1 至 5 之间的 int 值,也可以通过 bipush、sipush
加载一个字节、两个字节所能代表的 int 值。Java 虚拟机还可以通过 ldc
加载常量池中的常量值,这些常量包括 int 类型、long 类型、float 类型、double 类型、String 类型以及 Class 类型的常量。例如 ldc #18
将加载常量池中的第 18 项。
常量加载指令表
类型 | 指令 | 范围 |
---|---|---|
int (boolean, byte, char, short) | iconst | [-1, 5] |
bipush | [-128, 127] | |
sipush | [-32768, 32767] | |
ldc | 任意 int 值 | |
long | lconst | 0, 1 |
ldc | 任意 long 值 | |
float | fconst | 0, 1, 2 |
ldc | 任意 float 值 | |
double | dconst | 0, 1 |
ldc | 任意 double 值 | |
reference | aconst | null |
ldc | String, Class |
详细指令汇总:JVM 字节码指令表
局部变量表
局部变量表是 Java 方法栈帧的另外一个重要组成部分,用于存放方法参数和方法内部定义的局部变量,字节码程序可以将计算的结果缓存在局部变量表中。实际上,Java 虚拟机将局部变量表当成一个数组,依次存放 this 指针(仅非静态方法)、所传入的参数、以及字节码中的局部变量。和操作数一样,long 类型以及 double 类型的值需要占据两个单元,其余类型仅占据一个单元。
假设 Test.java 中有实例方法 test:
3 public void test(long l, float f) {
4 double d = 0.0;
5 {
6 int i = 0;
7 }
8
9 {
10 boolean b = false;
11 }
12 }
编译后,通过 javap -verbose Test
查看该方法的字节码:
public void test(long, float);
descriptor: (JF)V
flags: ACC_PUBLIC
Code:
stack=2, locals=7, args_size=3
0: dconst_0
1: dstore 4
3: iconst_0
4: istore 6
6: iconst_0
7: istore 6
9: return
LineNumberTable:
line 4: 0
line 6: 3
line 10: 6
line 12: 9
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 this LTest;
0 10 1 l J
0 10 3 f F
3 7 4 d D
看懂方法字节码之前,先了解一下描述符标识字符的含义:
标识字符 | 含义 | 标识字符 | 含义 |
---|---|---|---|
B | 基本类型 byte | J | 基本类型 long |
C | 基本类型 char | S | 基本类型 short |
D | 基本类型 double | Z | 基本类型 boolean |
F | 基本类型 float | V | 特殊类型 void |
I | 基本类型 int | L | 对象类型,如Ljava/lang/Object |
现在通过字节码,可以对 test 方法有个大致的了解:
- descriptor: (JF)V
它接收两个基本类型(JF)参数,依次是 long 和 float,返回类型为 void(V) - flags: ACC_PUBLIC
它是一个 public 方法 - stack=2
该方法执行过程中需要消耗的栈的最大深度为 2 - locals=7
该方法执行过程中需要消耗的局部变量表的长度是 7 - args_size=3
该方法有 3 个输入参数。这时可能会有疑惑,方法签名中明明只有 2 个,为什么字节码中说有 3 个呢?因为,在任何实例方法中,我们都可以通过 “this” 关键字访问到此方法所属的对象,所以编译器编译的时候就把对 this 关键字的访问转变为一个普通方法参数的访问,然后在虚拟机调用实例方法的时候自动传入此参数。因此,在实例方法的局部变量表中,至少存在一个指向当前对象实例的局部变量,局部变量表的第一个 Slot 会被保留用来存放实例对象的引用,而方法参数值从索引 1 开始存储。

第一个参数为 long 类型(需要占据两个单元),于是数组索引为 1、2 两个单元存放着所传入的 long 型参数值;第二个参数则是 float 类型,仅需一个单元,所以索引为 3 的单元存放着所传入的 float 值;方法体内部定义的第一个局部变量时 double 类型,同样需要占据两个单元;在方法体的两个代码块中,分别定义了两个局部变量 i 和 b,由于它们的生命周期没有重合之处,因此,编译器可以将它们编排到同一单元中,也就是说,局部变量表中的第 7 个单元将为 i 或者 b。
存储在局部变量表中的值,通常需要加载至操作数栈中,方能进行计算,得到结果后再存储至局部变量数组中。这些加载、存储指令是区分类型的。例如,int 类型的加载指令为 iload,存储指令为 istore。
局部变量区访问指令
类型 | 加载指令 | 存储指令 |
---|---|---|
int (boolean, byte, char, short) | iload | istore |
long | lload | lstore |
float | fload | fstore |
double | dload | dstore |
reference | aload | astore |
局部变量数组的加载、存储指令都需要指明目标单元的索引。举例来说,aload 0
指的是加载索引为 0 的单元所存储的引用,在实例方法中便是加载 this 指针。
详细指令汇总:JVM 字节码指令表
综合示例
14 public double calc(int i) {
15 int a = 10;
16 int b = 2;
17 double c = 1.5;
18 return (a + b - i) * c;
19 }
对应的字节码:
public double calc(int);
descriptor: (I)D
flags: ACC_PUBLIC
Code:
stack=4, locals=6, args_size=2
0: bipush 10
2: istore_2
3: iconst_2
4: istore_3
5: ldc2_w #2 // double 1.5d
8: dstore 4
10: iload_2
11: iload_3
12: iadd
13: iload_1
14: isub
15: i2d
16: dload 4
18: dmul
19: dreturn
LineNumberTable:
line 15: 0
line 16: 3
line 17: 5
line 18: 10
LocalVariableTable:
Start Length Slot Name Signature
0 20 0 this LTest;
0 20 1 i I
3 17 2 a I
5 15 3 b I
10 10 4 c D
javap 提示这段代码需要深度为 4 的操作数栈和 6 个 Slot 的局部变量空间。以下各图描述了当调用 calc(5)
时,各条指令执行前后局部变量表和操作数栈的变化情况。
-
指令执行之前,局部变量表的第 1 个 Slot(为了描述方便,我指的是索引为 1 ,其实是数组中的第 2 个单元) 存储输入参数 5
0. 指令执行之前,局部变量表的第 1 个 Slot 存储输入参数 5
-
首先执行偏移地址为 0 的指令,
bipush
指令的作用是将单字节的整型常量值(-128 ~ 127)推入操作数栈顶,跟随一个参数,指明要推入的常量值,这里就是 10。
1. 首先执行偏移地址为 0 的 bipush 指令
-
执行偏移地址为 2 的指令,
istore_2
指令的作用是将操作数栈顶的 int 型值弹出栈,并存放到局部变量表中的第 2 个 Slot 中
2. 执行偏移地址为 2 的 istore_2 指令
-
执行偏移地址为 3 的指令,
iconst_2
指令的作用是将 int 型常量 2 压入栈顶
3. 执行偏移地址为 3 的 iconst_2 指令
-
执行偏移地址为 4 的指令,
istore_3
指令的作用是将操作数栈顶的 int 型值弹出栈,并存放到局部变量表中的第 3 个 Slot 中
4. 执行偏移地址为 4 的 istore_3 指令
-
执行偏移地址为 5 的指令,
ldc2_w
指令的作用是将 long 或 double 型常量值从常量池中推至栈顶(宽索引),跟随一个参数,指明常量值在常量池中的位置,这里是第 2 项
5. 执行偏移地址为 5 的 ldc2_w 指令
-
执行偏移地址为 8 的指令,
dstore
指令的作用是将操作数栈顶的 double 型值弹出栈,并存放到局部变量表中指定索引的 Slot 中,这里是索引为 4 的 Slot
6. 执行偏移地址为 8 的 dstore 指令
-
执行偏移地址为 10 的指令,
iload_2
指令的作用是将局部变量表中索引为 2 的 Slot 中的 int 值复制到操作数栈顶,这里是数值 10
7. 执行偏移地址为 10 的 iload_2 指令
-
执行偏移地址为 11 的指令,
iload_3
指令的作用是将局部变量表中索引为 3 的 Slot 中的 int 值复制到操作数栈顶,这里是数值 2
8. 执行偏移地址为 11 的 iload_3 指令
-
执行偏移地址为 12 的指令,
iadd
指令的作用是将栈顶两 int 数值弹出栈,做加法计算,然后把结果重新压入操作数栈。在该指令执行完成后,栈中原有的 10 和 2 出栈,结果 12 入栈
9. 执行偏移地址为 12 的 iadd 指令
-
执行偏移地址为 13 的指令,
iload_1
指令的作用是将局部变量表中索引为 1 的 Slot 中的 int 值复制到操作数栈顶,这里是数值 5
10. 执行偏移地址为 13 的 iload_1 指令
-
执行偏移地址为 14 的指令,
isub
指令的作用是将栈顶两 int 数值弹出栈,做减法计算,然后把结果重新压入操作数栈。在该指令执行完成后,栈中原有的 12 和 5 出栈,结果 7 入栈
11. 执行偏移地址为 14 的 isub 指令
-
执行偏移地址为 15 的指令,
i2d
指令的作用是将栈顶的 int 数值弹出栈,强制转换成 double 型,并将结果压入栈顶,double 型数值需要占用两个单元
12. 执行偏移地址为 15 的 i2d 指令
-
执行偏移地址为 16 的指令,
dload
指令的作用是将局部变量表中指定索引位置的 double 型数据复制到栈顶,会占用操作数栈的两个单元,指令后跟一个参数,表示该 double 型数据在局部变量表的索引位置
13. 执行偏移地址为 16 的 dload 指令
-
执行偏移地址为 18 的指令,
dmul
指令的作用是将栈顶两个 double 数值弹出栈,做乘法计算,然后把结果重新压入栈
14. 执行偏移地址为 18 的 dmul 指令
-
最后,执行偏移地址为 19 的指令,
dreturn
指令是方法返回指令之一,它将结束方法执行,并将操作数栈顶的 double 型数值返回给方法的调用者
15. 最后,执行偏移地址为 19 的 dreturn 指令
到此,整个方法执行结束,以下长图更直观的对比各条指令执行前后局部变量表和操作数栈的变化。

对于更复杂的方法,同样可以通过查阅各指令的文档来读懂字节码。