程序员JVMJava 随笔

简单读懂 Java 字节码

2018-11-11  本文已影响35人  小鱼爱小虾

读懂字节码有助于更好的理解 Java 编译器的工作原理

Java 字节码(Java bytecode)是 Java 虚拟机所使用的指令集。Java 字节码可以划分为很多种类型,如加载常量指令、操作数栈专用指令、局部变量表访问指令、Java 相关指令、方法调用指令、数组相关指令、控制流指令,以及计算相关指令。

Java 虚拟机采用面向操作数栈的架构,在解释执行过程中,Java 虚拟机为每个 Java 方法分配栈帧(Stack Frame)。栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它存储了方法的操作数栈、局部变量表、动态连接、方法返回地址等信息。

栈帧

字节码的执行过程实际上就是对局部变量表、操作数栈的一系列读写过程。

操作数栈

也称操作栈,后入先出(Last In First Out, LIFO)。操作数栈用来存放计算的操作数以及即将返回的结果。

局部变量表可以看成是一个数组,而操作数栈是一个后入先出栈

通常来说,程序需要将变量从局部变量表加载至操作数栈中,进行一番运算之后再将结果存储回局部变量中。具体来说便是,执行每一条指令之前,Java 虚拟机要求该指令的操作数已被压入栈中;在执行指令时,Java 虚拟机会将指令所需的操作数从栈里弹出,并且将指令执行的结果重新压入栈中。正常情况下,操作数栈的压入弹出都是一条条指令完成的,唯一的例外情况是在抛异常时,Java 虚拟机会清除操作数栈上的所有内容,而后将异常实例压入栈上

以加法指令 iadd 为例,假设在执行指令之前,栈顶的两个元素分别是 int 型 1 和 2,那么 iadd 指令会先将这两个 int 数据弹出栈,而后将计算得到的结果 int 型 3 压入栈中。操作数栈的变化如下图:

iadd 指令 : 1 + 2 = 3

由于 iadd 指令只消耗栈的两个元素,因此,对于栈顶两个以外的元素,即图中的问号,iadd 指令并不关心它们是否存在,更加不会对其进行修改

在 Java 字节码中,有一部分指令可以直接将常量加载到操作数栈上。以 int 型为例,虚拟机既可以通过 iconst 指令加载 -1 至 5 之间的 int 值,也可以通过 bipush、sipush 加载一个字节、两个字节所能代表的 int 值。Java 虚拟机还可以通过 ldc 加载常量池中的常量值,这些常量包括 int 类型、long 类型、float 类型、double 类型、String 类型以及 Class 类型的常量。例如 ldc #18 将加载常量池中的第 18 项。

常量加载指令表
类型 指令 范围
int (boolean, byte, char, short) iconst [-1, 5]
bipush [-128, 127]
sipush [-32768, 32767]
ldc 任意 int 值
long lconst 0, 1
ldc 任意 long 值
float fconst 0, 1, 2
ldc 任意 float 值
double dconst 0, 1
ldc 任意 double 值
reference aconst null
ldc String, Class

详细指令汇总:JVM 字节码指令表

局部变量表

局部变量表是 Java 方法栈帧的另外一个重要组成部分,用于存放方法参数和方法内部定义的局部变量,字节码程序可以将计算的结果缓存在局部变量表中。实际上,Java 虚拟机将局部变量表当成一个数组,依次存放 this 指针(仅非静态方法)、所传入的参数、以及字节码中的局部变量。和操作数一样,long 类型以及 double 类型的值需要占据两个单元,其余类型仅占据一个单元。

假设 Test.java 中有实例方法 test:

 3    public void test(long l, float f) {
 4        double d = 0.0;
 5        {
 6            int i = 0;
 7        }
 8        
 9        {
10            boolean b = false;
11        }
12    }

编译后,通过 javap -verbose Test 查看该方法的字节码:

  public void test(long, float);
    descriptor: (JF)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=7, args_size=3
         0: dconst_0
         1: dstore        4
         3: iconst_0
         4: istore        6
         6: iconst_0
         7: istore        6
         9: return
      LineNumberTable:
        line 4: 0
        line 6: 3
        line 10: 6
        line 12: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   LTest;
            0      10     1     l   J
            0      10     3     f   F
            3       7     4     d   D

看懂方法字节码之前,先了解一下描述符标识字符的含义:

标识字符 含义 标识字符 含义
B 基本类型 byte J 基本类型 long
C 基本类型 char S 基本类型 short
D 基本类型 double Z 基本类型 boolean
F 基本类型 float V 特殊类型 void
I 基本类型 int L 对象类型,如Ljava/lang/Object

现在通过字节码,可以对 test 方法有个大致的了解:

test 方法的局部变量表中的元素排列

第一个参数为 long 类型(需要占据两个单元),于是数组索引为 1、2 两个单元存放着所传入的 long 型参数值;第二个参数则是 float 类型,仅需一个单元,所以索引为 3 的单元存放着所传入的 float 值;方法体内部定义的第一个局部变量时 double 类型,同样需要占据两个单元;在方法体的两个代码块中,分别定义了两个局部变量 i 和 b,由于它们的生命周期没有重合之处,因此,编译器可以将它们编排到同一单元中,也就是说,局部变量表中的第 7 个单元将为 i 或者 b。

存储在局部变量表中的值,通常需要加载至操作数栈中,方能进行计算,得到结果后再存储至局部变量数组中。这些加载、存储指令是区分类型的。例如,int 类型的加载指令为 iload,存储指令为 istore。

局部变量区访问指令
类型 加载指令 存储指令
int (boolean, byte, char, short) iload istore
long lload lstore
float fload fstore
double dload dstore
reference aload astore

局部变量数组的加载、存储指令都需要指明目标单元的索引。举例来说,aload 0 指的是加载索引为 0 的单元所存储的引用,在实例方法中便是加载 this 指针。
详细指令汇总:JVM 字节码指令表

综合示例

14  public double calc(int i) {
15      int a = 10;
16      int b = 2;
17      double c = 1.5;
18      return (a + b - i) * c;
19  }

对应的字节码:

  public double calc(int);
    descriptor: (I)D
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=6, args_size=2
         0: bipush        10
         2: istore_2
         3: iconst_2
         4: istore_3
         5: ldc2_w        #2                  // double 1.5d
         8: dstore        4
        10: iload_2
        11: iload_3
        12: iadd
        13: iload_1
        14: isub
        15: i2d
        16: dload         4
        18: dmul
        19: dreturn
      LineNumberTable:
        line 15: 0
        line 16: 3
        line 17: 5
        line 18: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      20     0  this   LTest;
            0      20     1     i   I
            3      17     2     a   I
            5      15     3     b   I
           10      10     4     c   D

javap 提示这段代码需要深度为 4 的操作数栈和 6 个 Slot 的局部变量空间。以下各图描述了当调用 calc(5) 时,各条指令执行前后局部变量表和操作数栈的变化情况。

  1. 首先执行偏移地址为 0 的指令,bipush 指令的作用是将单字节的整型常量值(-128 ~ 127)推入操作数栈顶,跟随一个参数,指明要推入的常量值,这里就是 10。

    1. 首先执行偏移地址为 0 的 bipush 指令
  2. 执行偏移地址为 2 的指令,istore_2 指令的作用是将操作数栈顶的 int 型值弹出栈,并存放到局部变量表中的第 2 个 Slot 中

    2. 执行偏移地址为 2 的 istore_2 指令
  3. 执行偏移地址为 3 的指令,iconst_2 指令的作用是将 int 型常量 2 压入栈顶

    3. 执行偏移地址为 3 的 iconst_2 指令
  4. 执行偏移地址为 4 的指令,istore_3 指令的作用是将操作数栈顶的 int 型值弹出栈,并存放到局部变量表中的第 3 个 Slot 中

    4. 执行偏移地址为 4 的 istore_3 指令
  5. 执行偏移地址为 5 的指令,ldc2_w 指令的作用是将 long 或 double 型常量值从常量池中推至栈顶(宽索引),跟随一个参数,指明常量值在常量池中的位置,这里是第 2 项

    5. 执行偏移地址为 5 的 ldc2_w 指令
  6. 执行偏移地址为 8 的指令,dstore 指令的作用是将操作数栈顶的 double 型值弹出栈,并存放到局部变量表中指定索引的 Slot 中,这里是索引为 4 的 Slot

    6. 执行偏移地址为 8 的 dstore 指令
  7. 执行偏移地址为 10 的指令,iload_2 指令的作用是将局部变量表中索引为 2 的 Slot 中的 int 值复制到操作数栈顶,这里是数值 10

    7. 执行偏移地址为 10 的 iload_2 指令
  8. 执行偏移地址为 11 的指令,iload_3 指令的作用是将局部变量表中索引为 3 的 Slot 中的 int 值复制到操作数栈顶,这里是数值 2

    8. 执行偏移地址为 11 的 iload_3 指令
  9. 执行偏移地址为 12 的指令,iadd 指令的作用是将栈顶两 int 数值弹出栈,做加法计算,然后把结果重新压入操作数栈。在该指令执行完成后,栈中原有的 10 和 2 出栈,结果 12 入栈

    9. 执行偏移地址为 12 的 iadd 指令
  10. 执行偏移地址为 13 的指令,iload_1 指令的作用是将局部变量表中索引为 1 的 Slot 中的 int 值复制到操作数栈顶,这里是数值 5

    10. 执行偏移地址为 13 的 iload_1 指令
  11. 执行偏移地址为 14 的指令,isub 指令的作用是将栈顶两 int 数值弹出栈,做减法计算,然后把结果重新压入操作数栈。在该指令执行完成后,栈中原有的 12 和 5 出栈,结果 7 入栈

    11. 执行偏移地址为 14 的 isub 指令
  12. 执行偏移地址为 15 的指令,i2d 指令的作用是将栈顶的 int 数值弹出栈,强制转换成 double 型,并将结果压入栈顶,double 型数值需要占用两个单元

    12. 执行偏移地址为 15 的 i2d 指令
  13. 执行偏移地址为 16 的指令,dload 指令的作用是将局部变量表中指定索引位置的 double 型数据复制到栈顶,会占用操作数栈的两个单元,指令后跟一个参数,表示该 double 型数据在局部变量表的索引位置

    13. 执行偏移地址为 16 的 dload 指令
  14. 执行偏移地址为 18 的指令,dmul 指令的作用是将栈顶两个 double 数值弹出栈,做乘法计算,然后把结果重新压入栈

    14. 执行偏移地址为 18 的 dmul 指令
  15. 最后,执行偏移地址为 19 的指令,dreturn 指令是方法返回指令之一,它将结束方法执行,并将操作数栈顶的 double 型数值返回给方法的调用者

    15. 最后,执行偏移地址为 19 的 dreturn 指令

到此,整个方法执行结束,以下长图更直观的对比各条指令执行前后局部变量表和操作数栈的变化。

各条指令执行前后局部变量表和操作数栈的变化

对于更复杂的方法,同样可以通过查阅各指令的文档来读懂字节码。

上一篇 下一篇

猜你喜欢

热点阅读