Java中的.class文件详解

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

        即使对于有经验的Java开发人员来说,阅读已编译的Java字节码也很乏味。为什么我们首先需要了解这种低级别的东西?这是上周发生在我身上的一个简单情况:很久以前,我在机器上进行了一些代码更改,编译了一个JAR,并将其部署到服务器上,以测试性能问题的潜在修补程序。不幸的是,代码从未被检入到版本控制系统中,并且出于某种原因,本地更改被删除而没有追踪。几个月后,我再次需要源代码形式的变化(这需要付出相当大的努力),但是我找不到它们!

        幸运的是编译后的代码仍然存在于该远程服务器上。于是松了一口气,我再次抓取JAR并使用反编译器编辑器打开它......只有一个问题:反编译器GUI并不是一个完美的工具,而且出于某种原因,该JAR中的许多类我想要反编译的特定类在我打开它时在UI中导致了一个错误,并且反编译器崩溃!

        绝望的时代需要绝望的措施。幸运的是,我熟悉原始字节码,我宁愿花些时间手动反编译代码的某些部分,而不是通过修改并再次测试它们。由于我仍然记得至少在代码中查找的地方,因此阅读字节码可帮助我确定确切的更改并将其构造回源代码形式。(我一定要从我的错误中吸取教训,并保留这些时间!)

        关于字节码的好处是你只学习一次它的语法,然后  它适用于所有Java支持的平台  - 因为它是代码中间表示,而不是底层CPU的实际可执行代码。而且,字节码比原生机器码简单,因为JVM架构相当简单,因此简化了指令集。还有一件好事就是,这套系列中的所有指令都由Oracle 完整记录

在了解字节码指令集之前,让我们先熟悉一些有关作为先决条件所需的JVM的信息。


JVM数据类型

        Java是静态类型的,它会影响字节码指令的设计,使得指令期望自己能够对特定类型的值进行操作。例如,有一些附加说明添加两个数字:iaddladdfadddadd他们期望类型的操作数分别为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双精度F​​P)
    • boolean 类型
    • returnAddress:指向指令的指针
  2. 参考类型:
    • 类的类型
    • 数组类型
    • 接口类型

        该boolean类型在字节码中的支持有限。例如,没有直接在boolean值上运行的指令布尔值被int编译器转换,并使用相应的int指令。

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


基于堆栈的体系结构

        字节码指令集的简单性很大程度上归功于Sun设计了基于堆栈的VM架构,而不是基于寄存器的架构。JVM进程使用各种内存组件,但只有JVM堆栈需要仔细检查,以便能够遵循字节码指令:

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

JVM堆栈:对于每个线程,都会分配一个堆栈以存储局部变量,方法参数和返回值。这里是一个显示3个线程堆栈的插图。

jvm_stacks

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

heap.png

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

method_area.png

JVM堆栈由框架组成,  当方法调用完成后,每个框架都会压入堆栈,并在堆栈中弹出(通过正常返回或抛出异常)。每个框架还包括:

  1. 一个局部变量数组,索引从0到其长度减1.该长度由编译器计算。局部变量可以保存任何类型的值,除了longdouble值,它们占据两个局部变量。
  2. 一个操作数堆栈,用于存储可充当指令操作数的中间值,或将参数推入方法调用。

stack_frame_zoom.png

Bytecode Explored

        有了关于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 []);
描述符:([Ljava / lang / String;)V
标志:(0x0009)ACC_PUBLIC,ACC_STATIC
码:
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:返回
...

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

        最重要的部分是Code属性,该属性包含方法的说明以及操作数堆栈的最大深度(本例中为2)以及此方法的帧中分配的局部变量的数量(4 in这个案例)。在上面的指令中引用了所有局部变量,除了第一个(在索引0处),它保存对args参数的引用其他3个局部变量对应于变量abc在源代码中。

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

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

iconst_1.png

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

istore_1.png

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

iconst_2.png

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

istore_2.png

iload_1:从索引为1的本地变量中加载int值并将其推入操作数堆栈。

iload_1.png

iload_2:从索引1处的本地变量加载int值并将其推入操作数堆栈。

iload_2.png

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

我加

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

istore_3.png

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

        主要方法代码的唯一区别就是不用iadd指令了,我们现在invokestatic只需调用静态方法calc关键要注意的是操作数堆栈包含传递给方法的两个参数calc换句话说,调用方法通过按照正确的顺序将它们推到操作数堆栈上来准备待调用方法的所有参数。invokestatic(或者类似的调用指令,将在后面看到)将随后弹出这些参数,并为参数放置在其局部变量数组中的被调用方法创建一个新框架。

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

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

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

math_pow.png

应用相同的过程来计算Math.pow(b, 2)

math_pow2.png

        下一条指令  dadd弹出前两个中间结果,并添加它们,并将总和推回顶端。最后,invokestatic调用Math.sqrt结果总和,并使用缩小转换(d2i将结果从double转换为int 生成的int返回到main方法,该方法将其存储回cistore_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

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

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

init.png

接下来,  astore_1弹出Point引用并将其分配给索引为1的局部变量(ain astore_1表示这是参考值)。

init_store.png

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

init2.png

init_store2.png

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

area.png

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

结论

        由于字节码指令集的简单性以及在生成指令时几乎没有编译器优化,拆分类文件可能是一种检查应用程序代码变化的方法,在没有源代码时,这种方法可以尝试一下。




猜你喜欢

转载自blog.csdn.net/xingkongdeasi/article/details/79688505