查看原文
其他

Java 字节码(Bytecode)技术详解

布丁 21CTO 2022-05-25

21世纪技术官导读:本文以实践的角度讲述了如何学习字节码和利用它恢复项目的故事,并从技术角度来介绍字节码的原理。



即使对于有经验的Java开发者来说,阅读编译过的Java ByteCode字节码也是很乏味的事情。


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


所幸编译后的代码仍存在于远端服务器上,我松了一口气。我再次下载JAR并使用反编译器编辑器打开它。但是,反编译编辑器并不是一个完美的工具,不知道是啥原因,当我想要反编译的该JAVR包中特定类时,打开UI就会导致致命错误,反编译器就崩溃了。


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


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


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


JVM数据类型


Java是静态类型语言,它会影响字节码指令的设计,使得指令期望自己能够对特定类型的值进行操作。例如,有一些附加说明添加两个数字: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)

boolean 类型

returnAddress:指向指令的指针。


(2)参考类型

类的类型

数组类型

接口类型


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


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


基于堆栈的体系结构


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


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


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


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



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



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


一个局部变量数组,索引从0到其长度减1.该长度由编译器计算。局部变量可以保存任何类型的值,除了long和double值,它们占据两个局部变量。


一个操作数堆栈,用于存储可充当指令操作数的中间值,或将参数推入方法调用。



字节码探索


有了关于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个局部变量对应于变量a,b并c在源代码中。


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


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



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



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




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





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



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



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  


主要方法代码的唯一区别就是不用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(b, 2):



下一条指令  dadd弹出前两个中间结果,并添加它们,并将总和推回顶端。最后,invokestatic调用Math.sqrt结果总和,并使用缩小转换(d2i)将结果从double转换为int 。生成的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: (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新的指令new,dup和invokespecial。与编程语言中的新运算符类似,该new指令创建一个在传递给它的操作数中指定类型的对象(这是对该类的符号引用Point)。对象的内存分配在堆上,并且对该对象的引用被压入操作数堆栈。


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



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



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




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




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


小结


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



作者:Ben Putano

编译:布丁

来源:https://dzone.com/articles/top-deployment-tools-for-2018


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存