Java字节简介

Java字节简介

即使对于有经验的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进行了探索

有了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,该描述符指示该方法采用Strings([Ljava/lang/String;数组并具有void返回类型(V)。接下来是一组标志,将方法描述为public(ACC_PUBLIC)和static(ACC_STATIC)。

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

地址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(或稍后将看到的类似的invoke *指令)随后会弹出这些参数,并为调用的方法创建一个新的框架,其中参数放在其局部变量数组中。

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

其他新信息显然是calc方法本身的代码它首先将第一个整数参数加载到操作数堆栈(iload_0)上。下一条指令i2d通过应用扩展转换将其转换为double。生成的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结果总和,并使用narrowing conversion(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

这里遇到的新指令是newdupinvokespecial与编程语言中的new运算符类似,该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_1aload_2加载对两个Point对象的引用,并调用areausing方法invokevirtual,该方法根据对象的实际类型处理调用适当方法的调用。例如,如果变量a包含SpecialPoint扩展类型的实例Point,并且子类型覆盖该area方法,则调用overriden方法。在这种情况下,没有子类,因此只有一种area方法可用。

area.png

请注意,即使该area方法接受一个参数,堆栈顶部也有两个 Point引用。第一个(pointA  来自变量a)实际上是调用该方法的实例(this在编程语言中也称为),它将在该area方法的新帧的第一个局部变量中传递另一个操作数value(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/Streamwhere的出现forEach,之前是对InvokeDynamica的引用的调用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());
}

结论

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值