Java字节码介绍
描述
本文截取并翻译introduction-to-java-bytecode一部分。翻译工具使用彩云小译。指令集可以参考jvm指令集
正文
JVM 数据类型
Java 是静态类型的语言,这影响了字节码指令的设计,使指令期望自己操作特殊类型的值。 例如,有几个添加指令来添加两个数字:iadd
,ladd
,fadd
,dadd
。它们期望操作数类型分别为 int、 long、 float 和 double。 大多数字节码都具有这样的特点: 相同功能的指令,根据操作数类型的不同而不同。
由 JVM 定义的数据类型如下:
- 基本类型
- 数值类型:
byte
、short
、int
、long
、float
、double
- 布尔类型:
boolean
returnAddress
- 数值类型:
- 引用类型
- Class
- Array
- Interface
boolean
类型在字节码中的支持有限。 例如,没有直接对布尔值进行操作的指令。 编译器将布尔值转换为int
,并使用相应的int
指令。
Java 开发人员应该熟悉上述所有类型,除了returnAddress
,它没有等效的编程语言类型。
基于栈的体系结构
字节码指令集的简单性主要是由于 Sun 设计了一个基于栈的 VM 架构,而不是一个基于寄存器的架构。 进程使用了各种各样的内存组件,但只有JVM栈需要详细检查,才能基本上遵循字节码指令:
- PC 寄存器:对于 Java 程序中运行的每个线程,PC 寄存器存储当前指令的地址。
- JVM 栈:一个线程一个栈,栈内存储局部变量、方法参数和返回值。 下面是一个示例,显示了3个线程的堆栈。
-
堆:所有线程共享的内存用于存储对象(类实例和数组)。 对象释放由垃圾回收器管理。
jvm_heap -
方法区:对于每个加载的类,它存储方法代码和符号表(例如对字段或方法的引用)以及常量池。
jvm_method_area
JVM 栈由栈帧组成,每个栈帧在方法被调用时推入栈,并在方法完成时从栈中弹出(通过正常返回或抛出异常)。 每个栈帧还包括:
-
局部变量数组(译者注:局部变量表,下文都用局部变量数组描述),下标从0到length-1。长度由编译器计算。局部变量可以保存任何类型的值,但
long
和double
比较特殊,它们占用两个局部变量。 -
操作数栈,用于存储作为指令操作数的中间值,或者将参数推入方法调用。
stack_frame_zoom
字节码探索
通过对JVM内部构造的了解,我们可以看一下从示例代码生成的一些基本字节码示例。类文件中的每个方法都有一个由一系列指令组成的代码段,每个指令的格式如下:
opcode (1 byte) operand1 (optional) operand2 (optional) ...
该指令由一个字节的操作码和零个或多个包含要操作的数据的操作数组成。
在当前执行方法的栈帧中,指令可以将值推入或弹出到操作数栈中,并且可能加载或存储局部变量数组中的值。 让我们来看一个简单的例子:
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
}
为了在编译后的类中打印字节码(假设它在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
方法的方法签名,它是一个描述符,表示该方法接受一个字符串数组([Ljava/lang/String;
) ,并具有一个 void 返回类型(V
)。 接下来是一组标志,它们将该方法描述为 public (ACC_PUBLIC
)和 static (ACC_STATIC
)。
最重要的部分是Code
属性,它包含方法的指令以及诸如操作数栈的最大深度(本例中为2),栈帧中分配的局部变量数量(本例中为4)等信息。 上述指令中引用了所有局部变量,但第一个变量(在索引0处)除外,该变量包含对args
参数的引用。 其他3个局部变量对应于源代码中的变量a
、b
和c
。
从地址0到8的指令将执行以下操作:
iconst_1
:将整数常量1推送到操作数栈上
istore_1
:弹出顶部操作数(一个 int 值) ,并将其存储在局部变量数组索引1中,该变量对应于变量a
。
iconst_2
:将整数常量2压入操作数栈。
istore_2
:弹出顶部操作数int值并将其存储在局部变量数组索引为2的地方,该局部变量对应于变量b
。
iload_1
:从局部变量数组索引1处加载int值,并将其推到操作数栈上。
iload_2
:从局部变量数组索引2的处加载int值,并将其推到操作数栈上。
iadd
:从操作数栈中弹出顶部的两个int值,求和,并将结果推回操作数栈。
istore_3
:弹出顶部操作数int值,并将其存储在局部变量数组索引3处,该变量对应于变量c
。
return
:没有返回值的返回
上面的每个指令都只包含一个操作码,这些操作码准确地规定了 JVM 要执行的操作。
方法调用
在上面的示例中,只有一个方法,即 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));
}
让我们看看生成的字节码:
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方法代码的唯一区别在于,我们现在使用的不是iadd
指令,而是调用静态方法calc
的invokestatic
指令。 需要注意的关键事项是,操作数栈包含传递给方法calc
的两个参数。 换句话说,正在执行的方法按照正确的顺序将参数推送到操作数栈,以此来准备被调用方法的所有参数。invokestatic
(或者类似的invoke
指令,稍后会看到)随后会弹出这些参数,并为被调用方法创建一个新的栈帧,其中参数们被放置在其局部变量数组中。
通过查看地址从6跳到9,我们注意到调用指令占用3个字节。 这是因为,与目前看到的所有指令不同,invokestatic
包含两个额外的字节,用于构造被调用方法的引用(除了操作码)。javap
将引用显示为#2
,这是对calc
方法的符号引用,可以从前面描述的常量池中解析获得。
另一个新信息显然是calc
方法本身的代码。 它首先将第一个整数参数加载到操作数栈(iload_0
)。 下一条指令i2d
通过申请扩大转换将其转换为double
。 生成的double
替换操作数栈的顶部。
下一条指令将一个double常量2.0d
(取自常量池)推送到操作数栈上。 然后使用准备好的两个操作数(calc
的第一个参数和常量2.0d
)调用静态Math.pow
方法。 当Math.pow
方法返回时,其结果将存储在调用程序的操作数栈中。 这可以在下面加以说明。
同样的过程也用于计算Math.pow(b,2)
:
下一条指令dadd
弹出前面的两个中间结果,将它们相加,并将和返回到顶部。 最后,调用Math.sqrt
对结果进行处理,并使用收缩转换(d2i
)将结果从double转换为int。 结果int返回到main方法,该方法将其存储回c
(istore_3
)。
实例创建
让我们修改这个例子并引入一个类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
指令创建一个传递给其操作数的指定类型的对象(这里是类Point
的符号引用)。 对象的内存在堆上分配,对象的引用被推送到操作数栈上。
dup
指令是复制操作数栈顶的值,这意味着现在栈顶我们有两个Point
对象的引用。 接下来的三条指令将构造函数(用于初始化对象)的参数推送到操作数栈,然后调用与构造函数对应的特殊初始化方法。 下一个方法是初始化字段 x 和 y 的位置。 方法完成后,前三个操作数堆栈值被消费,剩下的是对创建对象的原始引用(到目前为止,已经成功初始化)。
接下来,astore_1
弹出Point
引用并将其赋值给局部变量数组索引1处(astore_1
中的a
表示这是一个引用值)。
对于创建和初始化第二个Point
实例,重复执行相同的过程,该实例分配给变量b
。
最后一步从局部变量数组索引1和索引2处加载两个Point
对象的引用(分别使用aload_1
和aload_2
) ,并使用invokevirary
调用area
方法,该方法根据对象的实际类型执行调用。 例如,如果变量a
包含一个继承Point
的SpecialPoint
类型的实例,并且子类型覆盖了area
方法,那么就会调用override方法。 在这种情况下,没有子类,因此只有一个area
方法可用。
请注意,尽管area
方法接受一个参数,但栈顶有两个Point
引用。 第一个(pointA
,来自变量a
)实际上是调用方法的实例(在编程语言中也称为this
) ,它将被传递到新的area
方法栈帧的第一个局部变量中。 另一个操作数值(pointB
)是area
方法的参数。
反过来看
您不需要掌握对每条指令的理解和准确的执行流程,就可以了解基于手头的字节码程序所做的工作。 例如,在我的例子中,我想检查代码是否使用 Java stream来读取文件,以及stream是否被正确关闭。 现在,对于下面的字节码,确定流是否确实被使用是相对容易的,并且很有可能它是作为 try-with-resources 语句的一部分被关闭的。
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());
}
总结
由于字节码指令集的简单性以及在生成指令时几乎没有编译器优化,反汇编类文件可以成为检查应用程序代码变化的一种方法,而不需要源代码,如果有这种需要的话。