【译】如何阅读 Java 字节码

原文链接:https://dzone.com/articles/introduction-to-java-bytecode

即使对于一名经验丰富的 Java 程序员来说,阅读编译后的 Java 字节码也会感到枯燥。我们为什么需要了解如此底层的信息呢?在上周,我遇到了一个场景:在很早以前,我在自己的电脑上修改了一些代码,编译成了一个 JAR 文件,然后发布到服务端,检查我更改后的代码是否修复了一个性能问题。不幸的是,这些代码没有被检入版本控制系统,而且不知道什么原因,本地的修改在没有任何的记录下也没了。几个月后的上周,我再次需要这些修改后的代码,然而我找不到他们了!

幸运的是,编译后的代码仍然保留在远程服务端,抱着一丝希望,我下载了 JAR 包然后用反编译软件打开它…然而有一个问题:这个反编译软件的 GUI 界面并不是无瑕疵的,在查看反编译后的某些类时,会导致这个软件崩溃!

特殊情况特殊处理。好在我对字节码还有点印象,相比于使用那个注定会崩溃的反编译软件,我更偏向于自己手动反编译一些代码。

了解字节码的好处在于,一旦你掌握了它,那么在所有 Java 虚拟机支持的平台都能适用——因为字节码只是代码的 IR(中间表示),并不是底层 CPU 直接执行的代码。而且,相比于机器码,由于 JVM 的架构相对比较简单,JVM 的指令集也相对比较少,因此字节码比较容易掌握。并且,Oracle 对这些指令提供了完整的说明文档!

在学习字节码指令集之前,我们先熟悉一下关于 JVM 的一些常识。

JVM 数据类型

Java 是一门静态类型的语言,这也影响了字节码指令集的设计,比如,一个指令希望它自己能在一个指定类型的值上进行操作。举个例子,将两个数相加的加法指令,有 iaddladdfadddadd。他们指定的操作数类型,分别是 intlongfloatdouble。一些字节码,他们的功能相同,但由于操作数类型不同,也就有了不同的特征。

JVM 定义的数据类型有:

  1. 基本数据类型:
    • 数字类型:byte (8位),short (16 位),int (32 位),long (64 位),char (16 位无符号 Unicode),float (32 位 IEEE 754 单精度),double (64 位 IEEE 754 双精度).
    • boolean 类型。
    • returenAdress:指令指针。
  2. 引用类型:
    • 类类型。
    • 集合类型。
    • 接口类型。

boolean 类型在字节码里面支持有限。比如,并没有一个指令可以直接操作 boolean 类型的值。布尔值一般会被编译器转为 int 类型的值,并且用 int 相关的指令来操作。

除了 returnAddress 类型没有对应的程序语言类型以外,Java 开发者对上面所提到的类型应该很熟悉。

栈基架构

字节码指令集的简单很大程度上归功于 Sun 公司设计的基于栈的虚拟机架构,一个 JVM 进程里面使用了很多内存组件,但只要详细检查 JVM 的堆栈信息,就能够了解下面的指令集:

PC 寄存器:Java 程序里面的每一个运行的线程,都有一个 PC 寄存器存储着当前指令的地址。

JVM 栈:对于每一个线程, 是用来存放局部变量、方法参数以及返回值的。下面这张图表示三个线程的栈信息:

jvm_stacks

:所有线程共享的内存区域,存放着对象(类的实例化和数组)。对象由垃圾回收器进行再分配。

heap.png

方法区:对于每一个加载的类,方法区里面都存放着方法的代码,以及符号表(对象或字段的引用)以及常量池中的常量。

method_area.png

一个 JVM 的栈是由一系列的 (frame)组成,当调用一个方法的时候,就会将一帧的内存入栈,当方法运行结束的时候(无论是正常返回还是抛出异常),就会将栈顶的帧给弹出。每一帧由下面几部分组成:

  1. 一个局部变量数组,索引序列从 0 到数组长度减一。数组长度是由编译器计算的。除了 longdouble 类型的值需要两个局部变量的空间来存储外,其他任何类型的值都可以存储在一个局部变量里面。
  2. 一个用来存储中间变量的操作栈。该中间变量的作用是充当指令的操作数,或者存放方法调用的参数。

stack_frame_zoom.png

浏览字节码

对 JVM 内部有一些基本的了解后,我们可以看一下由简单的代码生成的字节码的例子。在 Java 类文件里面的每个方法中,都有像下面那样格式的代码段:

1
操作码		  操作数1		操作数2

也就是说,字节码是由一个操作码、0 个或多个操作数组成。

在当前正在运行的方法的栈帧中,指令可以将一个操作数压入操作栈中,也可以将一个操作数从操作栈中弹出,也可以悄悄地加载或存储局部变量数组里的值。我们来看一个简单的例子:

1
2
3
4
5
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
}

假设这些代码是在 Test.class 文件里的,为了从编译后的 class 文件中得到字节码,我们可以运行 javap 命令:

1
javap -v Test.class

然后我们就能得到下面的结果:

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
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#12 // java/lang/Object."<init>":()V
#2 = Class #13 // Test
#3 = Class #14 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 main
#9 = Utf8 ([Ljava/lang/String;)V
#10 = Utf8 SourceFile
#11 = Utf8 Test.java
#12 = NameAndType #4:#5 // "<init>":()V
#13 = Utf8 Test
#14 = Utf8 java/lang/Object
{
public Test();
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 1: 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: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
LineNumberTable:
line 3: 0
line 4: 2
line 5: 4
line 6: 8
}

我们可以看到 main 方法的方法签名,descriptor 表示该方法传入了一个字符串数组([Ljava/lang/String]),有一个空的返回类型(V)。还有一些 flags 表示方法修饰符:public( ACC_PUBLIC)static (ACC_STATIC)

最重要的是 Code 属性下面的代码,包含了该方法中使用到的指令集信息,比如操作栈的最大深度为2 (stack=2),该方法在栈帧中分配了 4 个局部变量(locals=4),所有的局部变量都在上面的指令中被引用了,除了序列号为 0 的那个变量,序列号为 0 的变量存储了指向 args 参数的引用。其他 3 个局部变量对应源码中的变量 a, b 和 c。

从地址 0 到 8,指令做了下面的事情:

iconst_1:将整数常量 1 压入操作栈中。

iconst_1.png

istore_1:将操作栈顶的内容弹出(一个 int 值),然后将其存放到下标为 1 的局部变量中,对应变量 a。

istore_1.png

iconst_2:将整数常量 2 压入操作栈中。

iconst_2.png

istore_2:将操作栈顶的值弹出,并将其存储到下标为 2 的局部变量中,对应变量 b。

istore_2.png

iload_1:从下标为 1 的局部变量数组中加载出 int 值,并将其压入操作栈中。

iload_1.png

iload_2:从下标为 1 的局部变量数组中加载出 int 值,并将其压入操作栈中。

iload_2.png

iadd:将操作栈中顶部的两个 int 值弹出,将他们俩相加,然后将结果压回操作栈中。

iadd

istore_3:将操作数顶部的 int 值弹出,并且将其存储到下标为 3 的局部变量数组中,对应源码中的变量 c。

istore_3.png

return:从 void 方法中返回。

上面的所有指令都只有一个操作数,表示 JVM 想要执行的具体操作是什么。

方法调用

在上面的例子中只有一个方法,那就是 main 方法。假如变量 c 的值需要稍微复杂的方式才能计算出来,我们一般都会将 c 的计算过程放在一个新的方法中执行——calc

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = calc(a, b);
}

static int calc(int a, int b) {
return (int)Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}

我们看一下对应的字节码:

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #8.#19 // java/lang/Object."<init>":()V
#2 = Methodref #7.#20 // Test.calc:(II)I
#3 = Double 2.0d
#5 = Methodref #21.#22 // java/lang/Math.pow:(DD)D
#6 = Methodref #21.#23 // java/lang/Math.sqrt:(D)D
#7 = Class #24 // Test
#8 = Class #25 // java/lang/Object
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 calc
#16 = Utf8 (II)I
#17 = Utf8 SourceFile
#18 = Utf8 Test.java
#19 = NameAndType #9:#10 // "<init>":()V
#20 = NameAndType #15:#16 // calc:(II)I
#21 = Class #26 // java/lang/Math
#22 = NameAndType #27:#28 // pow:(DD)D
#23 = NameAndType #29:#30 // sqrt:(D)D
#24 = Utf8 Test
#25 = Utf8 java/lang/Object
#26 = Utf8 java/lang/Math
#27 = Utf8 pow
#28 = Utf8 (DD)D
#29 = Utf8 sqrt
#30 = Utf8 (D)D
{
public Test();
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 1: 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: 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
LineNumberTable:
line 3: 0
line 4: 2
line 5: 4
line 6: 10

static int calc(int, int);
descriptor: (II)I
flags: 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
LineNumberTable:
line 9: 0
}

Main 方法中唯一不同的代码就是讲之前的 iadd 指令,变成了现在的 invokestatic 指令,改指令只是调用了静态方法 calc。要注意的是,操作栈中包含了需要传递给 calc 方法的两个参数,也就是说,方法调用方会准备好被调用的方法所需的参数,并且将这些参数按照正确的顺序压入操作栈中。invokestatic (或者其他类似的方法调用) 会依次弹出这些参数,同时会为被调用的方法创建一个新的帧,被调用的方法所需的参数存放在新栈帧的局部变量数组中。

同时,通过观察地址我们可以看到 invokestatic 指令占据了 5、6、7 三个地址索引,也就是说,invokestatic 指令占据了三个字节。和我们目前了解到的指令集不同,invokestatic 指令包含了用来调用方法引用所需的两个额外的字节。方法caljavap 中是由 #2 标识,而 #2 指向的是常量池中的的引用。

除此之外,在上面的字节码文件中我们可以发现 cal 方法本身的字节码。它首先将第一个整型参数加载到操作栈中(iload_0)。而接下来的指令 i2d 则是将第一个整型操作数转为一个 double 类型。然后将转化后的 double 值替换原来的整型参数,占据操作栈的顶端。

接下来的指令,会从常量池中取出一个双精度浮点型常量 2.0d ,并将其压入操作栈中,这样就为 Math.pow 静态方法准备好了两个操作数(方法 calc 的第一个参数和常量 2.0d)。当 Math.pow 方法执行完后,会将结果返回给调用它的操作栈,并压入栈顶,如下图所示:

math_pow.png

计算 Math.pow(b, 2) 也是类似的流程:

math_pow2.png

再接下来的指令,dadd,会将栈中的两个值弹出,将他们相加,然后将结果压入栈顶。最终,invokestatic 方法会调用 Math.sqrt 方法,该方法执行完后将结果强制转为 int 类型 (d2i),然后将 int 类型的结果返回给 main 方法,并存储在变量 c 中(istore_3)。

创建对象

我们修改上面的例子,引入 Point 类,用来计算 XY 的面积。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public 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
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
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=4, args_size=1
0: new #2 // class Point
3: dup
4: iconst_1
5: iconst_1
6: invokespecial #3 // Method Point."<init>":(II)V
9: astore_1
10: new #2 // class Point
13: dup
14: iconst_5
15: iconst_3
16: invokespecial #3 // Method Point."<init>":(II)V
19: astore_2
20: aload_1
21: aload_2
22: invokevirtual #4 // Method Point.area:(LPoint;)I
25: istore_3
26: return
LineNumberTable:
line 3: 0
line 4: 10
line 5: 20
line 6: 26
}

在上面的字节码文件中,我们会遇到新的指令集:newdupinvokespecial。和编程语言中的 new 关键字一样,new 指令会创建一个在操作栈中指定类型的对象(即符号引用常量池的 Point 类)。对象会被分配到堆内存中,而指向该对象的引用会被压入操作栈。

dup 指令会复制一个栈顶的值,也就是说现在我们有两个指向 Point 对象的引用。接下来的三个指令的作用,会先将初始化对象所需的参数压入操作栈中,然后调用特定的初始化方法,也就是对应的 Point 类的构造方法。在这个调用方法中,xy 对象会被初始化。当初始化方法结束后,栈顶的三个操作数都被消费了,只剩下最初指向创建对象(现在已经成功初始化了)的那个引用。

init.png

接下来,astore_1 会将 Point 引用弹出,并将其分配给局部变量数组中下标为 1 的变量(也就是 a)。

init_store.png

创建并初始化第二个 Point 对象的流程也类似,最终会被分配给变量 b

init2.png

init_store2.png

接下来,将局部变量数组中,下标为 1 和 2 的 Point 对象引用加载到操作栈中(分别用指令 aload_1aload_2 表示),然后使用 invokevirtual 指令调用 area 方法,该指令会负责根据对象的实际类型来调用合适的方法。比如,如果变量 a 包含了一个继承自 Point 类型的对象 SpecialPoint,并且子类重写了 area 方法,那么重写的方法就会被调用。在我们上面这个例子中,由于没有子类,因此只有一个 area 方法可用。

area.png

然而,即使 area 方法只接受一个参数,仍然需要两个 Point 引用。第一个 PointpointA,来自变量 a) 是方法调用者(也就是程序语言中的 this 关键字),它会被传入 area 方法帧的第一个局部变量。第二个操作数 pointBarea 方法的参数。

另一种方式

如果只是想了解程序运行方式,你不需要对编译后的字节码文件里面的每一个指令都了解彻底。比如,我只想检查代码是否使用了 Java 的 steam 来读一个文件,并且还想知道 stream 是否被正确关闭。我反编译代码得到了下面的字节码文件,并且可以比较容易地发现,我确实使用了 steam,而且很有可能是在 try - resource 语句中关闭了流。

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
67
public 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/stram/Stream 类里面的 forEach 方法确实被调用了,而在这之前,会调用一个指向 Consumer 对象引用的方法 InvokeDynamic。然后我们看到一大堆调用了 Steam.close 方法的字节码和调用 Throwable.addSuppressed 的跳转分支。这些是编译器编译 try - with - resource 语句的基本代码。

下面是完整的源代码:

1
2
3
4
5
6
7
8
public 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());
}

总结

感谢这些简单的字节码指令集和缺失的编译器优化,使得在没有源代码的情况下,拆分类文件并且分析你的代码成了一种比较容易的方法。

如果觉得文章对你有帮助,请我喝杯可乐吧