Java字节码简介(Introduction to Java Bytecode)
本文为译文,原链接地址Introduction to Java Bytecode,首先感谢原文作者。
跟随本篇文章深入研究JVM内部结构和java字节码,你将会知道如何分解你的文件进行深入检查。
对于一个经验丰富的开发人员阅读java字节码也是非常枯燥的事情。首先我们要弄清楚我们为什么需要知道如此底层的东西?上周有一个能应用简单的场景:很早以前我作了代码修改,编译至jar包中并部署到服务测试一个潜在的性能问题。不幸的是修改的代码从未保存在版本控制系统,无论什么原因,本地修改的代码已消失的无影无踪。在几个月后,我再次需要原格式的修改的代码,但我已找不到了!
幸好编译后的代码还保存在服务器上,长舒一口气,我获取服务器的JAR并使用反编译工具打开。。唯一的问题:反编译工具GUI不是完美的,JAR中的许多类,只有我想反编译的那个特定类,UI打开时引起了bug,反编译器直接崩溃了!
绝望时孤注一掷,还好我属性原字节码,我宁愿再花些时间手动反编译一些代码而不去完成代码,再去测试它们。至少我还记得去哪儿查看代码,读字节码帮助我精确的找出修改的地方,把它们转换成源代码格式。
一旦你学会了字节码的语法,它可以应用到所有支持java的平台上——因为它是代码的中间的表现,不是最终通过底层cpu执行的代码。而且由于JVM结构相当的简单,字节码也比机器码简单,因此也是简化的指令集合,另外集合中所有的指令Oracle提供了完善的文档说明。
在学习字节码指令集合之前,我们得先熟悉一些关于JVM的知识点。
JVM 数据类型
java是静态类型的,影响了其设计字节码指令,一个指令需要操作一个特定类型的数值。举个例子,现在有几个操作两个数字相加操作:iadd,ladd,fadd,dadd,它们期望的操作数类型,依次为,int,long,float和double。相同的功能由于不用的操作数类型会有不一样的展现形式。
JMV定义的数据类型:
-
原始类型:
- 数字类型:
byte
(8-bit 2's complement)八进制二位补码,short
(16-bit 2's complement),int
(32-bit 2's complement),long
(64-bit 2's complement),char
(16-bit unsigned Unicode),float
(32-bit IEEE 754 single precision FP)单精度,double
(64-bit IEEE 754 double precision FP)双精度 boolean
typereturnAddress
: pointer to instruction
- 数字类型:
-
引用类型:
- 类
- 数组
- 接口
boolean
类型在字节码中是有限支持的。例如,没有直接操作boolean值得指令,Boolean值将通过编译器转换为int类型进而通过int的指令进行使用。
Java开发者应熟悉以上所有的类型,除了returnAddress
, 在java编程语言中没有相对应的类型。
基于栈的结构
字节码的简单很大程度得益于Sun设计了基于栈的VM(虚拟机)结构,而不是通过寄存器。JVM进程使用了很多内存组件,我只需要详细了解JVM的栈就可以基本上理解字节码指令:
PC register: 运行在java程序上的每个线程,PC寄存器存储当前指令的地址。
JVM stack: 每个线程,一个栈用于存储局部变量,方法参数,返回值。下图展示3个线程的栈。
Heap: 所有线程共享该内存,存储对象(类的实体和集合对象)。对象的存储分配通过垃圾回收器进行管理。
Method area: 每个加载的类,存储它方法的代码和一个符号表(字段的引用或方法的引用)和常量池。
一个JVM栈是由帧组成,在每一帧中方法执行时将值放置在栈上,方法执行完成时栈将值弹出(不管是正常返回还是抛出异常)。每一帧还包含:
- 局部变量集合,从0至其长度-1。长度是编译器计算得出:一个局部变量能够存储任何类型的值,除了long和double,因为他们是由两个局部变量组成。
- 一个用于存储中间值的操作数栈,这些中间值将充当指令的操作数或将参数推送到方法调用中.
字节码探索
带着JVM内部结构认识,我们往下看一些从样例代码生成的基础字节码。每个方法都有一个代码块,包含一系列指令,每个指令有以下格式:
opcode (1 byte) operand1 (optional) operand2 (optional) ...
一个指令包含一个字节的操作码和处理0个或多个包含数据的操作数。
当前执行方法内部的栈帧,一个指令会对操作数栈推送或弹出值,它也可能在局部变量数组上加载或存储值,看以下简单的例子:
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
}
为了打印出编译成class文件的字节码(假设已编译成Test.class文件),我们可以运行如下javap命令:
javap -v Test.class
然后我们会得到:
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描述信息声明这个方法接收String数组的参数 ([Ljava/lang/String;
), 和一个空返回值类型 (V
). flags 描述了方法为public (ACC_PUBLIC
) and static (ACC_STATIC
).
最重要的部分为 Code
属性, 包含方法指令和信息,例如操作数栈的最大容量(此处为2),为该方法分配的局部变量数量(此处为4)。以上所有的局部变量都使用了除了第一个(索引0),它对应的是args
参数。 在源码中剩下3个变量对应的是 a
, b
and c 。
从0至8的指令将会实现以下操作:
iconst_1
: 推送int常量1至操作数栈。
istore_1
: 弹出顶部操作数(一个int值)然后存储至索引为1的局部变量,对应着变量a
.
iconst_2
: 推送int常量2至操作数栈。
istore_2
: 弹出顶部int操作数值,存储至索引为2的局部变量b上。
iload_1
: 从索引为1的局部变量加载int值并推送至操作数栈。
iload_2
: 从索引为2的局部变量加载int值并推送至操作数栈。
iadd
: 从操作数栈顶部弹出两个int值,把它们相加,把结果再推送至操作数栈。
istore_3
: 弹出顶部的int操作数值并存储至索引为3的局部变量,对应变量c
.
return
: Return from the void method.
上面每个指令仅包含一个操作码,精确决定JVM执行的操作。
Method Invocations(方法调用)
上面的例子只有一个main方法. 假设我们需要对变量c进行更精细的计算,我们决定用一个叫calc的新方法返回它:
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));
}
Let's see the resulting bytecode:
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: 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方法与前一个main方法的唯一区别就是使用invokestatic替换了iadd,invokestatic只是简单调用了static方法calc。关键事项是操作数栈需要包含两个参数传递给方法calc。换句话说,调用方法通过将他们按正确顺序推送至操作数栈来准备给被调用的方法的所有参数。 invokestatic(或一个相似的调用指令,接下来将会看到)将随后弹出这些参数,方法调用时将创建一个新帧,这些参数将会赋值给该新帧中的局部变量数组中。
观察code代码的地址,我们也注意到invokestatic
指令占用了 3 个字节,地址从6直接到9。与之前看的指令不同,因为invokestatic包含两个额外的字节去构造被调用方法的引用(操作码除外)。引用展示在javap生成结果的#2处,象征着指向
calc
方法
, 通过更早初始化的常量池解决的。
另外的新信息就是calc方法的代码。一开始加载第一个int参数至操作数栈 (iload_0
). 下一个指令, i2d
, 通过加宽将其转换为double。double结果放到操作栈的顶部。
下一个指令推送一个double的常量2.0d(从常量池获取)至操作数栈。带着目前两个已经准备好的参数调用静态方法Math.pow
(calc的一个参数
和 常量 2.0d
). 当Math.pow方法返回
, 它的结果将返回至其调用者的操作数栈。图解如下。
对于Math.pow(b, 2)是相同的处理过程
:
下一个指令, dadd
, 弹出顶部两个中间状态的结果,相加,将总和推送回操作数顶部。最终invokestatic 对结果总和执行 Math.sqrt
方法, 结果通过缩小转换从double转换为int (d2i
)。结果int返回至main方法,并存储值c变量 (istore_3
).
Instance Creations(实例创建)
修改实例,创建一个Point类去封装XY坐标。
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方法编译后的字节码如下:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=4, args_size=1
0: new #2 // class test/Point
3: dup
4: iconst_1
5: iconst_1
6: invokespecial #3 // Method test/Point."<init>":(II)V
9: astore_1
10: new #2 // class test/Point
13: dup
14: iconst_5
15: iconst_3
16: invokespecial #3 // Method test/Point."<init>":(II)V
19: astore_2
20: aload_1
21: aload_2
22: invokevirtual #4 // Method test/Point.area:(Ltest/Point;)I
25: istore_3
26: return
产生的新的指令有new
, dup
, 和 invokespecial。与编程语言的new操作相似,new指令创建操作数指向的特定的类型对象(象征class Point)。
在堆上分配对象的内存,对象的引用推送至操作数栈。
dup
指令复制一份顶部的操作数值,意味着在操作数栈顶部我们有两个指向Point对象的引用。下面三个指令将构造方法(用于初始化对象)的参数推送至操作数栈,然后调用一个与构造函数对应的特定初始化方法:下一个方法将会初始化字段x和字段y。当方法完成后,操作数栈顶部的三个值都已被消费,剩下的引用指向已创建的对象 (目前为止,已成功初始化).
下一步, astore_1 弹出
Point
的引用 并赋值给索引为1的局部变量 (the a
in astore_1
indicates this is a reference value).
重复以上过程初始化第二个Point对象,并赋值给变量b。
最后一步从局部变量数组的索引1和2分别加载两个Point的引用 (分别使用 aload_1
和 aload_2
),使用invokevirtual调用area 方法,根据对象的实际类型将调用调度到适当的方法。例如,如果变量a是SpecialPoint对象,Special继承自Point并重写了area方法,那么将调用重写的方法。本例子中,没有子类,因此只有一个area方法可用。
注意到即使area方法接收一个参数,栈的顶部仍有两个Point引用。第一个(pointA
, 来自于变量 a
) 实际上调用该方法的对象(另外在编程语言中被称为this),它将作为area方法的新帧中第一个局部变量传递过去 ,另一个操作数值 (pointB
)将作为area方法的参数。
The Other Way Around(另一种使用场景)
你不需要精通每个指令和精确的执行流程,根据手边的字节码来了解程序所作的工作。举个例子,我想知道通过stream读取文件的代码是否有适当的关闭。提供以下字节码,它相对简单去决定使用try-with-resources语句时是否stream作为一部分进行关闭。
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/stream/Stream 的
forEach调用的地方
, 在之前调用InvokeDynamic对应Consumer。然后我们看到一大块字节码,调用Stream.close 和调用Throwable.addSuppressed
. 这是编译器为try-with-resources语句生成的基本代码.
以下是完整的源码:
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());
}
Conclusion(总结)
多亏了字节码的简单性和几乎不用优化的编译器,当不需要源代码是,分解class文件将是一种检查程序代码更改的方式。