硬核万字长文,深刻理解 Java 字节码指令(建议收藏)

Java 字节码指令是 JVM 体系中很是难啃的一块硬骨头,我估计有些读者会有这样的疑惑,“Java 字节码难学吗?我能不能学会啊?”java

讲良心话,不是我谦虚,一开始学 Java 字节码和 Java 虚拟机方面的知识我也感受头大!但硬着头皮学了一阵子以后,忽然就开窍了,以为好有意思,尤为是明白了 Java 代码在底层居然是这样执行的时候,感受既膨胀又飘飘然,浑身上下散发着自信的光芒!git

我在 掘金 共输出了 100 多篇 Java 方面的文章,总字数超过 30 万字, 内容风趣幽默、通俗易懂,收获了不少初学者的承认和支持,内容包括 Java 语法、Java 集合框架、Java 并发编程、Java 虚拟机等核心内容 为了帮助更多的 Java 初学者,我“一怒之下”就把这些文章从新整理并开源到了 GitHub,起名《教妹学 Java》,听起来是否是就颇有趣?github

GitHub 开源地址(欢迎 star):github.com/itwanger/jm…编程

Java 官方的虚拟机 Hotspot 是基于栈的,而不是基于寄存器的。segmentfault

基于栈的优势是可移植性更好、指令更短、实现起来简单,但不能随机访问栈中的元素,完成相同功能所须要的指令数也比寄存器的要多,须要频繁的入栈和出栈。数组

基于寄存器的优势是速度快,有利于程序运行速度的优化,但操做数须要显式指定,指令也比较长。markdown

Java 字节码由操做码和操做数组成。并发

  • 操做码(Opcode):一个字节长度(0-255,意味着指令集的操做码总数不可能超过 256 条),表明着某种特定的操做含义。
  • 操做数(Operands):零个或者多个,紧跟在操做码以后,表明此操做须要的参数。

因为 Java 虚拟机是基于栈而不是寄存器的结构,因此大多数指令都只有一个操做码。好比 aload_0(将局部变量表中下标为 0 的数据压入操做数栈中)就只有操做码没有操做数,而 invokespecial #1(调用成员方法或者构造方法,并传递常量池中下标为 1 的常量)就是由操做码和操做数组成的。框架

0一、加载与存储指令

加载(load)和存储(store)相关的指令是使用最频繁的指令,用于将数据从栈帧的局部变量表和操做数栈之间来回传递。jvm

1)将局部变量表中的变量压入操做数栈中

  • xload_(x 为 i、l、f、d、a,n 默认为 0 到 3),表示将第 n 个局部变量压入操做数栈中。
  • xload(x 为 i、l、f、d、a),经过指定参数的形式,将局部变量压入操做数栈中,当使用这个指令时,表示局部变量的数量可能超过了 4 个

解释一下。

x 为操做码助记符,代表是哪种数据类型。见下表所示。

像 arraylength 指令,没有操做码助记符,它没有表明数据类型的特殊字符,但操做数只能是一个数组类型的对象。

大部分的指令都不支持 byte、short 和 char,甚至没有任何指令支持 boolean 类型。编译器会将 byte 和 short 类型的数据带符号扩展(Sign-Extend)为 int 类型,将 boolean 和 char 零位扩展(Zero-Extend)为 int 类型。

举例来讲。

private void load(int age, String name, long birthday, boolean sex) {
    System.out.println(age + name + birthday + sex);
}
复制代码

经过 jclasslib 看一下 load() 方法(4 个参数)的字节码指令。

  • iload_1:将局部变量表中下标为 1 的 int 变量压入操做数栈中。
  • aload_2:将局部变量表中下标为 2 的引用数据类型变量(此时为 String)压入操做数栈中。
  • lload_3:将局部变量表中下标为 3 的 long 型变量压入操做数栈中。
  • iload 5:将局部变量表中下标为 5 的 int 变量(实际为 boolean)压入操做数栈中。

经过查看局部变量表就能关联上了。

2)将常量池中的常量压入操做数栈中

根据数据类型和入栈内容的不一样,此类又能够细分为 const 系列、push 系列和 Idc 指令。

const 系列,用于特殊的常量入栈,要入栈的常量隐含在指令自己。

push 系列,主要包括 bipush 和 sipush,前者接收 8 位整数做为参数,后者接收 16 位整数。

Idc 指令,当 const 和 push 不能知足的时候,万能的 Idc 指令就上场了,它接收一个 8 位的参数,指向常量池中的索引。

  • Idc_w:接收两个 8 位数,索引范围更大。
  • 若是参数是 long 或者 double,使用 Idc2_w 指令。

举例来讲。

public void pushConstLdc() {
    // 范围 [-1,5]
    int iconst = -1;
    // 范围 [-128,127]
    int bipush = 127;
    // 范围 [-32768,32767]
    int sipush= 32767;
    // 其余 int
    int ldc = 32768;
    String aconst = null;
    String IdcString = "沉默王二";
}
复制代码

经过 jclasslib 看一下 pushConstLdc() 方法的字节码指令。

  • iconst_m1:将 -1 入栈。范围 [-1,5]。
  • bipush 127:将 127 入栈。范围 [-128,127]。
  • sipush 32767:将 32767 入栈。范围 [-32768,32767]。
  • ldc #6 <32768>:将常量池中下标为 6 的常量 32768 入栈。
  • aconst_null:将 null 入栈。
  • ldc #7 <沉默王二>:将常量池中下标为 7 的常量“沉默王二”入栈。

3)将栈顶的数据出栈并装入局部变量表中

主要是用来给局部变量赋值,这类指令主要以 store 的形式存在。

  • xstore_(x 为 i、l、f、d、a,n 默认为 0 到 3)
  • xstore(x 为 i、l、f、d、a)

明白了 xload_ 和 xload,再看 xstore_ 和 xstore 就会轻松得多,做用反了一下而已。

你们来想一个问题,为何要有 xstore_ 和 xload_ 呢?它们的做用和 xstore n、xload n 不是同样的吗?

xstore_ 和 xstore n 的区别在于,前者至关于只有操做码,占用 1 个字节;后者至关于由操做码和操做数组成,操做码占 1 个字节,操做数占 2 个字节,一共占 3 个字节。

因为局部变量表中前几个位置老是很是经常使用,虽然 xstore_<n>xload_<n> 增长了指令数量,但字节码的体积变小了!

举例来讲。

public void store(int age, String name) {
    int temp = age + 2;
    String str = name;
}
复制代码

经过 jclasslib 看一下 store() 方法的字节码指令。

  • istore_3:从操做数中弹出一个整数,并把它赋值给局部变量表中索引为 3 的变量。
  • astore 4:从操做数中弹出一个引用数据类型,并把它赋值给局部变量表中索引为 4 的变量。

经过查看局部变量表就能关联上了。

0二、算术指令

算术指令用于对两个操做数栈上的值进行某种特定运算,并把结果从新压入操做数栈。能够分为两类:整型数据的运算指令和浮点数据的运算指令。

须要注意的是,数据运算可能会致使溢出,好比两个很大的正整数相加,极可能会获得一个负数。但 Java 虚拟机规范中并无对这种状况给出具体结果,所以程序是不会显式报错的。因此,你们在开发过程当中,若是涉及到较大的数据进行加法、乘法运算的时候,必定要注意!

当发生溢出时,将会使用有符号的无穷大 Infinity 来表示;若是某个操做结果没有明确的数学定义的话,将会使用 NaN 值来表示。并且全部使用 NaN 做为操做数的算术操做,结果都会返回 NaN。

举例来讲。

public void infinityNaN() {
    int i = 10;
    double j = i / 0.0;
    System.out.println(j); // Infinity

    double d1 = 0.0;
    double d2 = d1 / 0.0;
    System.out.println(d2); // NaN
}
复制代码
  • 任何一个非零的数除以浮点数 0(注意不是 int 类型),能够想象结果是无穷大 Infinity 的。
  • 把这个非零的数换成 0 的时候,结果又不太好定义,就用 NaN 值来表示。

Java 虚拟机提供了两种运算模式

  • 向最接近数舍入:在进行浮点数运算时,全部的结果都必须舍入到一个适当的精度,不是特别精确的结果必须舍入为可被表示的最接近的精确值,若是有两种可表示的形式与该值接近,将优先选择最低有效位为零的(相似四舍五入)。
  • 向零舍入:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近可是不大于原值的数字做为最精确的舍入结果(相似取整)。

我把全部的算术指令列一下:

  • 加法指令:iadd、ladd、fadd、dadd
  • 减法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求余指令:irem、lrem、frem、drem
  • 自增指令:iinc

举例来讲。

public void calculate(int age) {
    int add = age + 1;
    int sub = age - 1;
    int mul = age * 2;
    int div = age / 3;
    int rem = age % 4;
    age++;
    age--;
}
复制代码

经过 jclasslib 看一下 calculate() 方法的字节码指令。

  • iadd,加法
  • isub,减法
  • imul,乘法
  • idiv,除法
  • irem,取余
  • iinc,自增的时候 +1,自减的时候 -1

0三、类型转换指令

能够分为两种:

1)宽化,小类型向大类型转换,好比 int–>long–>float–>double,对应的指令有:i2l、i2f、i2d、l2f、l2d、f2d。

  • 从 int 到 long,或者从 int 到 double,是不会有精度丢失的;
  • 从 int、long 到 float,或者 long 到 double 时,可能会发生精度丢失;
  • 从 byte、char 和 short 到 int 的宽化类型转换其实是隐式发生的,这样能够减小字节码指令,毕竟字节码指令只有 256 个,占一个字节。

2)窄化,大类型向小类型转换,好比从 int 类型到 byte、short 或者 char,对应的指令有:i2b、i2s、i2c;从 long 到 int,对应的指令有:l2i;从 float 到 int 或者 long,对应的指令有:f2i、f2l;从 double 到 int、long 或者 float,对应的指令有:d2i、d2l、d2f。

  • 窄化极可能会发生精度丢失,毕竟是不一样的数量级;
  • 但 Java 虚拟机并不会所以抛出运行时异常。

举例来讲。

public void updown() {
    int i = 10;
    double d = i;
    
    float f = 10f;
    long ong = (long)f;
}
复制代码

经过 jclasslib 看一下 updown() 方法的字节码指令。

  • i2d,int 宽化为 double
  • f2l, float 窄化为 long

0四、对象的建立和访问指令

Java 是一门面向对象的编程语言,那么 Java 虚拟机是如何从字节码层面进行支持的呢?

1)建立指令

数组也是一种对象,但它建立的字节码指令和普通的对象不一样。建立数组的指令有三种:

  • newarray:建立基本数据类型的数组
  • anewarray:建立引用类型的数组
  • multianewarray:建立多维数组

普通对象的建立指令只有一个,就是 new,它会接收一个操做数,指向常量池中的一个索引,表示要建立的类型。

举例来讲。

public void newObject() {
    String name = new String("沉默王二");
    File file = new File("无愁河的浪荡汉子.book");
    int [] ages = {};
}
复制代码

经过 jclasslib 看一下 newObject() 方法的字节码指令。

  • new #13 <java/lang/String>,建立一个 String 对象。
  • new #15 <java/io/File>,建立一个 File 对象。
  • newarray 10 (int),建立一个 int 类型的数组。

2)字段访问指令

字段能够分为两类,一类是成员变量,一类是静态变量(static 关键字修饰的),因此字段访问指令能够分为两类:

  • 访问静态变量:getstatic、putstatic。
  • 访问成员变量:getfield、putfield,须要建立对象后才能访问。

举例来讲。

public class Writer {
    private String name;
    static String mark = "做者";

    public static void main(String[] args) {
        print(mark);
        Writer w = new Writer();
        print(w.name);
    }

    public static void print(String arg) {
        System.out.println(arg);
    }
}
复制代码

经过 jclasslib 看一下 main() 方法的字节码指令。

  • getstatic #2 <com/itwanger/jvm/Writer.mark>,访问静态变量 mark
  • getfield #6 <com/itwanger/jvm/Writer.name>,访问成员变量 name

0五、方法调用和返回指令

方法调用指令有 5 个,分别用于不一样的场景:

  • invokevirtual:用于调用对象的成员方法,根据对象的实际类型进行分派,支持多态。
  • invokeinterface:用于调用接口方法,会在运行时搜索由特定对象实现的接口方法进行调用。
  • invokespecial:用于调用一些须要特殊处理的方法,包括构造方法、私有方法和父类方法。
  • invokestatic:用于调用静态方法。
  • invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行。

举例来讲。

public class InvokeExamples {
    private void run() {
        List ls = new ArrayList();
        ls.add("难顶");

        ArrayList als = new ArrayList();
        als.add("学不动了");
    }

    public static void print() {
        System.out.println("invokestatic");
    }

    public static void main(String[] args) {
        print();
        InvokeExamples invoke = new InvokeExamples();
        invoke.run();
    }
}
复制代码

咱们用 javap -c InvokeExamples.class 来反编译一下。

Compiled from "InvokeExamples.java"
public class com.itwanger.jvm.InvokeExamples {
  public com.itwanger.jvm.InvokeExamples();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  private void run();
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
       7: astore_1
       8: aload_1
       9: ldc           #4                  // String 难顶
      11: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      16: pop
      17: new           #2                  // class java/util/ArrayList
      20: dup
      21: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
      24: astore_2
      25: aload_2
      26: ldc           #6                  // String 学不动了
      28: invokevirtual #7                  // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
      31: pop
      32: return

  public static void print();
    Code:
       0: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #9                  // String invokestatic
       5: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #11                 // Method print:()V
       3: new           #12                 // class com/itwanger/jvm/InvokeExamples
       6: dup
       7: invokespecial #13                 // Method "<init>":()V
      10: astore_1
      11: aload_1
      12: invokevirtual #14                 // Method run:()V
      15: return
}
复制代码

InvokeExamples 类有 4 个方法,包括缺省的构造方法在内。

1)InvokeExamples() 构造方法中

缺省的构造方法内部会调用超类 Object 的初始化构造方法:

`invokespecial #1 // Method java/lang/Object."<init>":()V`
复制代码

2)成员方法 run()

invokeinterface #5,  2  // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
复制代码

因为 ls 变量的引用类型为接口 List,因此 ls.add() 调用的是 invokeinterface 指令,等运行时再肯定是否是接口 List 的实现对象 ArrayList 的 add() 方法。

invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
复制代码

因为 als 变量的引用类型已经肯定为 ArrayList,因此 als.add() 方法调用的是 invokevirtual 指令。

3)main() 方法中

invokestatic  #11 // Method print:()V
复制代码

print() 方法是静态的,因此调用的是 invokestatic 指令。

方法返回指令根据方法的返回值类型进行区分,常见的返回指令见下图。

0六、操做数栈管理指令

常见的操做数栈管理指令有 pop、dup 和 swap。

  • 将一个或两个元素从栈顶弹出,而且直接废弃,好比 pop,pop2;
  • 复制栈顶的一个或两个数值并将其从新压入栈顶,好比 dup,dup2,dup_×1,dup2_×1,dup_×2,dup2_×2;
  • 将栈最顶端的两个槽中的数值交换位置,好比 swap。

这些指令不须要指明数据类型,由于是按照位置压入和弹出的。

举例来讲。

public class Dup {
    int age;
    public int incAndGet() {
        return ++age;
    }
}
复制代码

经过 jclasslib 看一下 incAndGet() 方法的字节码指令。

  • aload_0:将 this 入栈。
  • dup:复制栈顶的 this。
  • getfield #2:将常量池中下标为 2 的常量加载到栈上,同时将一个 this 出栈。
  • iconst_1:将常量 1 入栈。
  • iadd:将栈顶的两个值相加后出栈,并将结果放回栈上。
  • dup_x1:复制栈顶的元素,并将其插入 this 下面。
  • putfield #2: 将栈顶的两个元素出栈,并将其赋值给字段 age。
  • ireturn:将栈顶的元素出栈返回。

0七、控制转移指令

控制转移指令包括:

  • 比较指令,比较栈顶的两个元素的大小,并将比较结果入栈。
  • 条件跳转指令,一般和比较指令一块使用,在条件跳转指令执行前,通常先用比较指令进行栈顶元素的比较,而后进行条件跳转。
  • 比较条件转指令,相似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。
  • 多条件分支跳转指令,专为 switch-case 语句设计的。
  • 无条件跳转指令,目前主要是 goto 指令。

1)比较指令

比较指令有:dcmpg,dcmpl、fcmpg、fcmpl、lcmp,指令的第一个字母表明的含义分别是 double、float、long。注意,没有 int 类型。

对于 double 和 float 来讲,因为 NaN 的存在,有两个版本的比较指令。拿 float 来讲,有 fcmpg 和 fcmpl,区别在于,若是遇到 NaN,fcmpg 会将 1 压入栈,fcmpl 会将 -1 压入栈。

举例来讲。

public void lcmp(long a, long b) {
    if(a > b){}
}
复制代码

经过 jclasslib 看一下 lcmp() 方法的字节码指令。

lcmp 用于两个 long 型的数据进行比较。

2)条件跳转指令

这些指令都会接收两个字节的操做数,它们的统一含义是,弹出栈顶元素,测试它是否知足某一条件,知足的话,跳转到对应位置。

对于 long、float 和 double 类型的条件分支比较,会先执行比较指令返回一个整形值到操做数栈中后再执行 int 类型的条件跳转指令。

对于 boolean、byte、char、short,以及 int,则直接使用条件跳转指令来完成。

举例来讲。

public void fi() {
    int a = 0;
    if (a == 0) {
        a = 10;
    } else {
        a = 20;
    }
}
复制代码

经过 jclasslib 看一下 fi() 方法的字节码指令。

3 ifne 12 (+9) 的意思是,若是栈顶的元素不等于 0,跳转到第 12(3+9)行 12 bipush 20

3)比较条件转指令

前缀“if_”后,以字符“i”开头的指令针对 int 型整数进行操做,以字符“a”开头的指令表示对象的比较。

举例来讲。

public void compare() {
    int i = 10;
    int j = 20;
    System.out.println(i > j);
}
复制代码

经过 jclasslib 看一下 compare() 方法的字节码指令。

11 if_icmple 18 (+7) 的意思是,若是栈顶的两个 int 类型的数值比较的话,若是前者小于后者时跳转到第 18 行(11+7)。

4)多条件分支跳转指令

主要有 tableswitch 和 lookupswitch,前者要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,经过给定的操做数 index,能够当即定位到跳转偏移量位置,所以效率比较高;后者内部存放着各个离散的 case-offset 对,每次执行都要搜索所有的 case-offset 对,找到匹配的 case 值,并根据对应的 offset 计算跳转地址,所以效率较低。

举例来讲。

public void switchTest(int select) {
    int num;
    switch (select) {
        case 1:
            num = 10;
            break;
        case 2:
        case 3:
            num = 30;
            break;
        default:
            num = 40;
    }
}
复制代码

经过 jclasslib 看一下 switchTest() 方法的字节码指令。

case 2 的时候没有 break,因此 case 2 和 case 3 是连续的,用的是 tableswitch。若是等于 1,跳转到 28 行;若是等于 2 和 3,跳转到 34 行,若是是 default,跳转到 40 行。

5)无条件跳转指令

goto 指令接收两个字节的操做数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处。

前面的例子里都出现了 goto 的身影,也很好理解。若是指令的偏移量特别大,超出了两个字节的范围,可使用指令 goto_w,接收 4 个字节的操做数。


巨人的肩膀:

segmentfault.com/a/119000003…

除了以上这些指令,还有异常处理指令和同步控制指令,我打算吊一吊你们的胃口,你们能够期待一波~~

(骚操做)

路漫漫其修远兮,吾将上下而求索

想要走得更远,Java 字节码这块就必须得硬碰硬地吃透,但愿二哥的这些分享能够帮助到你们~

叨逼叨

二哥在 掘金 上写了不少 Java 方面的系列文章,有 Java 核心语法、Java 集合框架、Java IO、Java 并发编程、Java 虚拟机等,也算是体系完整了。

为了能帮助到更多的 Java 初学者,二哥把本身连载的《教妹学Java》开源到了 GitHub,尽管只整理了 50 篇,发现字数已经来到了 10 万+,内容更是没得说,通俗易懂、风趣幽默、图文并茂

GitHub 开源地址(欢迎 star):github.com/itwanger/jm…

若是有帮助的话,还请给二哥点个赞,这将是我继续分享下去的最强动力!

相关文章
相关标签/搜索