• 中文
    • English
  • 注册
  • 查看作者
  • Java 字节码简介

    即使对于有经验的 Java 开发人员来说,阅读编译后的 Java 字节码也可能很乏味。为什么我们首先需要了解这些低级的东西?这是上周发生在我身上的一个简单场景:很久以前我在我的机器上做了一些代码更改,编译了一个 JAR,并将其部署在服务器上以测试性能问题的潜在修复。不幸的是,该代码从未 checked 到版本控制系统,而且出于某些原因,本地更改都被删除了。几个月后,我再次需要对源代码进行一些更改(这需要付出极大努力才能想出),但我找不到它们!所幸编译后的代码仍然存在于远程服务器上。总算松了一口气,我再次取出 JAR 并使用反编译器编辑器打开它……只有一个问题:反编译器 GUI 不是一个完美的工具,在那个 JAR 中的许多类中,出于某种原因,只有每当我打开它时,我想要反编译的特定类都会导致 UI 出现错误,并且反编译器会崩溃!非常之时行非常之事。幸运的是,我熟悉原始字节码,我宁愿花一些时间手动反编译一些代码,也不愿完成更改并再次测试它们。由于我仍然记得至少在哪里查看代码,阅读字节码帮助我查明确切的更改并以源代码的形式重新构建它们。(这次我一定要从我的错误中吸取教训并保存它们!)字节码的好处在于,您只需学习一次它的语法,然后 ——因为它是代码的中间表示,而不是底层 CPU 的实际可执行代码。此外,字节码比本地机器码更简单,因为 相当简单,因此简化了指令集。另一件好事是,这组指令中的所有指令都由 Oracle 不过,在了解字节码指令集之前,让我们先熟悉一些有关 JVM 的先决条件。

    JVM 数据类型

    Java 是静态类型的,这会影响字节码指令的设计,使得指令期望自己对特定类型的值进行操作。例如,有几个 add 指令可以将两个数字相加:iadd, ladd, fadd, dadd。他们期望操作数的类型分别为 int、long、float 和 double。大多数字节码具有根据操作数类型具有不同形式的相同功能的特性。JVM 定义的数据类型有:原始类型:

    • 数值类型: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)

    • boolean 类型

    • returnAddress: 指令指针

    引用类型:

    • Class 类型

    • Array 类型

    • Interface 类型

    boolean 类型在字节码中的支持有限。例如,没有直接对 boolean 值进行操作的指令。相反,布尔值由编译器转换为 int 并使用相应的 int 指令。Java 开发人员应该熟悉上述所有类型,除了 returnAddress 没有等效的编程语言类型。

    基于栈的架构

    字节码指令集的简单性很大程度上是由于 Sun 设计了基于栈的 VM 架构,而不是基于寄存器的架构。JVM 进程使用各种内存组件,但只需要详细检查 JVM 栈,以便基本上能够遵循字节码指令: PC 寄存器: 对于Java程序中运行的每个线程,PC 寄存器存储当前指令的地址。 JVM 栈: 为每个线程分配一个 ,用于存储局部变量、方法参数和返回值。这是一个显示 3 个线程的栈的插图。

    Java 字节码简介

    堆: 所有线程共享的内存空间,用于存储对象(类实例和数组)。对象释放由垃圾收集器管理。

    Java 字节码简介

    方法区: 对于每个加载的类,它存储方法代码和符号表(例如对字段或方法的引用)和常量池中的常量。

    Java 字节码简介

    JVM 栈由 栈帧 组成,每个栈帧在调用方法时被压入栈,并在方法完成时从栈中弹出(通过正常返回或抛出异常)。每个栈帧还包括:

    1. 局部变量数组,索引从 0 到其长度减 1。长度由编译器计算。一个局部变量可以保存任何类型的值,除了 long 和 double 值,它占用两个局部变量。

    2. 一个操作数栈,用于存储中间值,这些值将充当指令的操作数,或将参数推送到方法调用。

    Java 字节码简介

    字节码探索

    了解了 JVM 的内部结构后,我们可以查看一些从示例代码生成的基本字节码示例。Java 类文件中的每个方法都有一个代码段,该代码段由一系列指令组成,每个指令具有以下格式:opcode (1 byte) operand1 (optional) operand2 (optional) …这是一条由一个字节的操作码和包含零个或多个操作数组成的指令。在当前执行方法的栈帧中,一条指令可以将值压入或弹出操作数栈,并且它可以潜在地加载或存储数组局部变量中的值。让我们看一个简单的例子:

    为了在编译后的类中打印生成的字节码(假设它在文件中 Test.class ),我们可以运行该 javap 工具:

    我们得到:

    我们可以看到该方法的方法签名 main,一个描述符,指示该方法以一个字符串数组为参数( [Ljava/lang/String;),并具有一个 void 返回类型 ( V)。一组标志将方法描述为 public ( ACC_PUBLIC) 和 static ( ACC_STATIC)。最重要的部分是 Code 属性,它包含方法的指令以及诸如操作数栈的最大深度(在这种情况下为 2)以及为该方法在帧中分配的局部变量的数量(在这种情况下为 4)等信息。这个案例中,除了第一个(在索引 0 处),所有局部变量都在上述指令中被引用,它保存对 args 参数的引用。其它 3 个局部变量对应于 variables a, b 和 c 在源代码中。地址 0 到 8 的指令将执行以下操作:iconst_1:将整数常量 1 压入操作数栈。

    Java 字节码简介

    istore_1:弹出顶部操作数(一个 int 值)并将其存储在索引 1 处的局部变量中,该变量对应于 variable a。

    Java 字节码简介

    iconst_2:将整数常量 2 压入操作数栈。

    Java 字节码简介

    istore_2:弹出顶部操作数 int 值并将其存储在索引 2 处的局部变量中,该变量对应于 variable b。

    Java 字节码简介

    iload_1:从索引 1 处的局部变量加载 int 值并将其压入操作数栈。

    Java 字节码简介

    iload_2:从索引 2 处的局部变量加载 int 值并将其压入操作数栈。

    Java 字节码简介

    iadd:从操作数栈中弹出顶部的两个 int 值,将它们相加,然后将结果推回操作数栈。

    Java 字节码简介

    istore_3:弹出顶部操作数 int 值并将其存储在索引 3 处的局部变量中,该变量对应于 variable c。

    Java 字节码简介

    return: 从 void 方法返回。上面的每条指令都只包含一个操作码,它准确地指示了 JVM 将要执行的操作。

    方法调用

    在上面的示例中,只有一种方法,即 main 方法。假设我们需要对 variable 的值进行更精细的计算 c,我们决定将其放在一个名为 的新方法中 calc:

    让我们看看生成的字节码:

    main 方法代码中的唯一区别是,我们现在不是 iadd 指令,而是调用静态指令,它只是调用静态方法 calc。需要注意的关键点是操作数栈包含传递给方法的两个参数 calc。换句话说,调用方法通过将要调用方法的所有参数以正确的顺序推入操作数栈来准备它们。 invokestatic(或类似的调用指令,稍后将看到)随后将弹出这些参数,并为调用的方法创建一个新栈帧,其中参数放置在其局部变量数组中。 我们还注意到,invokestatic 通过查看地址,该指令占用了 3 个字节,从 6 跳转到了 9。这是因为,与目前看到的所有指令不同,invokestatic 它包含两个额外的字节来构造对要调用的方法的引用(另外到操作码)。该引用由 javap as 显示#2,它是对该方法的符号引用,该 calc 方法是从前面描述的常量池中解析的。其它新信息显然是 calc 方法本身的代码。它首先将第一个整数参数加载到操作数栈 ( iload_0) 上。下一条指令 , i2d 通过应用扩展转换将其转换为双精度。结果 double 替换操作数栈的顶部。下一条指令将一个 double 常量 2.0d (取自常量池)压入操作数栈。然后以准备好的两个操作数值(calc 的第一个参数和常量 2.0d)调用静态 Math.pow 方法。当 Math.pow 方法返回时,其结果将存储在其调用者的操作数栈顶。这可以在下面说明。

    Java 字节码简介

    相同的过程适用于计算 Math.pow(b, 2):

    Java 字节码简介

    下一条指令 dadd 弹出前两个中间结果,将它们相加,然后将结果推回栈顶。最后,将此结果为参数, invokestatic 指令调用 Math.sqrt ,并且将结果通过 ( d2i) 从 double 转换为 int。int 返回值返回给 main 方法,该方法将其存储回 c ( istore_3)。

    实例创建

    我们修改例子,引入一个类 Point 来封装XY坐标。

    该 main 方法的编译字节码如下所示:

    这里遇到的新指令是 new、dup 和 invokespecial。与编程语言中的 new 运算符类似,该 new 指令创建一个在传递给它的操作数中指定的类型的对象(这是对 class 的符号引用 Point)。对象的内存在堆上分配,且对象的引用被压入操作数栈。该 dup 指令复制顶部操作数栈值,这意味着现在我们有两个引用 Point 栈顶部的对象。接下来的三个指令将构造函数的参数(用于初始化对象)压入操作数栈,然后调用与构造函数对应的特殊初始化方法。下一个方法是字段 x 和 y 将被初始化的地方。方法完成后,前三个操作数栈值被消耗掉,剩下的是对创建对象的原始引用(此时已成功初始化)。

    Java 字节码简介

    接下来, astore_1 弹出该 Point 引用并将其分配给索引 1 处的局部变量(a 在 astore_1 中表示这是一个引用值)。

    Java 字节码简介

    重复相同的过程来创建和初始化 Point 分配给变量的第二个实例 b。

    Java 字节码简介

    Java 字节码简介

    最后一步从索引 1 和 2 处的局部变量加载对两个 Point 对象的引用(分别使用 aload_1 和 aload_2 ),并通过 invokevirtual 指令调用 area 方法,该方法根据对象的实际类型将调用分派给适当的方法。例如,如果变量 a 包含 SpecialPoint 扩展类型的实例 Point,并且子类型覆盖了该 area 方法,则调用覆盖的方法。在这种情况下,没有子类,因此只有一种 area 方法可用。

    Java 字节码简介

    请注意,即使该方法接受一个参数,栈顶部 area 也有两个 引用。Point 第一个(pointA,来自 variable a)实际上是调用方法的实例(在编程语言中也称为 this),它将在 area 方法的新栈帧的第一个局部变量中传递。另一个操作数值 (pointB) 是 area 方法的参数。

    另一种方式

    您无需掌握对每条指令和确切执行流程的理解,即可根据手头的字节码了解程序的功能。例如,在我的例子中,我想检查代码是否使用 Java 来读取文件,以及流是否正确关闭。现在给定以下字节码,相对容易确定确实使用了一个流,并且很可能它作为 try-with-resources 语句的一部分被关闭。

    我们看到 java/util/stream/Stream 的出现,其中 forEach 被调用,前面是对 InvokeDynamic 的调用和对 Consumer 的引用。然后我们看到一大块调用 Stream.close 的字节码以及调用 Throwable.addSuppressed. 这是编译器为 生成的基本代码。这是完整性的原始来源:

    结论

    由于字节码指令集的简单性以及在生成指令时几乎没有编译器优化,因此,如有必要,反编译类文件可能是一种无需源代码即可检查应用程序代码更改的方法。

  • 0
  • 0
  • 0
  • 15
  • 请登录之后再进行评论

    登录
  • 任务
  • 实时动态
  • 发布
  • 单栏布局 侧栏位置: