原文链接:Introduction to Java Bytecode
原作者: Mahmoud Anouti
翻译:pjmike
备注:已省略作者的前言叙事部分
JVM数据类型
Java语言是一种静态类型的编程语言,而这会影响到字节码指令的设计,因为设计出来的指令会期望自己可以对特定类型的值进行操作。
译者注:编译时就知道变量类型的是静态类型
举个例子来说,这有几个加法指令可以对两个数进行相加:iadd
, ladd
, fadd
, dadd
。而这几个指令分别期望数据类型为int、long、float和double的操作数。大多数字节码都具有这种特性,即根据操作数类型,具有相同功能的不同形式。
JVM定义的数据类型有:
- 基本类型:
- 数值类型:
byte (8-bit)
,short(16-bit)
,int(32-bit)
,long(64-bit)
,char(16-bit)
,float(32-bit)
,double(64-bit)
boolean
类型returnAddress
: 指令指针
- 数值类型:
- 引用类型
- 类类型
- 数组类型
- 接口类型
在字节码中对boolean类型的支持是受限的, 例如, 没有直接对boolean值进行操作的指令, boolean值将由编译器使用相应的int指令转换为int类型。
除了returnAddress,Java开发人员应该熟悉上述所有类型,因为returnAddress没有等价的编程语言类型。
译者注:returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址
基于堆栈的架构
字节码指令集的简单性很大程度上是由于Sun设计了基于堆栈的VM体系结构,而不是基于寄存器的VM体系结构。 JVM进程使用各种内存组件,但是基本上只有Java堆栈需要详细检查字节码指令
PC寄存器:对于在Java程序中运行的每个线程,都有一个PC寄存器保存着当前执行的指令地址
JVM 栈:对于每个线程,都会分配一个栈,其中存储了局部变量、方法参数和返回值。下面是一个显示3个线程的堆栈示例
- 堆: 所有线程共享的一块内存区域,用于存储对象实例和数组。对象的回收由垃圾收集器管理
- 方法区: 对于每个已加载的类,它用于存储方法代码和符号表(例如对字段或方法的引用)以及常量池。
JVM堆栈由栈帧组成的,当方法被调用时,每个栈帧入栈;当方法完成时栈帧从堆栈中弹出(通过正常返回或抛出异常)。每一帧还包括:
- 局部变量表,索引从0到它的长度-1。长度是由编译器决定的,一个局部变量可以保存任何类型的值,其中long和double类型的数据会占用两个局部变量空间
- 操作数栈,它存储指令的操作数,或者方法调用的参数
译者注:栈帧不仅存储了方法的局部变量表、操作数栈,还存储着动态链接和方法返回地址等信息,更详细的信息可以参阅:《深入理解Java虚拟机》
字节码探索
了解了JVM的内部机制之后,我们来看一些由示例代码生成的字节码, Java类文件中的每个方法都有一个代码段,该代码段由一系列字节码指令组成,每个指令具有以下格式:1
2
3opcode (1 byte) operand1 (optional) operand2 (optional) ...
操作码 (1 byte) 操作数1(可选) 操作数2(可选) ...
该指令由一个字节的操作码(opcode)和零个或多个操作数(operand)组成的,operand包含了要被操作的数据
在当前执行方法的栈帧里,一条指令可以将值压入操作数栈或者弹出,并且有可能将值加载或存储在局部变量表中。 让我们看一个简单的例子:
1 | public static void main(String[] args) { |
我们使用javap工具查看其编译后生成的字节码1
javap -v Test.class
然后得到了如下结果:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
...
我们可以看到main方法的方法声明,descriptor说明这个方法的参数是一个字符串数组([Ljava/lang/String;
),而返回类型是void(V)
。flags说明该方法是public(ACC_PUBLIC
)和static(ACC_STATIC
)。
译者注:方法中的访问标志flag用于识别一些方法的访问信息,包括这个方法是否为public;方法是否为static;方法是否为final等等
Code属性是最重要的部分,它包含了这个方法的一系列指令和信息,这些信息包含了操作栈的最大深度(本例中是stack=2)和在这方法栈帧中被分配的局部变量的数量(本例中是locals=4)。所有的局部变量在上面的指令中都提到了,除了第一个变量(索引为0),这个变量保存的是args参数,而其他三个局部变量就是代码中的a,b和c。
译者注:Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内,Code属性主要作用是用于描述代码
从地址0到8的指令将执行以下操作:
iconst_1
: 将整数常量1压入操作数栈
译者注:
iconst_<i>
, 将常量i加载到操作数栈中
istore_1
: 弹出操作数栈中的顶部操作数(一个int值),并将其存储在索引为1的局部变量中,该局部变量对应于变量a。
译者注:
istore_<n>
,将操作数栈中的顶部数值存储到局部变量表中索引为n的位置
iconst_2
: 将整数常量2压入操作数栈
istore_2
: 弹出顶部操作数int值,并将其存储在索引为2的局部变量中,该变量对应于变量b。
iload_1
: 加载局部变量表中索引为1的值,并将其压入操作数栈中
译者注:
iload_<n>
: 将局部变量表中索引为n这个位置上的值加载到操作数栈
iadd
: 将操作数栈中的前两个int值出栈并相加,然后将相加的结果放入操作数栈
istore_3
: 弹出顶部操作数int值,并将其存储在索引为3的局部变量中,该变量对应于变量c。
return
: 从void方法中返回
上面的每条指令仅由一个操作码组成,每个操作码精确地指示了由JVM执行的操作
方法调用
上面的示例只有一个方法,即 main 方法。假如我们需要对变量 c 进行更复杂的计算,这些复杂的计算写在新方法 calc 中:
1 | public static void main(String[] args) { |
来看看生成的字节码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31public 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: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: invokestatic #2 // Method calc:(II)I
9: istore_3
10: return
static int calc(int, int);
descriptor: (II)I
flags: (0x0008) ACC_STATIC
Code:
stack=6, locals=2, args_size=2
0: iload_0
1: i2d
2: ldc2_w #3 // double 2.0d
5: invokestatic #5 // Method java/lang/Math.pow:(DD)D
8: iload_1
9: i2d
10: ldc2_w #3 // double 2.0d
13: invokestatic #5 // Method java/lang/Math.pow:(DD)D
16: dadd
17: invokestatic #6 // Method java/lang/Math.sqrt:(D)D
20: d2i
21: ireturn
与之前相比,main方法代码唯一的不同在于,用invokestatic
指令代替了iadd
指令,invokestatic
指令用于调用静态方法calc。需要注意的是,操作数栈中包含两个传递给calc方法的参数。也就是说,调用方法要按正确的顺序将它们压入操作数栈以准备要调用方法的所有参数。随后,invokestatic
(或类似的invoke指令,将在后面看到)将弹出这些参数,并为被调用的方法创建一个新的栈帧,其中将参数放置在新栈帧的局部变量表中。
我们还注意到,通过查看地址,invokestatic指令占用3个字节,该地址从6跳转到9。不同于之前的指令,invokestatic包括两个额外的字节来构造对要调用的方法的引用。该引用通过javap工具显示为#2
,它是从之前描述的常量池解析而来的。
译者注:Class文件中的常量池主要存放两大类常量:字面量(Literal)和符号引用(Symbolic Reference)。字面量比如文本字符串、声明为final的常量值等,而符合引用包括类和接口的全限定名、字段的名称和描述符以及方法的名称和描述。下面是上述代码的常量池(由于内容较多,展示部分),#2 所表示的就是calc方法的符号引用,invokestatic指令使用之前,需要先对它所使用的符号引用进行解析
1
2
3
4
5 > Constant pool:
> #1 = Methodref #8.#19 // java/lang/Object."<init>":()V
> #2 = Methodref #7.#20 // >com/pjmike/jvm/bytecode/JVMTest.calc:(II)I
> #3 = Double 2.0d
>
而其他字节码信息是calc方法对应的字节码。它首先使用iload_0
指令将第一个整数参数加载到操作数栈上,下一条指令i2d
,将其转换为双精度型double,转换后的double类型取代了操作数栈的顶部。
再下一条指令 ldc2_w
将双精度常量2.0d(从常量池中提取)压入操作数栈。 然后使用到目前为止准备好的两个操作数值(calc的第一个参数和常数2.0d)调用静态Math.pow方法。 当Math.pow方法返回时,其结果将存储在其调用程序的操作数栈中
计算Math.pow(b,2)也是同样的:
下一条指令dadd
会弹出顶部的两个中间结果,将它们相加,然后将总和推回顶部。 最后,invokestatic
会对所得的总和调用Math.sqrt
,然后使用 d2i
指令将结果从double
转换为int
。生成的int值将返回到main
方法,基于istore_3
指令,该方法将其存储回c。
译者注: 所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。在Java虚拟机中提供了5种方法调用字节码指令,分别如下:
- invokestatic: 调用静态方法
- invokespecial: 调用实例构造器
方法、私有方法和父类方法 - invokevirtual: 调用对象的实例方法
- invokeinterface: 调用接口方法,会在运行时再确定一个实现此接口的对象
- invokedynamic: 运行时动态解析出调用点限定符所引用的方法,并执行该方法
创建实例
现在修改这个示例,加入 Point 类来封装 XY 坐标。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class Test {
public static void main(String[] args) {
Point a = new Point(1, 1);
Point b = new Point(5, 3);
int c = a.area(b);
}
}
class Point {
int x, y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
public int area(Point b) {
int length = Math.abs(b.y - this.y);
int width = Math.abs(b.x - this.x);
return length * width;
}
}
main方法对应的字节码如下:
1 | public static void main(java.lang.String[]); |
这里引入了 new
、dup
和 invokespecial
几个新指令。new 指令与编程语言中的 new 运算符类似,它根据传入操作数所指定的类型来创建对象(这是对 Point 类的符号引用)。对象的内存是在堆上分配,对象引用则是被推入到操作数栈上。
dup指令会复制顶部操作数的栈值,这意味着我们在栈顶部有两个指向Point对象的引用。接下来的三条指令将构造函数的参数(用于初始化对象的)压入操作数栈中,然后通过invokerspecial
指令调用实例构造器<init>()
方法。该方法完成之后,前三个操作数的栈值将被销毁,剩下的就是已创建对象的原始引用,到目前为止,就完成了初始化工作
.png)
接下来,astore_1
将该Point引用弹出栈,并将其赋值到索引1所保存的局部变量(astore_1中的a表明这是一个引用值).
然后以同样的过程创建并初始化第二个Point实例,并将此实例赋值给变量b
最后一步,分别使用aload_1
和aload_2
指令加载索引1和2处的局部变量,这两个局部变量就是对Point对象的两个引用,然后使用invokevirtual
调用area
方法,该方法会根据实际类型调用适当的方法来完成分发。例如,如果变量a指向继承Point的SpecialPoint实例,并且子类型覆盖了area方法,那么将调用重写方法。而目前这种情况下,并不存在子类,因此仅有area方法是可用的。
请注意,即使area方法接受单参数,堆栈顶部也有两个Point的引用。第一个引用(pointA)实际上指向调用该方法的实例,对area方法来说,它将被传递到新栈帧的第一个局部变量中。另一个操作数(pointB)是area方法的参数。
由字节码反推源码的功能
你其实无需掌握每条字节码指令的含义和具体的执行流程,依然可以根据手头的字节码了解程序的功能。 例如,以我为例,我想检查代码是否使用Java流读取文件,以及该流是否已正确关闭。 现在给出以下字节码,很容易的判断出确实使用了流,并且很可能作为try-with-resources
语句的一部分将其关闭。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67public static void main(java.lang.String[]) throws java.lang.Exception;
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=8, args_size=1
0: ldc #2 // class test/Test
2: ldc #3 // String input.txt
4: invokevirtual #4 // Method java/lang/Class.getResource:(Ljava/lang/String;)Ljava/net/URL;
7: invokevirtual #5 // Method java/net/URL.toURI:()Ljava/net/URI;
10: invokestatic #6 // Method java/nio/file/Paths.get:(Ljava/net/URI;)Ljava/nio/file/Path;
13: astore_1
14: new #7 // class java/lang/StringBuilder
17: dup
18: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V
21: astore_2
22: aload_1
23: invokestatic #9 // Method java/nio/file/Files.lines:(Ljava/nio/file/Path;)Ljava/util/stream/Stream;
26: astore_3
27: aconst_null
28: astore 4
30: aload_3
31: aload_2
32: invokedynamic #10, 0 // InvokeDynamic #0:accept:(Ljava/lang/StringBuilder;)Ljava/util/function/Consumer;
37: invokeinterface #11, 2 // InterfaceMethod java/util/stream/Stream.forEach:(Ljava/util/function/Consumer;)V
42: aload_3
43: ifnull 131
46: aload 4
48: ifnull 72
51: aload_3
52: invokeinterface #12, 1 // InterfaceMethod java/util/stream/Stream.close:()V
57: goto 131
60: astore 5
62: aload 4
64: aload 5
66: invokevirtual #14 // Method java/lang/Throwable.addSuppressed:(Ljava/lang/Throwable;)V
69: goto 131
72: aload_3
73: invokeinterface #12, 1 // InterfaceMethod java/util/stream/Stream.close:()V
78: goto 131
81: astore 5
83: aload 5
85: astore 4
87: aload 5
89: athrow
90: astore 6
92: aload_3
93: ifnull 128
96: aload 4
98: ifnull 122
101: aload_3
102: invokeinterface #12, 1 // InterfaceMethod java/util/stream/Stream.close:()V
107: goto 128
110: astore 7
112: aload 4
114: aload 7
116: invokevirtual #14 // Method java/lang/Throwable.addSuppressed:(Ljava/lang/Throwable;)V
119: goto 128
122: aload_3
123: invokeinterface #12, 1 // InterfaceMethod java/util/stream/Stream.close:()V
128: aload 6
130: athrow
131: getstatic #15 // Field java/lang/System.out:Ljava/io/PrintStream;
134: aload_2
135: invokevirtual #16 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
138: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
141: return
...
我们看到出现了java/util/stream/Stream
,其中调用了forEach
,然后是对InvokeDynamic
的调用,并带有对Consumer的引用。 然后,我们还看到了一个字节代码块,它调用Stream.close以及调用Throwable.addSuppressed的分支。这就是编译器为try-with-resources语句生成的基本代码
下面是完整的源代码:1
2
3
4
5
6
7
8public static void main(String[] args) throws Exception {
Path path = Paths.get(Test.class.getResource("input.txt").toURI());
StringBuilder data = new StringBuilder();
try(Stream lines = Files.lines(path)) {
lines.forEach(line -> data.append(line).append("\n"));
}
System.out.println(data.toString());
}
总结
还好字节码指令集简洁,生成指令时几乎少有编译器优化,因此如果有必要,反编译类文件可以在没有源码的情况下检查代码。