Java字节码介绍【译】

原文:Introduction to Java Bytecode

接下来深入了解JVM内部和Java字节码,了解如何拆卸文件来进行深入检查。

    即使对于经验丰富的Java开发人员来说,阅读已编译的Java字节码也会很乏味。为什么我们一开始就需要知道这些底层的东西呢?这里有一个上周发生在我身上的简单场景:很久以前我在我的机器上做了一些代码修改,编译了一个JAR,并将它部署到服务器上,以测试性能问题的潜在修复。不幸的是,代码从来没有签入版本控制系统,并且出于某种原因,本地更改被删除而没有跟踪。几个月后,我再次需要源代码形式的这些更改(这需要付出很大的努力),但是我找不到它们!

    幸运的是,编译后的代码仍然存在于远程服务器上。于是,我如释重负地叹了口气,再次取出JAR,用反编译器编辑器打开了它……只有一个问题:反编译器GUI并不是一个完美的工具,由于某种原因,在该JAR中的许多类中,只有我要反编译的特定类在每次打开它时都会在UI中引起bug,而反编译器会崩溃!

    非常时期采用非常手段【绝望的时代需要绝望的措施】。幸运的是,我熟悉原始字节码,我宁愿花一些时间手动地对代码的某些部分进行反编译,也不愿遍历更改并再次测试它们。因为我仍然记得至少应该在代码中查找什么地方,所以阅读字节码可以帮助我确定确切的更改,并以源代码的形式重新构造它们。(这次我一定要从错误中吸取教训,把它们保存起来!)

    字节码的好处是,您只需要学习它的语法一次,然后它就可以应用于所有Java支持的平台——因为它是代码的中间表示,而不是底层CPU的实际可执行代码。此外,字节码比本机机器代码更简单,因为JVM架构相当简单,因此简化了指令集。另一件好事是,这个指令集中所有指令都被Oracle完全文档化了。

    在学习字节码指令集之前,让我们先熟悉一下作为先决条件的JVM的一些事情。

 

JVM数据类型

    Java是静态类型化的,这会影响字节码指令的设计,以至于指令希望自己操作特定类型的值。例如,有几个add指令来添加两个数字:iadd、ladd、fadd、dadd。它们分别期望类型操作数,int、long、float和double。大多数字节码具有这样的特性,即根据操作数类型具有相同功能的不同形式。

JVM定义的数据类型是:

    1、基本类型:

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

        布尔类型

        返回地址:指向指令的指针

    2、引用类型:

        类类型

        数组类型

        接口类型

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

    Java开发人员应该熟悉上述所有类型,但返回地址除外,它没有等效的编程语言类型。

 

基于堆栈的架构

    字节码指令集的简单主要是因为Sun设计了基于堆栈的VM体系结构,而不是基于寄存器的体系结构。JVM进程使用各种内存组件,但只有JVM堆栈需要进行详细检查,才能基本上能够遵循字节码指令:

    PC寄存器:对于Java程序中运行的每个线程,PC寄存器存储当前指令的地址。

    JVM堆栈:对于每个线程,都在存储本地变量、方法参数和返回值的地方分配一个堆栈。下面的插图显示了3个线程的堆栈。

                                                                        

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

                                                                            

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

                                                        

    JVM堆栈由框架组成,每个框架在调用方法时推入堆栈,在方法完成时从堆栈弹出(通过正常返回或抛出异常)。每一框架进一步包括:

    1、从0到长度减一的局部变量数组。长度由编译器计算。局部变量可以容纳任何类型的值,但长值和双值除外,它们占用两个局部变量。

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

字节码探索

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

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

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

    在当前正在执行的方法的堆栈框架中,指令可以将或弹出值推入操作数堆栈,并且它可以潜在地在数组本地变量中加载或存储值。让我们看一个简单的例子:

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

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

    我们可以看到主方法的方法签名,描述符表示该方法接受一个字符串数组([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:将int值从索引1处的局部变量加载到操作数堆栈中。

    

    iload_2:将int值从索引1处的局部变量加载到操作数堆栈中。

    

    iadd:从操作数堆栈中取出前两个int值,添加它们,并将结果推回到操作数堆栈中。

    

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

    

    return:从void方法返回。

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

    

方法调用

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

    唯一的区别在主方法的代码,我们现在用一个invokestatic指令而不是iadd指令,它调用静态方法calc。关键要注意的是,操作数堆栈包含的两个参数传递给方法calc。换句话说,调用的方法准备所有的参数被称为方法,把他们推到操作数栈以正确的顺序。invokestatic(或类似的调用指令,稍后将会看到)随后将弹出这些参数,并为被调用的方法创建一个新的框架,其中参数被放置在它的本地变量数组中。

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

    另一个新信息显然是calc方法本身的代码。它首先将第一个整数参数加载到操作数堆栈(iload_0)上。下一条指令i2d通过应用扩展转换将其转换为double。得到的双值替换操作数堆栈的顶部。

    下一条指令将一个双常量2.0d(从常量池中取出)推送到操作数堆栈上。然后是静态方法Math。使用到目前为止准备好的两个操作数值(calc的第一个参数和常量2.0d)调用pow方法。当Math。pow方法返回,其结果将存储在其调用程序的操作数堆栈中。下面可以说明这一点。

                        

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

                        

下一条指令,dadd,弹出前两个中间结果,添加它们,并将总和推回到顶部。最后,invokestatic调用Mah。对结果和进行sqrt,然后使用窄化转换(d2i)将结果从double转换为int。结果int返回给main方法,主方法将它存储回c (istore_3)。

 

实例创建

    让我们修改示例并引入一个类点来封装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;
    }
}

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

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

    这里所遇到的新指令是新的、统一的和特殊的。与编程语言中的新操作符类似,新指令创建了传递给它的操作数中指定的类型的对象(这是对类点的符号引用)。对象的内存被分配到堆上,对对象的引用被推送到操作数堆栈上。

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

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

创建和初始化第二个点实例的过程是相同的,这个实例被赋给变量b。

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

    注意,即使area方法接受一个参数,在堆栈顶部也有两个点引用。第一个(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
   ...

    我们看到在调用forEach时出现了java/util/stream/ stream,前面是调用InvokeDynamic,引用一个消费者。然后我们看到一个字节码块,它调用流。关闭调用throwable . addsuppression的分支。这是编译器为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());
}

结论

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值