深入了解 JVM 内部结构和 Java 字节码,了解如何反汇编文件以进行深入检查。
即使对于有经验的 Java 开发人员来说,阅读已编译的 Java 字节码也是一件乏味的事情。为什么我们一开始就需要知道这些低层次的东西呢?下面是上周发生在我身上的一个简单场景: 很久以前,我在我的机器上做了一些代码更改,编译了一个 JAR,并将其部署到服务器上,以测试潜在的性能问题。不幸的是,代码从来没有被签入版本控制系统,而且不管是什么原因,本地更改被无痕迹地删除了。几个月后,我又需要那些修改了的源代码(这需要花费相当大的功夫才想出来的) ,但是我找不到它们了!
幸运的是,已编译的代码仍然存在于远程服务器上。因此,我松了一口气,我再次获取了 JAR,并使用反编译器编辑器打开了它… … 只有一个问题: 反编译器 GUI 不是一个完美的工具,而且在那个 JAR 中的许多类中,出于某种原因,每当我打开它的时候,我希望反编译的那个类就会在 UI 中触发一个 bug,然后反编译器崩溃!
非常时期需要非常手段。幸运的是,我熟悉原始字节码,我宁愿花一些时间手动反编译一些代码片段,而不是重新修改代码并测试它们。由于我至少还记得在代码中的哪个位置查找代码,所以阅读字节码可以帮助我精确定位准确的更改,并以源代码的形式构造它们。(这次我一定要从我的错误中吸取教训并保护它们!)
字节码的好处是,你只需学习一次它的语法,然后它就可以应用到所有 Java 支持的平台上,因为它是代码的中间表示,而不是底层 CPU 的实际可执行代码。此外,字节码比本机代码更简单,因为 JVM 体系结构相当简单,因此简化了指令集。另一个好处是,Oracle 完整地记录了这个集合中的所有指令。
不过,在学习字节码指令集之前,让我们先熟悉一些 JVM 的必备知识。
JVM 数据类型
Java 是静态类型的,这影响了字节码指令的设计,以至于指令期望自己对特定类型的值进行操作。例如,有几个添加指令来添加两个数字: iadd,ladd,fadd,dadd。它们期望操作数类型分别为 int、 long、 float 和 double。大多数字节码具有这样一个特点,即根据操作数类型具有相同功能的不同形式。
JVM 定义的数据类型:
- 基本类型:
- Numeric types:
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
- Numeric types:
- 引用类型:
- Class types
- Array types
- Interface types
布尔类型在字节码中的支持有限。例如,没有直接对布尔值进行操作的指令。编译器将布尔值转换为 int,并使用相应的 int 指令。
Java 开发人员应该熟悉上述所有类型,除了 returnAddress,它没有等效的编程语言类型。
基于堆栈的体系结构
字节码指令集的简单性主要是由于 Sun 设计了一个基于堆栈的 VM 架构,而不是基于寄存器的架构。进程使用了各种各样的内存组件,但只有 JVM 堆栈需要详细检查,才能实质上遵循字节码指令:
PC 寄存器: 对于 Java 程序中运行的每个线程,PC 寄存器存储当前指令的地址。
JVM 栈: 对于每个线程,栈是被用来存储局部变量、方法参数和返回值的地方。下图显示了3个线程的栈。
堆: 所有线程和存储对象共享的内存(类实例和数组)。对象释放由垃圾回收器管理。
方法区域: 对于每个加载的类,它存储方法代码和符号表(例如对字段或方法的引用)以及常量池。
JVM 栈由框架组成,每个框架在方法被调用时推入栈,并在方法完成时从栈中弹出(通过正常返回或抛出异常)。每个框架还包括:
- 局部变量数组(local variables),索引从 0 到其长度减 1。长度由编译器计算。局部变量可以保存任何类型的值,除了 long 和 double 值,它们占用两个局部变量。
- 操作数栈(operand stack),一种操作数堆栈,用于存储作为指令操作数的中间值,或者将参数推入方法调用。
字节码探索
通过对 JVM 内部构造的了解,我们可以看一下从示例代码生成的一些基本字节码示例。类文件中的每个方法都有一个由一系列指令组成的代码段,每个指令的格式如下:
opcode (1 byte) operand1 (optional) operand2 (optional) ...
该指令由一个字节的操作码和零个或多个包含要操作的数据的操作数组成。
在当前执行方法的堆栈框架中,指令可以将值推入或弹出到操作数堆栈中,并且可以潜在地加载或存储数组局部变量中的值。让我们来看一个简单的例子:
1 | public static void main(String[] args) { |
为了在编译后的类中打印结果字节码(假设它在 Test.class 文件中) ,我们可以运行 javap 工具:
1 | javap -v Test.class |
然后会得到:
1 | public static void main(java.lang.String[]); |
我们可以看到 main
方法的方法签名,它是一个描述符,表示该方法接受一个 String 数组([ 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: 弹出 top 操作数(一个 int 值) ,并将其存储在索引1处的局部变量中,索引1对应于变量 a。
Iconst _ 2: 将整数常量2推送到操作数堆栈上。
istore_2: 弹出 top operand int value 并将其存储在 index 2的局部变量中,该局部变量对应于变量 b。
iload_1: 从索引1的局部变量加载 int 值,并将其推到操作数堆栈上。
iload_2: 从索引1的局部变量加载 int 值,并将其推到操作数堆栈上。
Iadd: 从操作数堆栈中弹出顶部的两个 int 值,添加它们,并将结果推回操作数堆栈。
Istore _ 3: 弹出 top operand int value 并将其存储在索引3处的局部变量中,该变量对应于变量 c。
return: 从 void 方法返回。
上面的每个指令都只包含一个操作码,这些操作码准确地规定了 JVM 要执行的操作。
方法调用
在上面的示例中,只有一个方法,即 main 方法。假设我们需要对变量 c 的值进行更精细的计算,并且我们决定将其放入一个新的方法 calc 中:
1 | public static void main(String[] args) { |
让我们看看生成的字节码:
1 | public static void main(java.lang.String[]); |
Main 方法代码的唯一区别在于,我们现在使用的不是 iadd
指令,而是调用 static 方法 calc 的 invokestatic
指令。需要注意的关键事项是,操作数堆栈包含传递给方法 calc 的两个参数。换句话说,调用方法通过按正确的顺序将参数推送到操作数堆栈上来准备待调用方法的所有参数。Invokestatic
(或类似的 invoke 指令,稍后将看到)随后将弹出这些参数,并为调用的方法创建一个新的框架,其中的参数放置在其局部变量数组中。
我们还注意到,通过查看地址,调用指令占用3个字节,从6跳到9。这是因为,与目前看到的所有指令不同,invokestatic
包含两个额外的字节,用于构造对要调用的方法的引用(除了操作码)。Javap 将引用显示为 #2
,这是对 calc
方法的符号引用,该方法是从前面描述的常量池中解析出来的。
另一个新信息显然是 calc 方法本身的代码。它首先将第一个整数参数加载到操作数堆栈(iload _ 0)。下一条指令 i2d 通过应用扩大转换将其转换为 double。生成的 double 替换操作数堆栈的顶部。
下一条指令将一个双常量2.0 d (从常量池中取出)推送到操作数堆栈上。然后使用迄今为止准备的两个操作数值(calc 的第一个参数和常量2.0 d)调用静态 Math.pow 方法。当 Math.pow 方法返回时,其结果将存储在其调用程序的操作数堆栈中。这一点可以在下面加以说明。
相同的过程也用于计算 Math.pow (b,2) :
下一个指令 dadd 弹出前两个中间结果,将它们相加,并将和返回到前面。最后,调用 Math.sqrt 对结果进行调用,并使用收缩转换(d2i)将结果从 double 转换为 int。生成的 int 返回到 main 方法,该方法将其存储回 c (istore _ 3)。
创建实例
让我们修改这个例子并引入一个类 Point 来封装 XY 坐标。
1 | public class Test { |
main 方法的编译后的字节码如下所示:
1 | public static void main(java.lang.String[]); |
在这里遇到的新指令是 new
, dup
和 invokespecial
。与编程语言中的新运算符类似,新指令创建一个在传递给它的操作数中指定的类型的对象(这是对类 Point
的符号引用)。对象的内存在堆上分配,对对象的引用被推送到操作数堆栈上。
Dup 指令重复顶部的操作数和堆栈值,这意味着我们现在有两个引用,即堆栈顶部的 Point 对象。接下来的三条指令将构造函数(用于初始化对象)的参数推送到操作数堆栈上,然后调用与构造函数对应的特殊初始化方法。下一个方法是初始化字段 x 和 y 的位置。方法完成后,前三个操作数堆栈值将被使用,剩下的是对所创建对象的原始引用(到目前为止,已经成功初始化)。
接下来,一个 store 1弹出这个 Point 引用,并将其赋值给索引1处的局部变量(store 1中的 a 表示这是一个引用值)。
在创建和初始化第二个 Point 实例时,重复执行相同的过程,该实例分配给变量 b。
最后一步从索引1和索引2处的局部变量加载对两个 Point 对象的引用(分别使用 aload 1和 aload 2) ,并使用 invokevirary 调用 area 方法,该方法根据对象的实际类型处理对适当方法的调用。例如,如果变量 a 包含一个类型 SpecialPoint 的扩展 Point 实例,并且子类型覆盖了 area 方法,那么将调用 overriden 方法。在这种情况下,没有子类,因此只有一个区域方法可用。
请注意,尽管 area 方法接受一个参数,但在堆栈顶部有两个 Point 引用。第一个(pointA,来自变量 a)实际上是调用方法的实例(在编程语言中也称为这个实例) ,它将被传递到新的 area 方法框架的第一个局部变量中。另一个操作数值(pointB)是 area 方法的参数。
另一种方式
您不需要掌握对每条指令的理解和准确的执行流程,就可以了解基于手头的字节码程序所做的工作。例如,在我的例子中,我想检查代码是否使用 Java 流来读取文件,以及流是否被正确关闭。现在,考虑到下面的字节码,确定流是否确实被使用是相对容易的,而且很可能它是作为 try-with-resources 语句的一部分被关闭的。
1 | public static void main(java.lang.String[]) throws java.lang.Exception; |
我们可以看到调用 forEach 的 java/util/Stream/Stream 的事件,在此之前调用 InvokeDynamic 并引用 Consumer。然后我们看到一个字节码块,它调用 Stream.close 和调用 throwable.addcompressed 的分支。这是由编译器为 try-with-resources 语句生成的基本代码。
以下完整的源码:
1 | public static void main(String[] args) throws Exception { |
总结
由于字节码指令集的简单性和生成指令时几乎没有编译器的优化,反汇编类文件可能是在没有源代码的情况下检查应用程序代码变化的一种方式。