[译]Java字节码介绍

原文:Introduction to Java Bytecode
深入了解JVM和字节码,以及如何反汇编文件以进行深入分析。
阅读Java字节码可能很繁琐,即使对于有经验的开发亦是如此。为什么我们需要了解这些低级别的东西?
上周发生了一个场景:很久以前我进行了一些代码更改,并部署在服务器上。遗憾的是,源代码未放入版本控制系统,几个月后,我再次需要修改代码的时候,我找不到了!

但编译后的代码仍然存在于服务器上。所以我松了一口气,我找到JAR并使用反编译器打开它。然而,反编译的类时出现异常,导致反编译器崩溃了。

幸运的是,我熟悉字节码,我手动反编译一些代码,而不是重新修改并测试。因为我还记得代码中改动位置,阅读字节码能帮助我以源代码的形式重新构建它们。

关于字节码的好处是,学习它的语法一次,就适用于所有Java支持的平台。因为它是代码的中间表示层,而不是底层CPU的实际可执行代码。此外,字节码比机器码简单,因为JVM架构相当简单,简化了指令集。另一个好处是Oracle文档中完整记录了所有指令。
在学习字节码指令集之前,让我们先熟悉一下JVM。

JVM数据类型

Java是静态类型的,它影响字节码指令的设计,让指令对特定类型的值进行操作。例如,有一些相加两个数字的指令:iadd,ladd,fadd,dadd。它们的操作数分别为int,long,float和double。大多数指令具有操作数类型不同但功能相同的特征。

JVM定义的数据类型有:

  1. 原始类型:
    1. 数字类型:byte(8位2进制补码)short(16位2进制补码),int(32位2进制补码),long(64位2进制补码),char(16位无符号Unicode),float(32位单精度浮点),double(64位双精度浮点)
    2. boolean 类型
    3. returnAddress:指向指令的指针
  2. 引用类型:
    1. 类类型
    2. 数组类型
    3. 接口类型

boolean类型在字节码中的支持有限,没有直接操作boolean的指令。而是由编译器转换为int,并使用相应的int指令。

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

基于堆栈的体系结构

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

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

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

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

  1. 一个局部变量数组,索引从0到其长度减1.长度由编译器计算。局部变量可以包含任何类型的值,特别的,long和double值占用两个局部变量位。
  2. 一个操作数栈,用于存储中间值,这些中间值将充当指令的操作数,或者将参数推送到方法调用。

字节码探索

有了JVM内部的概念,我们可以看一下示例代码生成的字节码。Java类文件中的每个方法都有一个代码段,该代码段由一系列指令组成,每个指令都具有以下格式:

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,一个描述符descriptor,表示该方法采用了一个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:弹出顶部操作数(一个int值)并将其存储在索引1的局部变量中,该变量对应于变量a。

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

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

iload_1:从索引为1的局部变量加载int值并将其推送到操作数栈。

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

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

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

return:从void方法返回。
上述每条指令都只包含一个操作码,它完全决定了JVM要执行的操作。
译者注:
为什么所有操作需要经过操作数栈?
如果我们要把一颗苹果从桌子的左边挪到右边,看似一步,其实分为两步。一、把左边的苹果拿到手里,二、把手里的苹果放到右边,这里的手就是操作数栈。
从计算机组成原理来看,操作数栈对应着CPU寄存器,局部变量表对应着内存。

方法调用

在上面的例子中只有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指令换成了invokestatic,调用静态方法calc而不是使用iadd指令。需要注意的是操作数栈包含传递给方法calc的两个参数。
换句话说,调用方法顺序的将参数推入操作数栈,准备了被调用的方法的所有参数。invokestatic(或类似的调用指令,稍后将会看到)将随后弹出这些参数,并为被调用的方法创建一个新的栈帧,其中参数放在其局部变量数组中。

我们还注意到invokestatic指令占用3个字节,该地址下标为6到9。这是因为,与目前为止看到的所有指令不同,invokestatic需要额外的两个字节来构造对要调用的方法的引用。该引用在javap显示为#2,这是calc方法的符号引用,它是从前面所说的常量池中解析出来的。
接下来自然是calc方法的代码。它首先将第一个整数参数加载到操作数栈(iload_0)上。
下一条类型转换指令i2d(int to double),将其转换为double。结果double将替换操作数栈的顶部。
再下一条指令将double常量2.0d(取自常量池)推送到操作数栈。然后用两个操作数(calc的第一个参数和常量2.0d)调用静态方法Math.pow。
当Math.pow方法返回时,其结果将存储在其调用者的操作数栈中,如下图。

同样的来计算Math.pow(b, 2)。

下一条指令dadd弹出前两个中间结果求和,推回到栈顶部。最后,invokestatic调用Math.sqrt,并使用类型转换指令d2i(double to int)将结果从double转换为int 。生成的int返回给main方法,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

这里遇到的新指令是new、dup和invokespecial。与编程语言中的new类似,new指令创建一个指定类型的对象,传递引用给它的操作数。对象的内存在堆上分配,对象的引用在操作数栈上分配。

dup指令复制了操作数栈顶部的值,这意味着现在有两个引用堆栈顶部的Point对象。接下来的三个指令将构造函数的参数(用于初始化对象)推送到操作数栈,然后调用一个特殊的初始化方法,该方法与构造函数相对应,字段x和y将得到初始化。方法完成后,将销毁前三个操作数栈值,剩下的是创建的对象的原始引用。

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

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


最后一步从索引1和2的局部变量(分别使用aload_1和aload_2)加载对两个Point对象的引用,并通过invokevirtual调用area方法,该指令会根据对象的实际类型调用适当方法。
例如,如果变量a是Point的子类实例,并且子类型重写了area方法,则调用重写的方法。现在这里没有子类,因此只有一种area方法可用。

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

另一种方式

您不需要掌握每条指令和确切执行流程,以了解程序的功能。例如,在我的情况下,我想检查代码是否使用了Java流来读取文件,以及流是否已正确关闭。现在给出以下字节码,相对容易确定使用了流,并且很可能是作为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(37行),这之前的InvokeDynamica的调用了java/util/function/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());
}

结语

由于字节码指令集的简单性以及在生成指令时几乎没有编译器优化,反编译类文件是检查代码的一种方法,而无需源码。

Comments
Write a Comment