原文链接: https://dzone.com/articles/introduction-to-java-bytecode
即使对于一名经验丰富的 Java 程序员来说,阅读编译后的 Java 字节码也会感到枯燥。我们为什么需要了解如此底层的信息呢?在上周,我遇到了一个场景:在很早以前,我在自己的电脑上修改了一些代码,编译成了一个 JAR 文件,然后发布到服务端,检查我更改后的代码是否修复了一个性能问题。不幸的是,这些代码没有被检入版本控制系统,而且不知道什么原因,本地的修改在没有任何的记录下也没了。几个月后的上周,我再次需要这些修改后的代码,然而我找不到他们了!
幸运的是,编译后的代码仍然保留在远程服务端,抱着一丝希望,我下载了 JAR 包然后用反编译软件打开它…然而有一个问题:这个反编译软件的 GUI 界面并不是无瑕疵的,在查看反编译后的某些类时,会导致这个软件崩溃!
特殊情况特殊处理。好在我对字节码还有点印象,相比于使用那个注定会崩溃的反编译软件,我更偏向于自己手动反编译一些代码。
了解字节码的好处在于,一旦你掌握了它,那么在所有 Java 虚拟机支持的平台都能适用——因为字节码只是代码的 IR(中间表示),并不是底层 CPU 直接执行的代码。而且,相比于机器码,由于 JVM 的架构相对比较简单,JVM 的指令集也相对比较少,因此字节码比较容易掌握。并且,Oracle 对这些指令提供了完整的说明文档!
在学习字节码指令集之前,我们先熟悉一下关于 JVM 的一些常识。
Java 是一门静态类型的语言,这也影响了字节码指令集的设计,比如,一个指令希望它自己能在一个指定类型的值上进行操作。举个例子,将两个数相加的加法指令,有 iadd
, ladd
, fadd
, dadd
。他们指定的操作数类型,分别是 int
、 long
、 float
和 double
。一些字节码,他们的功能相同,但由于操作数类型不同,也就有了不同的特征。
JVM 定义的数据类型有:
byte
(8位), short
(16 位), int
(32 位), long
(64 位), char
(16 位无符号 Unicode), float
(32 位 IEEE 754 单精度), double
(64 位 IEEE 754 双精度). boolean
类型。 returenAdress
:指令指针。 boolean
类型在字节码里面支持有限。比如,并没有一个指令可以直接操作 boolean
类型的值。布尔值一般会被编译器转为 int
类型的值,并且用 int
相关的指令来操作。
除了 returnAddress
类型没有对应的程序语言类型以外,Java 开发者对上面所提到的类型应该很熟悉。
字节码指令集的简单很大程度上归功于 Sun 公司设计的基于栈的虚拟机架构,一个 JVM 进程里面使用了很多内存组件,但只要详细检查 JVM 的堆栈信息,就能够了解下面的指令集:
PC 寄存器:Java 程序里面的每一个运行的线程,都有一个 PC 寄存器存储着当前指令的地址。
JVM 栈:对于每一个线程, 栈
是用来存放局部变量、方法参数以及返回值的。下面这张图表示三个线程的栈信息:
堆:所有线程共享的内存区域,存放着对象(类的实例化和数组)。对象由垃圾回收器进行再分配。
方法区:对于每一个加载的类,方法区里面都存放着方法的代码,以及符号表(对象或字段的引用)以及常量池中的常量。
一个 JVM 的栈是由一系列的 帧
(frame)组成,当调用一个方法的时候,就会将一帧的内存入栈,当方法运行结束的时候(无论是正常返回还是抛出异常),就会将栈顶的帧给弹出。每一帧由下面几部分组成:
long
和 double
类型的值需要两个局部变量的空间来存储外,其他任何类型的值都可以存储在一个局部变量里面。
对 JVM 内部有一些基本的了解后,我们可以看一下由简单的代码生成的字节码的例子。在 Java 类文件里面的每个方法中,都有像下面那样格式的代码段:
操作码 操作数1 操作数2
也就是说,字节码是由一个操作码、0 个或多个操作数组成。
在当前正在运行的方法的栈帧中,指令可以将一个操作数压入操作栈中,也可以将一个操作数从操作栈中弹出,也可以悄悄地加载或存储局部变量数组里的值。我们来看一个简单的例子:
public static void main(String[] args) { int a = 1; int b = 2; int c = a + b; }
假设这些代码是在 Test.class
文件里的,为了从编译后的 class 文件中得到字节码,我们可以运行 javap
命令:
javap -v Test.class
然后我们就能得到下面的结果:
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 压入操作栈中。
istore_1
:将操作栈顶的内容弹出(一个 int 值),然后将其存放到下标为 1 的局部变量中,对应变量 a。
iconst_2
:将整数常量 2 压入操作栈中。
istore_2
:将操作栈顶的值弹出,并将其存储到下标为 2 的局部变量中,对应变量 b。
iload_1
:从下标为 1 的局部变量数组中加载出 int 值,并将其压入操作栈中。
iload_2
:从下标为 1 的局部变量数组中加载出 int 值,并将其压入操作栈中。
iadd
:将操作栈中顶部的两个 int 值弹出,将他们俩相加,然后将结果压回操作栈中。
istore_3
:将操作数顶部的 int 值弹出,并且将其存储到下标为 3 的局部变量数组中,对应源码中的变量 c。
return
:从 void 方法中返回。
上面的所有指令都只有一个操作数,表示 JVM 想要执行的具体操作是什么。
在上面的例子中只有一个方法,那就是 main 方法。假如变量 c 的值需要稍微复杂的方式才能计算出来,我们一般都会将 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 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
指令包含了用来调用方法引用所需的两个额外的字节。方法 cal
在 javap
中是由 #2
标识,而 #2
指向的是常量池中的的引用。
除此之外,在上面的字节码文件中我们可以发现 cal
方法本身的字节码。它首先将第一个整型参数加载到操作栈中( iload_0
)。而接下来的指令 i2d
则是将第一个整型操作数转为一个 double
类型。然后将转化后的 double
值替换原来的整型参数,占据操作栈的顶端。
接下来的指令,会从常量池中取出一个双精度浮点型常量 2.0d
,并将其压入操作栈中,这样就为 Math.pow
静态方法准备好了两个操作数(方法 calc
的第一个参数和常量 2.0d
)。当 Math.pow
方法执行完后,会将结果返回给调用它的操作栈,并压入栈顶,如下图所示:
计算 Math.pow(b, 2)
也是类似的流程:
再接下来的指令, dadd
,会将栈中的两个值弹出,将他们相加,然后将结果压入栈顶。最终, invokestatic
方法会调用 Math.sqrt
方法,该方法执行完后将结果强制转为 int 类型 ( d2i
),然后将 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: 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 }
在上面的字节码文件中,我们会遇到新的指令集: new
、 dup
和 invokespecial
。和编程语言中的 new
关键字一样, new
指令会创建一个在操作栈中指定类型的对象(即符号引用常量池的 Point
类)。对象会被分配到堆内存中,而指向该对象的引用会被压入操作栈。
dup
指令会复制一个栈顶的值,也就是说现在我们有两个指向 Point
对象的引用。接下来的三个指令的作用,会先将初始化对象所需的参数压入操作栈中,然后调用特定的初始化方法,也就是对应的 Point
类的构造方法。在这个调用方法中, x
和 y
对象会被初始化。当初始化方法结束后,栈顶的三个操作数都被消费了,只剩下最初指向创建对象(现在已经成功初始化了)的那个引用。
接下来, astore_1
会将 Point
引用弹出,并将其分配给局部变量数组中下标为 1 的变量(也就是 a
)。
创建并初始化第二个 Point
对象的流程也类似,最终会被分配给变量 b
。
接下来,将局部变量数组中,下标为 1 和 2 的 Point
对象引用加载到操作栈中(分别用指令 aload_1
和 aload_2
表示),然后使用 invokevirtual
指令调用 area
方法,该指令会负责根据对象的实际类型来调用合适的方法。比如,如果变量 a
包含了一个继承自 Point
类型的对象 SpecialPoint
,并且子类重写了 area
方法,那么重写的方法就会被调用。在我们上面这个例子中,由于没有子类,因此只有一个 area
方法可用。
然而,即使 area
方法只接受一个参数,仍然需要两个 Point
引用。第一个 Point
( pointA
,来自变量 a
) 是方法调用者(也就是程序语言中的 this
关键字),它会被传入 area
方法帧的第一个局部变量。第二个操作数 pointB
是 area
方法的参数。
如果只是想了解程序运行方式,你不需要对编译后的字节码文件里面的每一个指令都了解彻底。比如,我只想检查代码是否使用了 Java 的 steam
来读一个文件,并且还想知道 stream
是否被正确关闭。我反编译代码得到了下面的字节码文件,并且可以比较容易地发现,我确实使用了 steam
,而且很有可能是在 try - resource
语句中关闭了流。
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
语句的基本代码。
下面是完整的源代码:
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()); }
感谢这些简单的字节码指令集和缺失的编译器优化,使得在没有源代码的情况下,拆分类文件并且分析你的代码成了一种比较容易的方法。