55

如何阅读 Java 字节码

 7 years ago
source link: http://wl9739.github.io/2018/07/17/如何阅读-Java-字节码/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

原文链接: 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 栈:对于每一个线程, 是用来存放局部变量、方法参数以及返回值的。下面这张图表示三个线程的栈信息:

B3MjQjU.png!web

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

aAFnQ37.png!web

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

RRvyiiz.png!web

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

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

2aQruej.png!web

浏览字节码

对 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 压入操作栈中。

MNbQzye.png!web

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

VJfuIjY.png!web

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

biyauau.png!web

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

uAZNjyq.png!web

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

r2E7rq3.png!web

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

umuAVvu.png!web

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

zYnU3qV.png!web

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

f6rYBzV.png!web

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 方法执行完后,会将结果返回给调用它的操作栈,并压入栈顶,如下图所示:

N3mYfqz.png!web

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

Q3YVJzm.png!web

再接下来的指令, 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 对象会被初始化。当初始化方法结束后,栈顶的三个操作数都被消费了,只剩下最初指向创建对象(现在已经成功初始化了)的那个引用。

VzmYjq3.png!web

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

QNnyUzb.png!web

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

6ZRFnuV.png!web

RRfQbai.png!web

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

UvmmEfU.png!web

然而,即使 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());
}

总结

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK