原文链接:https://dzone.com/articles/introduction-to-java-bytecode
即使对于一名经验丰富的 Java 程序员来说,阅读编译后的 Java 字节码也会感到枯燥。我们为什么需要了解如此底层的信息呢?在上周,我遇到了一个场景:在很早以前,我在自己的电脑上修改了一些代码,编译成了一个 JAR 文件,然后发布到服务端,检查我更改后的代码是否修复了一个性能问题。不幸的是,这些代码没有被检入版本控制系统,而且不知道什么原因,本地的修改在没有任何的记录下也没了。几个月后的上周,我再次需要这些修改后的代码,然而我找不到他们了!
幸运的是,编译后的代码仍然保留在远程服务端,抱着一丝希望,我下载了 JAR 包然后用反编译软件打开它…然而有一个问题:这个反编译软件的 GUI 界面并不是无瑕疵的,在查看反编译后的某些类时,会导致这个软件崩溃!
特殊情况特殊处理。好在我对字节码还有点印象,相比于使用那个注定会崩溃的反编译软件,我更偏向于自己手动反编译一些代码。
了解字节码的好处在于,一旦你掌握了它,那么在所有 Java 虚拟机支持的平台都能适用——因为字节码只是代码的 IR(中间表示),并不是底层 CPU 直接执行的代码。而且,相比于机器码,由于 JVM 的架构相对比较简单,JVM 的指令集也相对比较少,因此字节码比较容易掌握。并且,Oracle 对这些指令提供了完整的说明文档!
在学习字节码指令集之前,我们先熟悉一下关于 JVM 的一些常识。
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)组成,当调用一个方法的时候,就会将一帧的内存入栈,当方法运行结束的时候(无论是正常返回还是抛出异常),就会将栈顶的帧给弹出。每一帧由下面几部分组成:
- 一个局部变量数组,索引序列从 0 到数组长度减一。数组长度是由编译器计算的。除了
long
和double
类型的值需要两个局部变量的空间来存储外,其他任何类型的值都可以存储在一个局部变量里面。 - 一个用来存储中间变量的操作栈。该中间变量的作用是充当指令的操作数,或者存放方法调用的参数。
浏览字节码
对 JVM 内部有一些基本的了解后,我们可以看一下由简单的代码生成的字节码的例子。在 Java 类文件里面的每个方法中,都有像下面那样格式的代码段:
1 | 操作码 操作数1 操作数2 |
也就是说,字节码是由一个操作码、0 个或多个操作数组成。
在当前正在运行的方法的栈帧中,指令可以将一个操作数压入操作栈中,也可以将一个操作数从操作栈中弹出,也可以悄悄地加载或存储局部变量数组里的值。我们来看一个简单的例子:
1 | public static void main(String[] args) { |
假设这些代码是在 Test.class
文件里的,为了从编译后的 class 文件中得到字节码,我们可以运行 javap
命令:
1 | javap -v Test.class |
然后我们就能得到下面的结果:
1 | public class Test |
我们可以看到 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
:
1 | public static void main(String[] args) { |
我们看一下对应的字节码:
1 | public class Test |
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 的面积。
1 | public class Test { |
编译后的 main 方法对应的字节码如下:
1 | public static void main(java.lang.String[]); |
在上面的字节码文件中,我们会遇到新的指令集: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
语句中关闭了流。
1 | public static void main(java.lang.String[]) throws java.lang.Exception; |
可以看到 java/util/stram/Stream
类里面的 forEach
方法确实被调用了,而在这之前,会调用一个指向 Consumer
对象引用的方法 InvokeDynamic
。然后我们看到一大堆调用了 Steam.close
方法的字节码和调用 Throwable.addSuppressed
的跳转分支。这些是编译器编译 try - with - resource
语句的基本代码。
下面是完整的源代码:
1 | public static void main(String[] args) throws Exception { |
总结
感谢这些简单的字节码指令集和缺失的编译器优化,使得在没有源代码的情况下,拆分类文件并且分析你的代码成了一种比较容易的方法。