Java字节码介绍

2019-12-25  本文已影响0人  紫色红色黑色

描述

本文截取并翻译introduction-to-java-bytecode一部分。翻译工具使用彩云小译。指令集可以参考jvm指令集

正文

JVM 数据类型

Java 是静态类型的语言,这影响了字节码指令的设计,使指令期望自己操作特殊类型的值。 例如,有几个添加指令来添加两个数字:iaddladdfadddadd。它们期望操作数类型分别为 int、 long、 float 和 double。 大多数字节码都具有这样的特点: 相同功能的指令,根据操作数类型的不同而不同。

由 JVM 定义的数据类型如下:

  1. 基本类型
    • 数值类型:byteshortintlongfloatdouble
    • 布尔类型:boolean
    • returnAddress
  2. 引用类型
    • Class
    • Array
    • Interface

boolean类型在字节码中的支持有限。 例如,没有直接对布尔值进行操作的指令。 编译器将布尔值转换为int,并使用相应的int指令。

Java 开发人员应该熟悉上述所有类型,除了returnAddress,它没有等效的编程语言类型。

基于栈的体系结构

字节码指令集的简单性主要是由于 Sun 设计了一个基于栈的 VM 架构,而不是一个基于寄存器的架构。 进程使用了各种各样的内存组件,但只有JVM栈需要详细检查,才能基本上遵循字节码指令:

jvm_stacks

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

  1. 局部变量数组(译者注:局部变量表,下文都用局部变量数组描述),下标从0到length-1。长度由编译器计算。局部变量可以保存任何类型的值,但longdouble比较特殊,它们占用两个局部变量。

  2. 操作数栈,用于存储作为指令操作数的中间值,或者将参数推入方法调用。


    stack_frame_zoom

字节码探索

通过对JVM内部构造的了解,我们可以看一下从示例代码生成的一些基本字节码示例。类文件中的每个方法都有一个由一系列指令组成的代码段,每个指令的格式如下:

opcode (1 byte) operand1 (optional) operand2 (optional) ...

该指令由一个字节的操作码和零个或多个包含要操作的数据的操作数组成。

在当前执行方法的栈帧中,指令可以将值推入或弹出到操作数栈中,并且可能加载或存储局部变量数组中的值。 让我们来看一个简单的例子:

public static void main(String[] args) {
    int a = 1;
    int b = 2;
    int c = a + b;
}

为了在编译后的类中打印字节码(假设它在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
...

我们可以看到main方法的方法签名,它是一个描述符,表示该方法接受一个字符串数组([Ljava/lang/String;) ,并具有一个 void 返回类型(V)。 接下来是一组标志,它们将该方法描述为 public (ACC_PUBLIC)和 static (ACC_STATIC)。

最重要的部分是Code属性,它包含方法的指令以及诸如操作数栈的最大深度(本例中为2),栈帧中分配的局部变量数量(本例中为4)等信息。 上述指令中引用了所有局部变量,但第一个变量(在索引0处)除外,该变量包含对args参数的引用。 其他3个局部变量对应于源代码中的变量abc

从地址0到8的指令将执行以下操作:

iconst_1:将整数常量1推送到操作数栈上

iconst_1

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

istore_1

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

iconst_2

istore_2:弹出顶部操作数int值并将其存储在局部变量数组索引为2的地方,该局部变量对应于变量b

istore_2

iload_1:从局部变量数组索引1处加载int值,并将其推到操作数栈上。

iload_1

iload_2:从局部变量数组索引2的处加载int值,并将其推到操作数栈上。

iload_2

iadd:从操作数栈中弹出顶部的两个int值,求和,并将结果推回操作数栈。

iadd

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

istore_3

return:没有返回值的返回

上面的每个指令都只包含一个操作码,这些操作码准确地规定了 JVM 要执行的操作。

方法调用

在上面的示例中,只有一个方法,即 main 方法。 假设我们需要对变量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

上述main方法代码的唯一区别在于,我们现在使用的不是iadd指令,而是调用静态方法calcinvokestatic指令。 需要注意的关键事项是,操作数栈包含传递给方法calc的两个参数。 换句话说,正在执行的方法按照正确的顺序将参数推送到操作数栈,以此来准备被调用方法的所有参数invokestatic(或者类似的invoke指令,稍后会看到)随后会弹出这些参数,并为被调用方法创建一个新的栈帧,其中参数们被放置在其局部变量数组中。

通过查看地址从6跳到9,我们注意到调用指令占用3个字节。 这是因为,与目前看到的所有指令不同,invokestatic包含两个额外的字节,用于构造被调用方法的引用(除了操作码)。javap将引用显示为#2,这是对calc方法的符号引用,可以从前面描述的常量池中解析获得。

另一个新信息显然是calc方法本身的代码。 它首先将第一个整数参数加载到操作数栈(iload_0)。 下一条指令i2d通过申请扩大转换将其转换为double。 生成的double替换操作数栈的顶部。

下一条指令将一个double常量2.0d(取自常量池)推送到操作数栈上。 然后使用准备好的两个操作数(calc的第一个参数和常量2.0d)调用静态Math.pow方法。 当Math.pow方法返回时,其结果将存储在调用程序的操作数栈中。 这可以在下面加以说明。

math_pow2

同样的过程也用于计算Math.pow(b,2)

math_pow21

下一条指令dadd弹出前面的两个中间结果,将它们相加,并将和返回到顶部。 最后,调用Math.sqrt对结果进行处理,并使用收缩转换(d2i)将结果从double转换为int。 结果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: (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

在这里遇到的新指令是newdupinvokespecial。 与编程语言中的new运算符类似,new指令创建一个传递给其操作数的指定类型的对象(这里是类Point的符号引用)。 对象的内存在堆上分配,对象的引用被推送到操作数栈上。

dup指令是复制操作数栈顶的值,这意味着现在栈顶我们有两个Point对象的引用。 接下来的三条指令将构造函数(用于初始化对象)的参数推送到操作数栈,然后调用与构造函数对应的特殊初始化方法。 下一个方法是初始化字段 x 和 y 的位置。 方法完成后,前三个操作数堆栈值被消费,剩下的是对创建对象的原始引用(到目前为止,已经成功初始化)。

init

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

init_store

对于创建和初始化第二个Point实例,重复执行相同的过程,该实例分配给变量b

init2 init_store2

最后一步从局部变量数组索引1和索引2处加载两个Point对象的引用(分别使用aload_1aload_2) ,并使用invokevirary调用area方法,该方法根据对象的实际类型执行调用。 例如,如果变量a包含一个继承PointSpecialPoint类型的实例,并且子类型覆盖了area方法,那么就会调用override方法。 在这种情况下,没有子类,因此只有一个area方法可用。

area2

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

反过来看

您不需要掌握对每条指令的理解和准确的执行流程,就可以了解基于手头的字节码程序所做的工作。 例如,在我的例子中,我想检查代码是否使用 Java stream来读取文件,以及stream是否被正确关闭。 现在,对于下面的字节码,确定流是否确实被使用是相对容易的,并且很有可能它是作为 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
   ...

我们可以看到java/util/Stream/Stream调用forEach,在此之前调用 InvokeDynamic并引用Consumer。 然后我们看到一个字节码块,它调用Stream.close以及调用Throwable.addSuppressed的分支。 这是由编译器为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());
}

总结

由于字节码指令集的简单性以及在生成指令时几乎没有编译器优化,反汇编类文件可以成为检查应用程序代码变化的一种方法,而不需要源代码,如果有这种需要的话。

原文

https://dzone.com/articles/introduction-to-java-bytecode

上一篇下一篇

猜你喜欢

热点阅读