转载

如何阅读 Java 字节码

原文链接: https://dzone.com/articles/introduction-to-java-bytecode

即使对于一名经验丰富的 Java 程序员来说,阅读编译后的 Java 字节码也会感到枯燥。我们为什么需要了解如此底层的信息呢?在上周,我遇到了一个场景:在很早以前,我在自己的电脑上修改了一些代码,编译成了一个 JAR 文件,然后发布到服务端,检查我更改后的代码是否修复了一个性能问题。不幸的是,这些代码没有被检入版本控制系统,而且不知道什么原因,本地的修改在没有任何的记录下也没了。几个月后的上周,我再次需要这些修改后的代码,然而我找不到他们了!

幸运的是,编译后的代码仍然保留在远程服务端,抱着一丝希望,我下载了 JAR 包然后用反编译软件打开它…然而有一个问题:这个反编译软件的 GUI 界面并不是无瑕疵的,在查看反编译后的某些类时,会导致这个软件崩溃!

特殊情况特殊处理。好在我对字节码还有点印象,相比于使用那个注定会崩溃的反编译软件,我更偏向于自己手动反编译一些代码。

了解字节码的好处在于,一旦你掌握了它,那么在所有 Java 虚拟机支持的平台都能适用——因为字节码只是代码的 IR(中间表示),并不是底层 CPU 直接执行的代码。而且,相比于机器码,由于 JVM 的架构相对比较简单,JVM 的指令集也相对比较少,因此字节码比较容易掌握。并且,Oracle 对这些指令提供了完整的说明文档!

在学习字节码指令集之前,我们先熟悉一下关于 JVM 的一些常识。

JVM 数据类型

Java 是一门静态类型的语言,这也影响了字节码指令集的设计,比如,一个指令希望它自己能在一个指定类型的值上进行操作。举个例子,将两个数相加的加法指令,有 iaddladdfadddadd 。他们指定的操作数类型,分别是 intlongfloatdouble 。一些字节码,他们的功能相同,但由于操作数类型不同,也就有了不同的特征。

JVM 定义的数据类型有:

  1. 基本数据类型:
    • 数字类型: byte (8位), short (16 位), int (32 位), long (64 位), char (16 位无符号 Unicode), float (32 位 IEEE 754 单精度), double (64 位 IEEE 754 双精度).
    • boolean 类型。
    • returenAdress :指令指针。
  2. 引用类型:
    • 类类型。
    • 集合类型。
    • 接口类型。

boolean 类型在字节码里面支持有限。比如,并没有一个指令可以直接操作 boolean 类型的值。布尔值一般会被编译器转为 int 类型的值,并且用 int 相关的指令来操作。

除了 returnAddress 类型没有对应的程序语言类型以外,Java 开发者对上面所提到的类型应该很熟悉。

栈基架构

字节码指令集的简单很大程度上归功于 Sun 公司设计的基于栈的虚拟机架构,一个 JVM 进程里面使用了很多内存组件,但只要详细检查 JVM 的堆栈信息,就能够了解下面的指令集:

PC 寄存器:Java 程序里面的每一个运行的线程,都有一个 PC 寄存器存储着当前指令的地址。

JVM 栈:对于每一个线程, 是用来存放局部变量、方法参数以及返回值的。下面这张图表示三个线程的栈信息:

如何阅读 Java 字节码

堆:所有线程共享的内存区域,存放着对象(类的实例化和数组)。对象由垃圾回收器进行再分配。

如何阅读 Java 字节码

方法区:对于每一个加载的类,方法区里面都存放着方法的代码,以及符号表(对象或字段的引用)以及常量池中的常量。

如何阅读 Java 字节码

一个 JVM 的栈是由一系列的 (frame)组成,当调用一个方法的时候,就会将一帧的内存入栈,当方法运行结束的时候(无论是正常返回还是抛出异常),就会将栈顶的帧给弹出。每一帧由下面几部分组成:

  1. 一个局部变量数组,索引序列从 0 到数组长度减一。数组长度是由编译器计算的。除了 longdouble 类型的值需要两个局部变量的空间来存储外,其他任何类型的值都可以存储在一个局部变量里面。
  2. 一个用来存储中间变量的操作栈。该中间变量的作用是充当指令的操作数,或者存放方法调用的参数。

如何阅读 Java 字节码

浏览字节码

对 JVM 内部有一些基本的了解后,我们可以看一下由简单的代码生成的字节码的例子。在 Java 类文件里面的每个方法中,都有像下面那样格式的代码段:

操作码		  操作数1		操作数2

也就是说,字节码是由一个操作码、0 个或多个操作数组成。

在当前正在运行的方法的栈帧中,指令可以将一个操作数压入操作栈中,也可以将一个操作数从操作栈中弹出,也可以悄悄地加载或存储局部变量数组里的值。我们来看一个简单的例子:

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

假设这些代码是在 Test.class 文件里的,为了从编译后的 class 文件中得到字节码,我们可以运行 javap 命令:

javap -v Test.class

然后我们就能得到下面的结果:

public class Test
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#12         // java/lang/Object."<init>":()V
   #2 = Class              #13            // Test
   #3 = Class              #14            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               main
   #9 = Utf8               ([Ljava/lang/String;)V
  #10 = Utf8               SourceFile
  #11 = Utf8               Test.java
  #12 = NameAndType        #4:#5          // "<init>":()V
  #13 = Utf8               Test
  #14 = Utf8               java/lang/Object
{
  public Test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: 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
      LineNumberTable:
        line 3: 0
        line 4: 2
        line 5: 4
        line 6: 8
}

我们可以看到 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 压入操作栈中。

如何阅读 Java 字节码

istore_1 :将操作栈顶的内容弹出(一个 int 值),然后将其存放到下标为 1 的局部变量中,对应变量 a。

如何阅读 Java 字节码

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

如何阅读 Java 字节码

istore_2 :将操作栈顶的值弹出,并将其存储到下标为 2 的局部变量中,对应变量 b。

如何阅读 Java 字节码

iload_1 :从下标为 1 的局部变量数组中加载出 int 值,并将其压入操作栈中。

如何阅读 Java 字节码

iload_2 :从下标为 1 的局部变量数组中加载出 int 值,并将其压入操作栈中。

如何阅读 Java 字节码

iadd :将操作栈中顶部的两个 int 值弹出,将他们俩相加,然后将结果压回操作栈中。

如何阅读 Java 字节码

istore_3 :将操作数顶部的 int 值弹出,并且将其存储到下标为 3 的局部变量数组中,对应源码中的变量 c。

如何阅读 Java 字节码

return :从 void 方法中返回。

上面的所有指令都只有一个操作数,表示 JVM 想要执行的具体操作是什么。

方法调用

在上面的例子中只有一个方法,那就是 main 方法。假如变量 c 的值需要稍微复杂的方式才能计算出来,我们一般都会将 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 class Test
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #8.#19         // java/lang/Object."<init>":()V
   #2 = Methodref          #7.#20         // Test.calc:(II)I
   #3 = Double             2.0d
   #5 = Methodref          #21.#22        // java/lang/Math.pow:(DD)D
   #6 = Methodref          #21.#23        // java/lang/Math.sqrt:(D)D
   #7 = Class              #24            // Test
   #8 = Class              #25            // java/lang/Object
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               main
  #14 = Utf8               ([Ljava/lang/String;)V
  #15 = Utf8               calc
  #16 = Utf8               (II)I
  #17 = Utf8               SourceFile
  #18 = Utf8               Test.java
  #19 = NameAndType        #9:#10         // "<init>":()V
  #20 = NameAndType        #15:#16        // calc:(II)I
  #21 = Class              #26            // java/lang/Math
  #22 = NameAndType        #27:#28        // pow:(DD)D
  #23 = NameAndType        #29:#30        // sqrt:(D)D
  #24 = Utf8               Test
  #25 = Utf8               java/lang/Object
  #26 = Utf8               java/lang/Math
  #27 = Utf8               pow
  #28 = Utf8               (DD)D
  #29 = Utf8               sqrt
  #30 = Utf8               (D)D
{
  public Test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: 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
      LineNumberTable:
        line 3: 0
        line 4: 2
        line 5: 4
        line 6: 10

  static int calc(int, int);
    descriptor: (II)I
    flags: 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
      LineNumberTable:
        line 9: 0
}

Main 方法中唯一不同的代码就是讲之前的 iadd 指令,变成了现在的 invokestatic 指令,改指令只是调用了静态方法 calc 。要注意的是,操作栈中包含了需要传递给 calc 方法的两个参数,也就是说, 方法调用方会准备好被调用的方法所需的参数,并且将这些参数按照正确的顺序压入操作栈中。 invokestatic (或者其他类似的方法调用) 会依次弹出这些参数,同时会为被调用的方法创建一个新的帧,被调用的方法所需的参数存放在新栈帧的局部变量数组中。

同时,通过观察地址我们可以看到 invokestatic 指令占据了 5、6、7 三个地址索引,也就是说, invokestatic 指令占据了三个字节。和我们目前了解到的指令集不同, invokestatic 指令包含了用来调用方法引用所需的两个额外的字节。方法 caljavap 中是由 #2 标识,而 #2 指向的是常量池中的的引用。

除此之外,在上面的字节码文件中我们可以发现 cal 方法本身的字节码。它首先将第一个整型参数加载到操作栈中( iload_0 )。而接下来的指令 i2d 则是将第一个整型操作数转为一个 double 类型。然后将转化后的 double 值替换原来的整型参数,占据操作栈的顶端。

接下来的指令,会从常量池中取出一个双精度浮点型常量 2.0d ,并将其压入操作栈中,这样就为 Math.pow 静态方法准备好了两个操作数(方法 calc 的第一个参数和常量 2.0d )。当 Math.pow 方法执行完后,会将结果返回给调用它的操作栈,并压入栈顶,如下图所示:

如何阅读 Java 字节码

计算 Math.pow(b, 2) 也是类似的流程:

如何阅读 Java 字节码

再接下来的指令, dadd ,会将栈中的两个值弹出,将他们相加,然后将结果压入栈顶。最终, invokestatic 方法会调用 Math.sqrt 方法,该方法执行完后将结果强制转为 int 类型 ( d2i ),然后将 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: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=4, args_size=1
         0: new           #2                  // class Point
         3: dup
         4: iconst_1
         5: iconst_1
         6: invokespecial #3                  // Method Point."<init>":(II)V
         9: astore_1
        10: new           #2                  // class Point
        13: dup
        14: iconst_5
        15: iconst_3
        16: invokespecial #3                  // Method Point."<init>":(II)V
        19: astore_2
        20: aload_1
        21: aload_2
        22: invokevirtual #4                  // Method Point.area:(LPoint;)I
        25: istore_3
        26: return
      LineNumberTable:
        line 3: 0
        line 4: 10
        line 5: 20
        line 6: 26
}

在上面的字节码文件中,我们会遇到新的指令集: newdupinvokespecial 。和编程语言中的 new 关键字一样, new 指令会创建一个在操作栈中指定类型的对象(即符号引用常量池的 Point 类)。对象会被分配到堆内存中,而指向该对象的引用会被压入操作栈。

dup 指令会复制一个栈顶的值,也就是说现在我们有两个指向 Point 对象的引用。接下来的三个指令的作用,会先将初始化对象所需的参数压入操作栈中,然后调用特定的初始化方法,也就是对应的 Point 类的构造方法。在这个调用方法中, xy 对象会被初始化。当初始化方法结束后,栈顶的三个操作数都被消费了,只剩下最初指向创建对象(现在已经成功初始化了)的那个引用。

如何阅读 Java 字节码

接下来, astore_1 会将 Point 引用弹出,并将其分配给局部变量数组中下标为 1 的变量(也就是 a )。

如何阅读 Java 字节码

创建并初始化第二个 Point 对象的流程也类似,最终会被分配给变量 b

如何阅读 Java 字节码

如何阅读 Java 字节码

接下来,将局部变量数组中,下标为 1 和 2 的 Point 对象引用加载到操作栈中(分别用指令 aload_1aload_2 表示),然后使用 invokevirtual 指令调用 area 方法,该指令会负责根据对象的实际类型来调用合适的方法。比如,如果变量 a 包含了一个继承自 Point 类型的对象 SpecialPoint ,并且子类重写了 area 方法,那么重写的方法就会被调用。在我们上面这个例子中,由于没有子类,因此只有一个 area 方法可用。

如何阅读 Java 字节码

然而,即使 area 方法只接受一个参数,仍然需要两个 Point 引用。第一个 PointpointA ,来自变量 a ) 是方法调用者(也就是程序语言中的 this 关键字),它会被传入 area 方法帧的第一个局部变量。第二个操作数 pointBarea 方法的参数。

另一种方式

如果只是想了解程序运行方式,你不需要对编译后的字节码文件里面的每一个指令都了解彻底。比如,我只想检查代码是否使用了 Java 的 steam 来读一个文件,并且还想知道 stream 是否被正确关闭。我反编译代码得到了下面的字节码文件,并且可以比较容易地发现,我确实使用了 steam ,而且很有可能是在 try - resource 语句中关闭了流。

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/stram/Stream 类里面的 forEach 方法确实被调用了,而在这之前,会调用一个指向 Consumer 对象引用的方法 InvokeDynamic 。然后我们看到一大堆调用了 Steam.close 方法的字节码和调用 Throwable.addSuppressed 的跳转分支。这些是编译器编译 try - with - resource 语句的基本代码。

下面是完整的源代码:

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());
}

总结

感谢这些简单的字节码指令集和缺失的编译器优化,使得在没有源代码的情况下,拆分类文件并且分析你的代码成了一种比较容易的方法。

原文  http://wl9739.github.io/2018/07/17/如何阅读-Java-字节码/
正文到此结束
Loading...