原文:Introduction to Java Bytecode
接下来深入了解JVM内部和Java字节码,了解如何拆卸文件来进行深入检查。
即使对于经验丰富的Java开发人员来说,阅读已编译的Java字节码也会很乏味。为什么我们一开始就需要知道这些底层的东西呢?这里有一个上周发生在我身上的简单场景:很久以前我在我的机器上做了一些代码修改,编译了一个JAR,并将它部署到服务器上,以测试性能问题的潜在修复。不幸的是,代码从来没有签入版本控制系统,并且出于某种原因,本地更改被删除而没有跟踪。几个月后,我再次需要源代码形式的这些更改(这需要付出很大的努力),但是我找不到它们!
幸运的是,编译后的代码仍然存在于远程服务器上。于是,我如释重负地叹了口气,再次取出JAR,用反编译器编辑器打开了它……只有一个问题:反编译器GUI并不是一个完美的工具,由于某种原因,在该JAR中的许多类中,只有我要反编译的特定类在每次打开它时都会在UI中引起bug,而反编译器会崩溃!
非常时期采用非常手段【绝望的时代需要绝望的措施】。幸运的是,我熟悉原始字节码,我宁愿花一些时间手动地对代码的某些部分进行反编译,也不愿遍历更改并再次测试它们。因为我仍然记得至少应该在代码中查找什么地方,所以阅读字节码可以帮助我确定确切的更改,并以源代码的形式重新构造它们。(这次我一定要从错误中吸取教训,把它们保存起来!)
字节码的好处是,您只需要学习它的语法一次,然后它就可以应用于所有Java支持的平台——因为它是代码的中间表示,而不是底层CPU的实际可执行代码。此外,字节码比本机机器代码更简单,因为JVM架构相当简单,因此简化了指令集。另一件好事是,这个指令集中所有指令都被Oracle完全文档化了。
在学习字节码指令集之前,让我们先熟悉一下作为先决条件的JVM的一些事情。
JVM数据类型
Java是静态类型化的,这会影响字节码指令的设计,以至于指令希望自己操作特定类型的值。例如,有几个add指令来添加两个数字:iadd、ladd、fadd、dadd。它们分别期望类型操作数,int、long、float和double。大多数字节码具有这样的特性,即根据操作数类型具有相同功能的不同形式。
JVM定义的数据类型是:
1、基本类型:
数字类型:byte(8位2的补码),short(16位2的补码),int(32位2的补码),long(64位2的补码),char(16位无符号Unicode), float(32位IEEE 754单精度FP), double(64位IEEE 754双精度FP)
布尔类型
返回地址:指向指令的指针
2、引用类型:
类类型
数组类型
接口类型
布尔类型对字节码的支持有限。例如,没有直接操作布尔值的指令。相反,由编译器将布尔值转换为int,并使用相应的int指令。
Java开发人员应该熟悉上述所有类型,但返回地址除外,它没有等效的编程语言类型。
基于堆栈的架构
字节码指令集的简单主要是因为Sun设计了基于堆栈的VM体系结构,而不是基于寄存器的体系结构。JVM进程使用各种内存组件,但只有JVM堆栈需要进行详细检查,才能基本上能够遵循字节码指令:
PC寄存器:对于Java程序中运行的每个线程,PC寄存器存储当前指令的地址。
JVM堆栈:对于每个线程,都在存储本地变量、方法参数和返回值的地方分配一个堆栈。下面的插图显示了3个线程的堆栈。
堆:所有线程和存储对象(类实例和数组)共享的内存。对象重分配由垃圾收集器管理。
方法区域:对于每个加载的类,它存储方法代码和一个符号表(例如对字段或方法的引用)以及常量池。
JVM堆栈由框架组成,每个框架在调用方法时推入堆栈,在方法完成时从堆栈弹出(通过正常返回或抛出异常)。每一框架进一步包括:
1、从0到长度减一的局部变量数组。长度由编译器计算。局部变量可以容纳任何类型的值,但长值和双值除外,它们占用两个局部变量。
2、一种操作数堆栈,用于存储作为指令操作数或将参数推入方法调用的中间值。
字节码探索
通过JVM的内部构件的IDEA,我们可以了解从示例代码生成的一些基本字节码示例。Java类文件中的每个方法都有一个由指令序列组成的代码段,每个指令序列都具有以下格式:
opcode (1 byte) operand1 (optional) operand2 (optional) ...
这是一个指令,由一个字节的操作码和零个或多个包含要操作的数据的操作数组成。
在当前正在执行的方法的堆栈框架中,指令可以将或弹出值推入操作数堆栈,并且它可以潜在地在数组本地变量中加载或存储值。让我们看一个简单的例子:
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
}
为了在编译后的类中打印生成的字节码(假设它在file 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
...
我们可以看到主方法的方法签名,描述符表示该方法接受一个字符串数组([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:将int值从索引1处的局部变量加载到操作数堆栈中。
iload_2:将int值从索引1处的局部变量加载到操作数堆栈中。
iadd:从操作数堆栈中取出前两个int值,添加它们,并将结果推回到操作数堆栈中。
istore_3:弹出最上面的操作数int值,并将其存储在索引3处的局部变量中,这对应于变量c。
return:从void方法返回。
上面的每一条指令都只包含一个操作码,它精确地规定了JVM要执行的操作。
方法调用
在上面的例子中,只有一个方法,主方法。让我们假设我们需要对变量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
唯一的区别在主方法的代码,我们现在用一个invokestatic指令而不是iadd指令,它调用静态方法calc。关键要注意的是,操作数堆栈包含的两个参数传递给方法calc。换句话说,调用的方法准备所有的参数被称为方法,把他们推到操作数栈以正确的顺序。invokestatic(或类似的调用指令,稍后将会看到)随后将弹出这些参数,并为被调用的方法创建一个新的框架,其中参数被放置在它的本地变量数组中。
我们还注意到,invokestatic指令通过查看地址占用了3个字节,地址从6跳到9。这是因为,与目前看到的所有指令不同,invokestatic包含两个额外的字节来构造对要调用的方法的引用(除了操作码之外)。javap将引用显示为#2,这是对calc方法的符号引用,该方法是从前面描述的常量池中解析的。
另一个新信息显然是calc方法本身的代码。它首先将第一个整数参数加载到操作数堆栈(iload_0)上。下一条指令i2d通过应用扩展转换将其转换为double。得到的双值替换操作数堆栈的顶部。
下一条指令将一个双常量2.0d(从常量池中取出)推送到操作数堆栈上。然后是静态方法Math。使用到目前为止准备好的两个操作数值(calc的第一个参数和常量2.0d)调用pow方法。当Math。pow方法返回,其结果将存储在其调用程序的操作数堆栈中。下面可以说明这一点。
同样的程序也适用于计算Math.pow(b,2):
下一条指令,dadd,弹出前两个中间结果,添加它们,并将总和推回到顶部。最后,invokestatic调用Mah。对结果和进行sqrt,然后使用窄化转换(d2i)将结果从double转换为int。结果int返回给main方法,主方法将它存储回c (istore_3)。
实例创建
让我们修改示例并引入一个类点来封装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;
}
}
mian方法的编译字节码如下所示:
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
这里所遇到的新指令是新的、统一的和特殊的。与编程语言中的新操作符类似,新指令创建了传递给它的操作数中指定的类型的对象(这是对类点的符号引用)。对象的内存被分配到堆上,对对象的引用被推送到操作数堆栈上。
dup指令复制了最高操作数堆栈值,这意味着现在我们有两个对堆栈顶部的Point对象的引用。接下来的三个指令将构造函数的参数(用于初始化对象)推入操作数堆栈,然后调用与构造函数对应的特殊初始化方法。下一个方法是初始化字段x和y。方法完成后,将使用前三个操作数堆栈值,剩下的是对已创建对象的原始引用(目前已成功初始化)。
接下来,astore_1弹出该点引用并将其赋给索引1处的局部变量(astore_1中的a表示这是一个引用值)。
创建和初始化第二个点实例的过程是相同的,这个实例被赋给变量b。
最后一步从索引1和索引2的本地变量(分别使用aload_1和aload_2)加载对两个点对象的引用,并使用invokevirtual调用area方法,该方法根据对象的实际类型处理对适当方法的调用。例如,如果变量a包含扩展Point的SpecialPoint类型的实例,并且子类型覆盖areamethod,则调用overriden方法。在这种情况下,没有子类,因此只有一个区域可用。
注意,即使area方法接受一个参数,在堆栈顶部也有两个点引用。第一个(pointA,来自变量a)实际上是调用方法的实例(在编程语言中称为this),它将被传递到area方法的新框架的第一个局部变量中。另一个操作数值(pointB)是area方法的参数。
反过来
您不需要掌握每条指令的理解和确切的执行流程,就可以根据手边的字节码了解程序的工作。例如,在我的示例中,我想检查代码是否使用Java流来读取文件,以及流是否正确关闭。现在,有了以下字节码,就可以比较容易地确定是否使用了流,并且很可能它是作为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
...
我们看到在调用forEach时出现了java/util/stream/ stream,前面是调用InvokeDynamic,引用一个消费者。然后我们看到一个字节码块,它调用流。关闭调用throwable . addsuppression的分支。这是编译器为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());
}
结论
由于字节码指令集的简单性,以及在生成指令时几乎没有编译器优化,如果需要的话,分解类文件可以成为一种不用源代码检查应用程序代码更改的方法。