字节码文件指令集解析

152 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第7天,点击查看活动详情

本系列专栏:JVM专栏

前言

上一篇文章我们简单介绍了一些指令集,比如dup和pop,以及一些加载常量值到操作数栈中的指令,本篇文章我们继续来解析其他指令集。

正文

栈帧中除了操作数栈还有一个局部变量区,其实这个在之前说Java基本类型的时候就说过,局部变量区是一个数组,它保存着this指针(非静态方法)、所传入的参数以及字节码中的局部变量。

局部变量区

这里和操作数栈一样,除了long和double类型外,其他基本类型和引用类型都占一个单元,这2个类型占2个单元。而且这里的局部变量区不仅仅包含局部变量,还会包含this和参数,比如下面的代码:

public void foo(long l,float f){
    int i = 0;
    String s1 = "hello";
}

这是一个非静态方法,所以它的局部变量区如下:

image.png

这里this占用一个单位,l是long类型,占用2个单位,f占用1个单位,对于i和s1则没有先后顺序,所以第4个单元是i或者s1。

加载、存储局部变量区数据到操作数栈

存储在局部变量区中的值,通常需要加载到操作数栈中方能进行计算,得到结果再保存到局部变量数组中。

而这些加载、存储指令是区分类型的,比如int类型的加载指令为iload,存储指令为istore,下图是所有的相关指令:

image.png

这些指令十分常见,而且局部变量数组的加载、存储指令都需要指定所加载单元的下标,比如aload 0就是加载第0个单元所存储的引用。

iinc指令

为什么单独说这个指令呢,因为这个指令iinc M N(M为非负整数,N为整数)是唯一一个能直接作用于局部变量区的指令,该指令是将局部变量数组的第M个单元中的int值增加到N,常用于for循环自增量的更新。

比如下面代码:

public void foo(){
    for (int i = 100; i >=0; i --){

    }
}

编译成指令如下:

public void foo();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=1
         0: bipush        100
         2: istore_1
         3: iload_1
         4: iflt          13
         7: iinc          1, -1
        10: goto          3
        13: return
      LineNumberTable:
        line 10: 0
        line 13: 13

这里 #0 把100入栈,#1 把100保存在局部变量区中的index为1的位置,#3 把100加载出来,#4 判断是否小于0,如果小于0,直接#13 返回结束,否则#7 对本地变量区第1个单位中的int值-1,#10到#3继续执行。

这里可以发现这里100自减的操作就没有在栈上进行操作,而是直接在本地变量区中操作,算是比较特殊的指令。

综合例子

前面我们说的都是一些简单的操作数栈执行指令以及本地变量区的操作指令,我们来看个综合例子:

public static int bar(int i){
    return ((i + 1) - 2) * 3 / 4;
}

上面的静态函数,对应下面的字节码:

 public static int bar(int);
    descriptor: (I)I
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: iload_0
         1: iconst_1
         2: iadd
         3: iconst_2
         4: isub
         5: iconst_3
         6: imul
         7: iconst_4
         8: idiv
         9: ireturn
      LineNumberTable:
        line 10: 0

可以看到该方法的stack即需要的操作数栈空间为2,局部变量数组的空间为1,当调用bar(5)的时候,每条指令执行前后的局部变量区和操作数栈分布如下:

image.png

清楚的了解iconst以及iload等指令,这就会很容易看懂。

Java相关指令

前面我们介绍了加载常量指令、操作数栈专用指令以及局部变量区访问指令,下面来看看Java相关的指令。

  • new:后跟目标类,生成该类的未初始化对象;

  • instanceof:后跟目标类,判断栈顶元素是否为目标类实例,是则压入1,否则压入0;

  • checkcast:后跟目标类,判断栈顶元素是否为目标类实例,如果不是则抛出异常;

  • athrow:将栈顶异常抛出;

  • monitorenter和monitorexit:对栈顶对象加锁和解锁;

此外还有字段访问指令,包括静态字段访问指令getstatic、putstatic,和实例字段访问指令getfield和putfield。这4个指令均附带用以定位目标字段的信息,以putfield为例:

image.png

这里会将值v保存至对象obj的目标字段中。

还有就i是方法调用指令,包括invokestatic、invokespecial、invokevirtual、invokeinterface和invokedynamic,这几条指令是用来调用方法的,其实从名字就可以看出这是调用不同类型的方法,至于为什么分为这几种,我们后面文章细细说来,这里就了解一个大概即可。

除了invokedynamic外,其他的方法调用指令所消耗的操作数栈元素是根据调用类型以及目标方法描述符来确定的,在进行方法调用前,程序需要依次压入调用者(invokestatic不需要)以及各个参数。

比如下面例子:

public int neg(int i) {
    return -i;
}

public int foo(int i) {
    return neg(neg(i));
}

这里在foo中调用neg函数,下面是字节码:

public int foo(int);
    descriptor: (I)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=2, args_size=2
         0: aload_0
         1: aload_0
         2: iload_1
         3: invokevirtual #2                  // Method neg:(I)I
         6: invokevirtual #2                  // Method neg:(I)I
         9: ireturn
      LineNumberTable:
        line 10: 0

会发现这里有连续2次加载本地变量表中的this,那它是如何运行的呢,下图所示:

image.png

数组相关指令

数组相关的指令,包括新建基本类型数组newarray,新建引用类型数组anewarray,求数组长度arraylength,此外包括数组的加载指令以及存储指令都是区分类型的,比如int数组的加载指令为ialod,存储指令为iastore,指令如下表:

image.png

这里该如何理解呢,加载和存储其实也就是数组的方法,所以也会消耗相应的操作数栈元素,比如下面代码:

public void foo() {
    int[] intarray = new int[3];
    intarray[0] = 10;
    intarray[1] = 11;
    intarray[2] = 12;
    int y = intarray[0];
}

对应的字节码如下:

 public void foo();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: iconst_3
         1: newarray       int
         3: astore_1
         4: aload_1
         5: iconst_0
         6: bipush        10
         8: iastore
         9: aload_1
        10: iconst_1
        11: bipush        11
        13: iastore
        14: aload_1
        15: iconst_2
        16: bipush        12
        18: iastore
        19: aload_1
        20: iconst_0
        21: iaload
        22: istore_2
        23: return

可以发现,栈空间为3,局部变量区空间为3(this,intarray和y),首先是 #0 入栈3,#1 消耗3创建数组,#3 把数组引用存入局部变量区,#4 取出数组入栈,#5和#6加载0和10,#8把10保存到数组0位置,同时消耗栈顶元素,#10重新把局部变量区的数组引用入栈,#11到#18保存11和12,#19到#22取出数组0元素放入本地变量区2中。

控制流指令

控制流指令包括无条件跳转goto,条件跳转指令,tableswitch以及lookupswitch(前者针对密集的cases,后者针对稀疏的cases),还有返回指令,其中返回指令是区分类型的,如下图:

image.png

除了返回指令外,其他控制流指令要附带一个或者多个字节码偏移量,代表跳转的位置,比如下面代码:

public int abs(int i) {
    if (i >= 0) {
        return i;
    }
    return -i;
}

对应的字节码是:

public int abs(int);
    descriptor: (I)I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: iload_1
         1: iflt          6
         4: iload_1
         5: ireturn
         6: iload_1
         7: ineg
         8: ireturn
      LineNumberTable:
        line 6: 0
        line 7: 4
        line 9: 6

从这里可以发现#1 当i小于0时,直接跳转到#6。

总结

到这里,除了方法调用指令外,其他指令大致都过了一遍,都了解一些,遇到问题也不至于看不懂字节码了。

这里推荐个网址:

JVM指令手册 

遇到不熟悉的JVM指令可以手动查询。

后面文章我们就来继续分析几种方法调用的原因和原理。