JVM学习笔记之字节码指令集

目录

背景

概述

执行模型

字节码与数据类型

指令分类

加载与存储指令

再谈操作数栈与局部变量表

局部变量压栈指令

常量入栈指令

出栈装入局部变量表指令

算术指令

所有算术指令

比较指令的说明

类型转换指令

宽化类型转换

窄化类型转换

对象的创建与访问指令

创建指令

字段访问指令

数组操作指令

类型检查指令

方法调用与返回指令

方法调用指令

方法返回指令

操作数栈管理指令

控制转移指令

条件跳转指令

比较条件跳转指令

多条件分支跳转指令

无条件跳转指令

异常处理指令

异常抛出指令

异常处理与异常表

同步控制指令

方法级的同步

方法内指定指令序列的同步

结语

背景

本文记录学习字节码指令集的笔记

概述

java字节码对于jvm就好像汇编对于计算机,属于基本执行指令。字节码指令由一个字节长度的、代表某种特定操作含义的数字(操作码),以及跟随其后的零到多个操作数组成。因此字节码指令数量不会超过256。

官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html

执行模型

如果不考虑异常处理的话,那么jvm的解释器的执行模型可以用以下伪代码来理解

do {
    pc = pc + 1;
    根据pc的位置,从字节码流取出操作码;
    if (字节码存在操作数) 从字节码流中取出操作数;
    执行操作码定义的操作
} while(字节码长度 > 0)

字节码与数据类型

对于不同的数据类型,有不同的助记符:

助记符

对应类型

i

int

l

long

s

short

b

byte

c

char

f

float

d

double

a

对象

也有一些指令助记符没有明确指定操作类型的字母,比如arraylength指令,但它操作数永远只能是数组对象。

另有一些指令与数据类型无关,比如无条件跳转指令goto

大部分指令不支持byte、char和short,甚至没有任何指令支持boolean。编译器会在编译期或运行期对byte和short类型的数据进行带符号扩展成对应的int数据,将boolean和char数据进行零位扩展成对应的int数据。类似地,对于boolean、byte、short、char数组时,也会转换成使用对应int类型的字节码处理。因此,大多数对于boolean、byte、short、char的操作,实际都是int类型的操作

指令分类

包括加载与存储、算术、类型转换、对象的创建与·访问、方法调用与返回、操作数栈管理、比较控制、异常处理、同步控制九类

加载与存储指令

主要负责将数据从栈帧的局部变量表和操作数栈之间来回传递

再谈操作数栈与局部变量表

操作数栈用来存放计算的操作数和返回结果。在执行每一条指令前,JVM要求该指令的操作数已经被压入操作数栈中。执行指令时,JVM会将该指令所需的操作数弹出,并将指令的结果重新压入栈中

局部变量表用来存放方法入参、局部变量,以及this指针(非静态方法),long和double类型的值占据两个槽,别的只有一个槽

具体请参见此文里虚拟机栈中相关内容

另外,局部变量表中的变量是重要的GC根结点,被它们直接或间接引用的对象都不会被回收。在方法执行时,JVM用局部变量表完成方法的传参

局部变量压栈指令

将一个局部变量的值加载到操作数栈:xload、xload_n,x∈{i, l, f, d, a},n∈{0, 1, 2, 3},x表示类型,n表示操作的是局部变量表中哪个索引位置上的数据。一般都用xload_n来进行压栈,如果使用xload,就说明局部变量表长度可能超过了4

具体举例以以下代码为例

public void load(int num, Object obj, long count, boolean flag, short[] arr) {
    System.out.println(num);
    System.out.println(obj);
    System.out.println(count);
    System.out.println(flag);
    System.out.println(arr);
}

对应字节码指令如下

0 getstatic #2 <java/lang/System.out>
3 iload_1
4 invokevirtual #3 <java/io/PrintStream.println>
7 getstatic #2 <java/lang/System.out>
10 aload_2
11 invokevirtual #4 <java/io/PrintStream.println>
14 getstatic #2 <java/lang/System.out>
17 lload_3
18 invokevirtual #5 <java/io/PrintStream.println>
21 getstatic #2 <java/lang/System.out>
24 iload 5
26 invokevirtual #6 <java/io/PrintStream.println>
29 getstatic #2 <java/lang/System.out>
32 aload 6
34 invokevirtual #4 <java/io/PrintStream.println>
37 return

上来一个getstatic用来获取静态结构,这里就是System.out,这里不多说。由于这是非静态方法,那么局部变量表索引为0的位置存放的是this。

iload_1就表示加载第二个局部变量的值、类型为int,aload_2加载第三个局部变量的值、类型为对象,lload_3加载第四个局部变量的值、类型为long(long类型占两个槽,因此下一个元素放到索引为5的槽中),iload 5加载索引为5的局部变量的值、类型为int(boolean转换成了int),aload 6加载索引为6的局部变量的值,类型为对象。

我们可以看一下局部变量表,验证上面局部变量位置与类型的说法

常量入栈指令

将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_i、lconst_l、fconst_f、dconst_d

const系列:用于对特定的常量入栈,入栈的常量通常隐藏在指令本身里

包括aconst_null、iconst_m1、iconst_i、lconst_l、fconst_f、dconst_d,其中:

  • aconst_null将null入栈
  • iconst_m1将-1入栈
  • iconst_x(x∈[0, 5])将整型x入栈
  • lconst_l(l∈[0, 1])将长整型的0或1入栈
  • fconst_f(f∈[0, 1, 2])将浮点型0、1、2入栈
  • dconst_d(d∈[0, 1])将double型的0、1入栈

如果这些指令不能满足需求,就尝试使用push指令

push系列:bipush接收八位整数,sipush接收十六位整数,都对他们进行入栈

ldc系列:ldc是万能指令,接收一个八位参数,指向常量池中int、float或String索引,将指定的内容压栈。如果这还不够,可以使用ldc_w,接收两个八位参数。如果操作数是double或long,就使用ldc2_w

示例代码如下

public void pushConstantLdc() {
    int i = -1;
    int a = 5;
    int b = 6;
    int c = 127;
    int d = 128;
    int e = 32767;
    int f = 32768;
}

对应字节码指令如下

0 iconst_m1
1 istore_1
2 iconst_5
3 istore_2
4 bipush 6
6 istore_3
7 bipush 127
9 istore 4
11 sipush 128
14 istore 5
16 sipush 32767
19 istore 6
21 ldc #7 <32768>
23 istore 7
25 return

可见,-1入栈使用的是iconst_m1,5入栈用的是iconst_5,6和127入栈就得用bipush,128和32767超过了一个字节的范围([-128, 127]),就得用sipush,32768超过了两个字节的表示范围,就用了ldc来加载常量池中的常量,并将之入栈

对以上三种指令总结如下

示例代码如下

public void constantLdc() {
    long a1 = 1;
    long a2 = 2;
    float b1 = 2;
    float b2 = 3;
    double c1 = 1;
    double c2 = 2;
    Date d1 = null;
}

对应字节码指令为

0 lconst_1
1 lstore_1
2 ldc2_w #8 <2>
5 lstore_3
6 fconst_2
7 fstore 5
9 ldc #10 <3.0>
11 fstore 6
13 dconst_1
14 dstore 7
16 ldc2_w #11 <2.0>
19 dstore 9
21 aconst_null
22 astore 11
24 return

可见,所有超过范围的常量都用ldc系列来入栈,其中float用的是ldc,而long和double则用的是ldc2_w

出栈装入局部变量表指令

将一个数值从操作数栈存储到局部变量表中:xstore、xstore_n,x∈{i, l, f, d, a},n∈{0, 1, 2, 3}、xastore,x∈{i, l, f, d, a, b, c, s}。一般说来,类似store这种指令需要带一个参数,用来指明将弹出的数据放到局部变量表的哪个位置,为了尽可能压缩指令大小,使用专门的istore_0、istore_1、istore_2、istore_3将操作数栈顶的元素弹出来放到局部变量表的0~3号位置。这么做是因为局部变量表的前几个位置很常用,所以就用零操作数指令来对其进行操作。

示例代码如下

public void store(int k, double d) {
    int m = k + 2;
    long l = 12;
    String s = "szc";
    float f = 1.1f;
    d = 20;
}

对应字节码指令为

0 iload_1
1 iconst_2
2 iadd
3 istore 4
5 ldc2_w #13 <12>
8 lstore 5
10 ldc #15 <szc>
12 astore 7
14 ldc #16 <1.1>
16 fstore 8
18 ldc2_w #17 <20.0>
21 dstore_2
22 return

其中第3行istore 4就是把操作数栈栈顶整型元素弹出来,存到局部变量表4号位(对应局部变量m);第12行astore 7弹出的则是对象元素,存到7号位(对应局部变量m);第16行fstore 8弹出的则是float元素,存到8号位(对应局部变量f);第21行dstore_2弹出的是double元素,存到2号位(对应形参d)。此方法的局部变量表如下图所示

其中d、l分别为double和long类型的形参或局部变量,因此占两个槽

再看以下代码

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


    {
        String s = "szc";
    }
}

由于i和s都是代码块中的局部变量,所以它们用的都是4号槽位,而且s会复用i的位置。字节码如下

0 iconst_0
1 istore 4
3 ldc #15 <szc>
5 astore 4
7 return

局部变量表如下

可见代码块内的局部变量i或s没有显示出来,但是能看到局部变量表最大长度为5,这个值和最后一个局部变量的槽位索引 + 1是一致的,因此i和s还是被加载了。

算术指令

负责对操作数栈上的元素进行运算,先弹出栈顶操作数,运算完后结果入栈。如果是双操作数指令(加减乘除等),运算规则是次栈顶元素 加减乘除 栈顶元素。

数据运算可能会导致溢出,例如两个很大的正整数相加可能会成一个负数。不过JVM没有明确规定整形数据溢出的具体结果,只是规定整数除以0是会发生算术异常。

运算模式:

  1. 向最接近的数舍入模式:JVM要求进行浮点数运算时,所有的运算结果必须舍入到适当的精度,而非精确结果要被舍入为可被表示的最接近的精确值。如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为0的形式。
  2. 向零舍入模式:将浮点数转换成整数时,采用此模式。此模式将在目标数值中选择一个最接近但不大于原值的数字作为最精确的舍入结果

NaN值的使用:

  • 当一个操作产生溢出时,将会使用有符号的无穷大表示。如果某个操作结果没有明确的数学定义的话,就会使用NaN表示。所有使用NaN值作为操作数的运算操作,结果都是NaN。
public static void main(String[] args) {
    int a = 1;
    double d = a / 0.0;
    System.out.println(d);  // Infinity


    float b = 0f;
    d = b / 0.0;
    System.out.println(d);  // NaN
}

所有算术指令

取反举例:

public void f() {
    float f = 10f;
    float j = -f;
    f = -j;
}

对应字节码指令

0 ldc #19 <10.0>
2 fstore_1
3 fload_1
4 fneg
5 fstore_2
6 fload_2
7 fneg
8 fstore_1
9 return

先加载第19个常量10.0到操作数栈栈顶,然后弹出并存储到局部变量表1号位(f)。然后加载1号位的局部变量到操作数栈栈顶,fneg对栈顶元素取反,然后结果弹出存到局部变量表2号位。加载2号位的局部变量到操作数栈栈顶,再使用fneg对栈顶元素取反,最后弹出存到局部变量表1号位,返回

加举例1:

public void f1() {
    int i = 10;
    i = i + 10;
}

对应字节码指令

0 bipush 10
2 istore_1
3 iload_1
4 bipush 10
6 iadd
7 istore_1
8 return

整型10入栈,弹出并存到局部变量表1号位。加载1号位的局部变量到操作数栈栈顶,再入栈一个整型10,而后iadd让栈顶两个整型元素弹出并相加,结果存到操作数栈栈顶。最后栈顶元素弹出并存到局部变量表1号位,返回。

加举例2:

    public void f1() {
        int i = 10;
        i += 10;
    }

对应字节码指令

0 bipush 10
2 istore_1
3 iinc 1 by 10
6 return

把栈顶元素10弹出并存到1号位的局部变量后,使用iinc 1 by 10对1号局部变量进行整型自增,增幅为10

乘法举例:

public int f2() {
    int a = 89;
    int b = 7;
    int c = 10;

    return (a + b) * c;
}

对应字节码指令

0 bipush 89
2 istore_1
3 bipush 7
5 istore_2
6 bipush 10
8 istore_3
9 iload_1
10 iload_2
11 iadd
12 iload_3
13 imul
14 ireturn

入栈一个整型89,然后弹出存到局部变量表1号位。入栈一个整型7,弹出存到局部变量表2号位。入栈一个整型10,弹出存到局部变量表3号位。然后加载局部变量表1号位和2号位到栈顶,都是整型,再弹出两个整型栈顶元素并相加,结果存到栈顶。加载整型局部变量表3号位到栈顶,弹出两个整型栈顶元素并相乘,结果放到栈顶。最后返回栈顶整型值

逻辑运算举例:

public int f3(int i, int j) {
    return ((i + j - 1) & ~(j - 1));
}

对应字节码指令

0 iload_1
1 iload_2
2 iadd
3 iconst_1
4 isub
5 iload_2
6 iconst_1
7 isub
8 iconst_m1
9 ixor
10 iand
11 ireturn

先把1号和2号整型局部变量加载到栈顶,然后iadd把栈顶两个整型元素弹出并相加,结果入栈。iconst_1入栈一个整型常量1,并对栈顶两个整型元素弹出相减(次栈顶减栈顶),结果入栈。然后加载2号整型局部变量到栈顶,加载整型常量1到栈顶,同样对栈顶两个整型元素弹出相减),结果入栈。然后入栈一个整型-1,将整型次栈顶和栈顶元素先弹出进行异或,结果入栈,这是取反的一种方法。最后iand对整型次栈顶和栈顶元素先弹出再相与,结果入栈,而后弹出整型栈顶元素作为返回值

++运算符举例:

i++举例如下

public void f4() {
    int i = 0;
    int a = i++;
}

对应字节码指令

0 iconst_0
1 istore_1
2 iload_1
3 iinc 1 by 1
6 istore_2
7 return

显然,把1号局部变量的值入栈,然后自增1号局部变量(而非栈顶,所以栈顶值还是旧的1号局部变量值),再把栈顶弹出到2号局部变量中,因此a为0,i为1

++i举例如下

public void f5() {
    int i = 0;
    int b = ++i;
}

对应字节码指令

0 iconst_0
1 istore_1
2 iinc 1 by 1
5 iload_1
6 istore_2
7 return

显然,这里是先自增1号局部变量,再将其值入栈,并弹出到2号局部变量中,因此b和i都是1

对于以下代码

public void f6() {
    int i = 0;
    i = i++;
}

对应字节码指令

0 iconst_0
1 istore_1
2 iload_1
3 iinc 1 by 1
6 istore_1
7 return

执行流程为:把整型常量0入栈,并弹出到1号局部变量。再把1号局部变量的值入栈,对1号局部变量自增,然后把栈顶弹出到1号局部变量里,返回。因此i = i++后,i的值不变,++的结果被覆盖了。

比较指令的说明

比较指令的作用是比较次栈顶与栈顶元素的大小,并将结果入栈

对于double和float,由于NaN的存在,各有两个版本的比较指令。以float为例,有fcmpg和fcmpl两个,它们的区别是遇到NaN时的处理结果不同,前者结果为1.后者为-1。

lcmp针对长整型整数,因为long类型没有NaN,因此就它一个就足够了

比较指令的工作过程为:弹出栈顶与次栈顶元素,如果两者相等,则压入0;如果次栈顶大于栈顶,则压入1;如果次栈顶小于栈顶,则压入-1

int类型没有单独的比较指令,它的比较通常和转移在一起,因此int类型的只有条件跳转和比较条件跳转指令。实际上,我们编程时,比较指令确实总是跟转移在一起,而前面几种比较指令也是为了把栈顶转换成int类型的数值,再进行转移,所以更多关于比较和转移指令的说明,请参见下面控制转移指令章节

类型转换指令

类型转换指令可以将两种不同类型的数值进行转换,一般用于实现用户代码中的显式类型转换,或者处理字节码指令集中数据类型相关指令无法和数据类型一一对应的问题。

宽化类型转换

宽化类型转换指的是小范围类型向大范围类型的安全转换。JVM支持以下数值的宽化类型转换,也就是说不需要我们显式强转:

  • int -> long、float、double:分别对应i2l、i2f、i2d
  • long -> float、double:分别对应l2f、l2d
  • float -> double: f2d

示例代码如下

public void f7() {
    int i = 1;
    long l = i;
    float f = i;
    double d = i;

    float f1 = l;
    double d1 = l;

    double d2 = f;
}

对应字节码指令

0 iconst_1
1 istore_1
2 iload_1
3 i2l
4 lstore_2
5 iload_1
6 i2f
7 fstore 4
9 iload_1
10 i2d
11 dstore 5
13 lload_2
14 l2f
15 fstore 7
17 lload_2
18 l2d
19 dstore 8
21 fload 4
23 f2d
24 dstore 10
26 return

可见JVM用宽化类型转换指令帮我们进行了类型转换

精度损失问题:

    宽化类型转换是不会因为超过目标类型最大值而丢失信息的,比如从int转成long,不会丢失任何信息,转换前后的值是精确相等的。但是从int、long转到float,或者从long转换成double可能会发生精度丢失——可能会丢掉几个最低有效位上的值,JVM会根据IEEE754最接近舍入模式得到正确的整数值。这是因为float和double要用一些位数表示阶码

    尽管宽化类型转换可能会发生精度损失,但不会导致JVM抛出异常

示例代码如下

int i = 123321321;
float f = i;
System.out.println(f); // 1.2332132E8


long l = 123123123123123123L;
double d2 = l;
System.out.println(d2); // 1.2312312312312312E17

可见最后的3都没了

补充说明:

    从byte、char和short转int的宽化类型转换实际上是不存在的。对于byte转int,JVM没有做实质性的转换处理,只是通过操作数栈交换了两个数据。而将byte转换成long时,用的是i2l,可见byte已经被转换成了int。short也是这样,这种处理方式有两个原因:

    首先可以减少实际的数据类型。如果为byte、short或char准备一套指令,那么指令的数量就会大增,而目前jvm只想用一个字节表示指令,因此指令总数不能超过256个。其次,由于局部变量表的槽固定为32位,无论是byte、short还是char存到局部变量表,都会占用四个字节,和int是一样的,因此没必要特意区分这几种数据类型了。

窄化类型转换

窄化类型转换就是强制类型转换。JVM直接支持以下的窄化类型转换:

  • int -> byte、short、char:对应指令i2b、i2s、i2c
  • long -> int:对应指令l2i
  • float -> int、long:对应指令f2i、f2l
  • double -> int、long、floate:对应指令d2i、d2l、d2f

示例代码如下

public void f8() {
    int i = 10;
    byte b = (byte) i;
    char c = (char) i;
    short s = (short) i;

    long  l = 10L;
    byte b1 = (byte) l;
    char c1 = (char) l;
    short s1 = (short) l;
}

对应字节码指令

0 bipush 10
2 istore_1
3 iload_1
4 i2b
5 istore_2
6 iload_1
7 i2c
8 istore_3
9 iload_1
10 i2s
11 istore 4
13 ldc2_w #20 <10>
16 lstore 5
18 lload 5
20 l2i
21 i2b
22 istore 7
24 lload 5
26 l2i
27 i2c
28 istore 8
30 lload 5
32 l2i
33 i2s
34 istore 9
36 return

可见,long强转到byte、char、short时,会先转换成int,再从int转换到目标类型。而short转换成byte,则会直接i2b

精度损失问题:

    窄化类型可能会导致转换结果具备不同的正负号、不同的数量级,因此转换过程很可能会导致数值丢失精度。

    尽管数据类型窄化转换肯可能会发生上限溢出、下限溢出和精度丢失等情况,但依旧不会发生运行时异常

示例代码如下

int i1 = 128;
byte b2 = (byte) i1;
System.out.println(b2); // -128 

128二进制为1000 0000,把它当成一个字节的话,最高位表示符号,因此为负数,所以为-128

补充说明:

    当一个浮点值窄化转换成整数类型int或long时,将遵循以下规则:

  1. 如果浮点值是NaN,那么转换结果就是int或long的0;
  2. 如果浮点值不是无穷大的话,浮点值用IEEE 754向零舍入模式取整,获得整数值v。如果v在目标类型表示范围之内,那么转换结果就是v。否则将根据v的符号,转换成目标类型能表示的最大或者最小整数值。

    当一个double值窄化成float类型时,通过最接近舍入模式舍入一个可以用float类型表示的数字,最后结果根据下面三条规则进行判断:

  1. 如果转换结果的绝对值太小而无法使用float表示,将返回float类型的正负0;
  2. 如果转换结果的绝对值太大而无法使用float表示,将返回float类型的正负无穷大;
  3. 如果是double类型的NaN,则转换成float类型的NaN

对象的创建与访问指令

java是面向对象的语言,JVM从字节码层面就对面向对象做了深层次的支持。

创建指令

创建类对象的指令:new,它接受一个操作数,为指向常量池的索引,表示要创建的类型。执行完成后,将对象的引用压栈

示例代码

public void f9() {
    Object obj = new Object();
}

对应字节码指令

0 new #22 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init>>
7 astore_1
8 return

上来创建一个Object对象,将其地址压入操作数栈栈顶,再把栈顶元素复制一份。然后弹出栈顶元素,执行它的构造方法。最后再弹出栈顶元素存到1号局部变量,并返回。因为执行构造方法和存储到局部变量都要弹出栈顶,索引new之后要进行dup复制

创建数组的指令:newarray(创建基本类型数组)、newarray(创建对象数组)、multianewarray(创建多维数组)

示例代码

public void f10() {
    int[] arr1 = new int[10];
    Object[] arr2 = new Object[10];


    int[][] arr3 = new int[10][10];
    Object[][] arr4 = new Object[10][];
}

对应字节码指令

0 bipush 10
2 newarray 10 (int)
4 astore_1
5 bipush 10
7 anewarray #22 <java/lang/Object>
10 astore_2
11 bipush 10
13 bipush 10
15 multianewarray #23 <[[I> dim 2
19 astore_3
20 bipush 10
22 anewarray #24 <[Ljava/lang/Object;>
25 astore 4
27 return

可见不同类型的数组有不同的创建指令,要注意多维数组如果只填第一个维度,那还是当作一维数组来创建。

字段访问指令

对象创建后,就可以通过对象访问指令来获取对象实例或数组实例中的字段或元素。

访问类字段(static字段):getstatic、putstatic

访问类实例字段:getfield、putfield

示例代码如下

public void f11() {
    System.out.println("...");
}

对应字节码

0 getstatic #2 <java/lang/System.out>
3 ldc #25 <...>
5 invokevirtual #26 <java/io/PrintStream.println>
8 return

第一行就是访问类字段并入栈,字段名为2号常量<java/lang/System.out>

再看一个示例代码

public void f12() {
    Order o = new Order();
    o.id = 1;
    System.out.println(o.id);


    Order.name = "szc";
    System.out.println(Order.name);
}

class Order {
    int id;
    static String name;
}

方法f12()的字节码指令如下

0 new #27 <Order>
3 dup
4 invokespecial #28 <Order.<init>>
7 astore_1
8 aload_1
9 iconst_1
10 putfield #29 <Order.id>
13 getstatic #2 <java/lang/System.out>
16 aload_1
17 getfield #29 <Order.id>
20 invokevirtual #3 <java/io/PrintStream.println>
23 ldc #15 <szc>
25 putstatic #30 <Order.name>
28 getstatic #2 <java/lang/System.out>
31 getstatic #30 <Order.name>
34 invokevirtual #26 <java/io/PrintStream.println>
37 return

前八行不用多说,第8行把对象类型的1号局部变量加载到操作数栈栈顶,第9行入栈一个整型常量1。然后putfield从栈顶和次栈顶弹出值和字段,把值存入字段中。第13行获取静态字段<java/lang/System.out>并入栈,然后把1号局部变量加载到栈顶,invokevirtual指令调用次栈顶元素的父类方法,入参为栈顶元素。第23行加载常量szc到栈顶,然后将其出栈保存到静态属性Order.name中,然后用同样的方法调用<java/lang/System.out>的父类方法输出,最后返回

数组操作指令

数组操作主要有加载和存储两种类型,分别为xastore和xaload,具体如下

数组类型

加载指令

存储指令

byte(boolean)

baload

bastore

char

caload

castore

short

saload

sastore

int

iaload

iastore

long

laload

lastore

float

faload

fastore

double

daload

dastore

reference

aaload

aastore

说明:

    指令xaload表示把数组引用压栈,比如saload和caload分别表示压入short数组引用和char数组引用。指令xaload在执行时,要求操作数中栈顶元素为数组索引i,次栈顶元素为数组引用a,此指令会弹出这两个元素,并把a[i]入栈

    xastore专门对数组操作,以iastore为例,他用于给一个整型数组的给定索引赋值。在iastore执行前,操作数栈从栈顶往下要依次弹出值、索引和数组引用,iastore会根据这三个元素来对数组指定位置进行赋值

取数组长度指令:arraylength,要求栈顶元素为数组引用。执行arraylength时,弹出栈顶元素,获取其长度,并入栈

示例代码如下

public void f13() {
    int[] arr = new int[20];
    arr[3] = 4;
    System.out.println(arr[5]);
    System.out.println(arr.length);
}

对应字节码指令

0 bipush 20
2 newarray 10 (int)
4 astore_1
5 aload_1
6 iconst_3
7 iconst_4
8 iastore
9 getstatic #2 <java/lang/System.out>
12 aload_1
13 iconst_5
14 iaload
15 invokevirtual #3 <java/io/PrintStream.println>
18 getstatic #2 <java/lang/System.out>
21 aload_1
22 arraylength
23 invokevirtual #3 <java/io/PrintStream.println>
26 return

前三行不用解释,第四行把数组引用入栈,第六第七行把整型常量3和4先后入栈,然后执行iastore,把栈顶三个元素4、3和数组引用出栈,并对数组元素进行赋值。第9行把静态字段<java/lang/System.out>入栈,第12行把1号局部变量入栈,第13行把整型常量5入栈,iaload弹出栈顶和次栈顶元素,也就是数组索引和数组引用,取出数组指定索引的值,并入栈。第15行弹出栈顶和次栈顶元素,执行次栈顶元素的父类方法,入参为栈顶元素。由于执行了arr.length,因此第21行要先把1号局部变量入栈,再调用arraylength弹出栈顶元素,获取其长度,并入栈,最后用和第15行一样的方法执行方法,最后返回

类型检查指令

检查类实例或数组类型的指令:instanceof和checkcast,前者用于判断指定对象是否是某一个类的实例,它会把判断结果(1属于,0不属于)压入操作数栈;后者用于检查类型强制转换是否可以直接进行,如果可以还则罢了,什么也不做,否则就会抛出ClassCastException异常

示例代码

public String f14(Object o) {
    if (o instanceof String) {
        return (String) o;
    } else {
        return null;
    }
}

对应字节码指令

0 aload_1
1 instanceof #31 <java/lang/String>
4 ifeq 12 (+8)
7 aload_1
8 checkcast #31 <java/lang/String>
11 areturn
12 aconst_null
13 areturn

上来把1号局部变量加载到栈顶,然后调用instanceof指令,弹出栈顶元素并判断是否属于31号常量对应的类型,并把结果(1属于,0不属于)入栈。第4行ifeq判断栈顶元素是否为0,为0的话,跳到第12行。第12行就是把引用类型的常量null入栈,然后areturn返回栈顶元素;如果ifeq判断失败,也就是栈顶元素不是0(这里就只能是1了),则进入下一行,加载1号局部变量入栈,checkcast判断栈顶元素能否被强转成31号常量对应的类型,如果可以,就继续执行第11行,返回栈顶元素

方法调用与返回指令

方法调用指令

方法调用指令总结见下表

指令名

说明

invokevirtual

调用对象的实例方法,采用虚方法分派(java中最常见的方法分派方式),支持多态。

invokeinterface

调用接口方法,它会在运行时搜索有特定对象实现的接口方法,并找出合适的方法进行调用

invokespecial

调用一些需要特殊处理的实例方法,包括构造方法、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用时进行动态分发

invokestatic

调用类中的静态方法,静待绑定

invokedynamic

它的分派逻辑由用户设定的引导方法决定

测试代码如下

public void invoke1() {
    Date date = new Date();
    Thread t1 = new Thread();

    super.toString();

    invokePrivate();

    invoke2();
}

public void invoke2() {}

private void invokePrivate() {}

invoke1()方对应的字节码指令如下

0 new #2 <java/util/Date>
3 dup
4 invokespecial #3 <java/util/Date.<init>>
7 astore_1
8 new #4 <java/lang/Thread>
11 dup
12 invokespecial #5 <java/lang/Thread.<init>>
15 astore_2
16 aload_0
17 invokespecial #6 <java/lang/Object.toString>
20 pop
21 aload_0
22 invokespecial #7 <MethodTest.invokePrivate>
25 aload_0
26 invokevirtual #8 <MethodTest.invoke2>
29 return

可见构造方法、父类方法和私有方法的调用用的都是invokespecial,因为这些方法都不可能被覆写。而公有方法用的是invokevirtual,因为可能被覆写

下面改造一下invokePrivate()方法

private void invokePrivate() {
    staticPrivate();
    staticPublic();
}

private static void staticPrivate() {}

public static void staticPublic() {}

其对应的字节码指令如下

0 invokestatic #9 <MethodTest.staticPrivate>
3 invokestatic #10 <MethodTest.staticPublic>
6 return

可见,不管是公有还是私有(哪怕是接口的),只要是静态方法,就都是invokestatic

再改造一下invoke2()方法

public void invoke2() {
    Thread t = new Thread();
    ((Runnable) t).run();
}

对应的字节码指令如下

0 new #4 <java/lang/Thread>
3 dup
4 invokespecial #5 <java/lang/Thread.<init>>
7 astore_1
8 aload_1
9 invokeinterface #9 <java/lang/Runnable.run> count 1
14 return

可见调用Runnable接口对象的run()方法,使用的就是invokeinterface指令

方法返回指令

方法调用结束前,要进行返回。方法返回指令是根据返回值的类型进行区分的,总结如下

返回类型

返回指令

void

return

int(boolean、char、short、byte)

ireturn

long

lreturn

float

freturn

double

dreturn

reference

areturn

通过ireturn、lreturn、freturn、dreturn和areturn指令,将当前方法的操作数栈栈顶元素弹到调用者的操作数栈中,而当前方法操作数栈中其他元素会被全部丢弃。如果当前方法是同步方法,那么还会执行一条隐含的monitorexit指令,退出临界区。最后,会丢弃当前方法的栈帧,并将控制权交给调用者。

操作数栈管理指令

这些指令可以直接操作数栈,包括以下内容:

指令

作用

pop、pop2

将一个槽或两个槽的元素从栈顶弹出,并直接抛弃。

dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2

复制栈顶一个槽或两个槽的数值,并将复制值压入栈顶

swap

交换栈顶两个槽的元素的位置,JVM没有提供交换两个8字节数值(long、double)的指令

nop

什么也不做,用于调试、占位等

不带_x的指令表示复制栈顶数据并压入栈顶,包括dup和dup2。dup的系数表示要复制的槽个数。dup开头的指令用于复制一个槽的数据,如一个int或reference类型的数据;dup2开头的指令用于复制两个槽的数据,如一个long、两个int、一个int和一个float类型的数据

带_x的指令是复制栈顶的数据并插入到栈顶以下的某个位置,包括dup_x1、dup2_x1、dup_x2和dup2_x2。对于带_x的复制插入指令,只要将dup和x的系数相加,和就是要插入的位置。因此这些指令插入的位置分别是栈顶往下(包括栈顶)第2、3、3、4个槽的下面

pop和pop2分别将栈顶一个或两个槽的数值出栈,前者如一个short、int,后者如两个int、一个long等

这些指令是通用型指令,使用时不用加上操作数类型

看以下示例代码

public void print() {
    Object obj = new Object();
}

对应字节码如下

0 new #2 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init>>
7 astore_1
8 return

调用new指令后在堆空间创建一个对象,并把其地址压入操作数栈。然后dup指令把栈顶数值复制一份,因为接下来的invokespecial和astore都要弹出栈顶来进行方法的调用或保存到局部变量表

修改一下示例代码

public void print() {
    Object obj = new Object();
    obj.toString();
}

现在它的字节码指令如下

0 new #2 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init>>
7 astore_1
8 aload_1
9 invokevirtual #3 <java/lang/Object.toString>
12 pop
13 return

调用obj.toString()方法会使用invokeVirtual指令调用方法,返回一个String引用到栈顶。但由于这个栈顶我们没使用,因此调用pop来对它进行抛弃

再看以下示例代码

private int index = 0;
public int test() {
    return index++;
}

对应字节码指令如下

0 aload_0
1 dup
2 getfield #2 <StackOperateTest.index>
5 dup_x1
6 iconst_1
7 iadd
8 putfield #2 <StackOperateTest.index>
11 ireturn

首先加载引用型的0号局部变量this到栈顶,然后复制一份栈顶,再弹出栈顶并访问字段,同时字段入栈。dup_x1指令把栈顶的字段复制并将复制品插入到1+1=2个槽的下面,然后入栈一个整型常量1,将次栈顶和栈顶弹出相加,结果入栈。然后把出栈次栈顶和栈顶,把栈顶赋给次栈顶的字段,最后把剩下的栈顶返回。整个过程的操作数栈变化过程如下(最右边为栈顶,每一项都占用一个槽):

this -> this this -> this index -> index this index -> index this index 1 -> index this index + 1 -> index -> 空栈

控制转移指令

程序流程离不开条件控制,为了支持条件跳转,JVM提供了大量字节码指令。

控制转移指令和比较指令密切相关,比较指令在算术指令一节已经讲过了,接下来主要讲一下转移指令

条件跳转指令

条件跳转指令通常和比较指令结合使用。在条件跳转指令执行之前,一般可以先用比较指令准备栈顶元素,再进行条件跳转。跳转指令有ifeq、iflt、ifle、ifne、ifge、ifgt、ifnull、ifnonnull,这些指令都接收两个字节的操作数,此操作数为向下的偏移量,用于计算要跳转的位置(第几条字节码)。这些指令的统一含义为:弹出栈顶元素,如果满足特定条件,就跳转到指定位置。

指令的具体说明如下

指令

跳转时栈顶int类型数值要满足的条件

ifeq

== 0

ifne

!= 0

iflt

< 0

ifle

<= 0

ifgt

> 0

ifge

>= 0

ifnull

== null

ifnonnull

!= null

以如下代码为例

public void f1() {
    int a = 0;
    if (a == 0) {
        a = 10;
    } else {
        a = 20;
    }
}

对应字节码如下

0 iconst_0
1 istore_1
2 iload_1
3 ifne 12 (+9)
6 bipush 10
8 istore_1
9 goto 15 (+6)
12 bipush 20
14 istore_1
15 return

看第3条字节码,如果栈顶不等于0,跳转到第12条(3 + 9)字节码(入栈20并存入1号整型局部变量);否则,向下执行第6条字节码,完了到第9条字节码,goto(无条件跳转)到第15条(9 + 6)字节码返回

再看以下示例代码

public void f2() {
    float a = 0.1f;
    if (a < 0f) {
        a = 1.2f;
    } else {
        a = 2.1f;
    }
}

对应字节码指令如下

0 ldc #5 <0.1>
2 fstore_1
3 fload_1
4 fconst_0
5 fcmpg
6 ifge 15 (+9)
9 ldc #6 <1.2>
11 fstore_1
12 goto 18 (+6)
15 ldc #7 <2.1>
17 fstore_1
18 return

对于浮点、双精度和长整型的条件转移来说,要先调用比较指令进行比较,把比较结果入栈,再调用条件转移指令跳转。此处在调用第5条指令fcmpg前,操作数栈(右边为栈顶)为a 0,因此比较指令fcmpg要把浮点类型的次栈顶和栈顶弹出进行比较,如果次栈顶大于栈顶,则压入1,否则压入-1。而后执行第6条条件转移指令,如果栈顶>0,跳到第15条指令,否则执行下面第9条指令。

条件跳转还可以和类型转换指令结合使用,看以下示例代码

public void f3() {
    int a = 1;
    long b = 2L;
    if (a > b) {
        a = 4;
    } else {
        b = 3;
    }
}

对应字节码指令如下

0 iconst_1
1 istore_1
2 ldc2_w #8 <2>
5 lstore_2
6 iload_1
7 i2l
8 lload_2
9 lcmp
10 ifle 18 (+8)
13 iconst_4
14 istore_1
15 goto 22 (+7)
18 ldc2_w #10 <3>
21 lstore_2
22 return

比较前,先调用第七条指令i2l把栈顶元素转换成long,再入栈2号长整型局部变量,然后调用lcmp把栈顶两个长整型元素弹出比较,如果次栈顶>栈顶,压入1;如果次栈顶<栈顶,压入-1;两者相等压入0。而后调用ifle判断栈顶元素是否<=0,是的话跳转到第18行,也就是else分支的逻辑;否则继续执行第13行,对应if分支的逻辑,执行完if分支的字节码后,goto到第22行返回

比较条件跳转指令

比较条件跳转指令类似于比较指令和条件跳转指令的结合体,用于int和引用类型的条件跳转。它将比较和跳转两个步骤合二为一,这类指令有if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpnq,它们的具体说明见下表

指令

次栈顶和栈顶元素类型

跳转条件

if_icmpeq

int

次栈顶 == 栈顶

if_icmpne

int

次栈顶 != 栈顶

if_icmplt

int

次栈顶 < 栈顶

if_icmple

int

次栈顶 <= 栈顶

if_icmpgt

int

次栈顶 > 栈顶

if_icmpge

int

次栈顶 >= 栈顶

if_acmpeq

引用

次栈顶 == 栈顶

if_acmpne

引用

次栈顶 != 栈顶

这些指令都接收两个字节的操作数作为参数,用于计算跳转的位置。同时在执行指令时,栈顶需要准备两个元素进行比较,比较时次栈顶与栈顶出栈,完成时没有任何元素入栈

看以下示例代码

public void f4() {
    int i = 1, j = 2;
    System.out.println(i > j);
}

对应的字节码指令如下

0 iconst_1
1 istore_1
2 iconst_2
3 istore_2
4 getstatic #12 <java/lang/System.out>
7 iload_1
8 iload_2
9 if_icmple 16 (+7)
12 iconst_1
13 goto 17 (+4)
16 iconst_0
17 invokevirtual #13 <java/io/PrintStream.println>
20 return

第9行就调用了比较条件跳转指令if_icmple来判断次栈顶(i)和栈顶(j)的大小关系,如果前者 <= 后者,跳转到第16行,入栈0;否则执行第12行,入栈1

再看以下示例代码

public void f5() {
    short i = 1;
    byte j = 2;
    System.out.println(i > j);
}

对应字节码指令如下

0 iconst_1
1 istore_1
2 iconst_2
3 istore_2
4 getstatic #12 <java/lang/System.out>
7 iload_1
8 iload_2
9 if_icmple 16 (+7)
12 iconst_1
13 goto 17 (+4)
16 iconst_0
17 invokevirtual #13 <java/io/PrintStream.println>
20 return

虽然两个局部变量类型为short和byte,但比较条件跳转指令还是把它俩当成了int

再看下面的代码

public void f6() {
    Object o1 = new Object();
    Object o2 = new Object();
    System.out.println(o1 == o2);
    System.out.println(o1 != o2);
}

对应字节码指令如下

0 new #3 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init>>
7 astore_1
8 new #3 <java/lang/Object>
11 dup
12 invokespecial #1 <java/lang/Object.<init>>
15 astore_2
16 getstatic #12 <java/lang/System.out>
19 aload_1
20 aload_2
21 if_acmpne 28 (+7)
24 iconst_1
25 goto 29 (+4)
28 iconst_0
29 invokevirtual #13 <java/io/PrintStream.println>
32 getstatic #12 <java/lang/System.out>
35 aload_1
36 aload_2
37 if_acmpeq 44 (+7)
40 iconst_1
41 goto 45 (+4)
44 iconst_0
45 invokevirtual #13 <java/io/PrintStream.println>
48 return

主要关注第21行和第37行的if_acmpne跟if_acmpeq,分别表示次栈顶和栈顶不相等和相等时跳转,前者跳到第28行,后者跳到第44行,目的地都是入栈0,也就是false

对于long、float、double类型的,只能用比较指令和条件跳转指令来进行条件跳转。

多条件分支跳转指令

多条件分支跳转是专门为switch-case语句设计的,主要有tableswitch和lookupswitch,见下表

指令

使用场景

tableswitch

case值连续

lookupswitch

case值不连续

tableswitch要求多个条件分支值是连续的,他内部只存放默认值、起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量指定的位置,因此效率较高,见下图;

lookupswitch内部升序存放着各个离散的case-offset对,每次执行都要搜索全部的case-offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低,见下图

请看以下代码

public void f7(int a) {
    switch (a) {
        case 0:
            a = 0;
            break;
        case 1:
            a = 1;
            break;
        case 2:
            a = 2;
            break;
        default:
            break;
    }
}

对应字节码如下

0 iload_1
1 tableswitch 0 to 2    0:  28 (+27)
    1:  33 (+32)
    2:  38 (+37)
    default:  43 (+42)
28 iconst_0
29 istore_1
30 goto 43 (+13)
33 iconst_1
34 istore_1
35 goto 43 (+8)
38 iconst_2
39 istore_1
40 goto 43 (+3)
43 return

这里case分支值为0、1、2,是连续的,因此使用tableswitch进行多分支跳转。它只存放起始值0和终止值2。如果栈顶元素 < 起始值或 > 终止值,就直接到默认值对应的行数;否则,目的行数的索引由栈顶元素 - 起始值指定,如果栈顶为1,那么目的行数的索引就是1,目的行数便是33,下一步就是跳转到第33行

再看以下代码

public void f8(int a) {
    switch (a) {
        case 10:
            a = 0;
            break;
        case 20:
            a = 2;
            break;
        case 30:
            a = 4;
            break;
        default:
            break;
    }
}

对应字节码指令如下

0 iload_1
1 lookupswitch 3
    10:  36 (+35)
    20:  41 (+40)
    30:  46 (+45)
    default:  51 (+50)
36 iconst_0
37 istore_1
38 goto 51 (+13)
41 iconst_2
42 istore_1
43 goto 51 (+8)
46 iconst_4
47 istore_1
48 goto 51 (+3)
51 return

其实逻辑很简单,根据栈顶元素遍历lookupswitch的case即可。如果栈顶为20,那么就跳转到第41行;如果栈顶为5或40,就跳转到default对应的第51行

对于String的switch-case,示例代码如下

public void f9(String a) {
    switch (a) {
        case "10":
            a = "0";
            break;
        case "20":
            a = "2";
            break;
        case "30":
            a = "4";
            break;
        default:
            break;
    }
}

对应字节码指令如下

  0 aload_1
  1 astore_2
  2 iconst_m1
  3 istore_3
  4 aload_2
  5 invokevirtual #14 <java/lang/String.hashCode>
  8 lookupswitch 3
    1567:  44 (+36)
    1598:  58 (+50)
    1629:  72 (+64)
    default:  83 (+75)
44 aload_2
45 ldc #15 <10>
47 invokevirtual #16 <java/lang/String.equals>
50 ifeq 83 (+33)
53 iconst_0
54 istore_3
55 goto 83 (+28)
58 aload_2
59 ldc #17 <20>
61 invokevirtual #16 <java/lang/String.equals>
64 ifeq 83 (+19)
67 iconst_1
68 istore_3
69 goto 83 (+14)
72 aload_2
73 ldc #18 <30>
75 invokevirtual #16 <java/lang/String.equals>
78 ifeq 83 (+5)
81 iconst_2
82 istore_3
83 iload_3
84 tableswitch 0 to 2    0:  112 (+28)
    1:  118 (+34)
    2:  124 (+40)
    default:  130 (+46)
112 ldc #19 <0>
114 astore_1
115 goto 130 (+15)
118 ldc #20 <2>
120 astore_1
121 goto 130 (+9)
124 ldc #21 <4>
126 astore_1
127 goto 130 (+3)
130 return

第8行的lookupswitch中的case值是"10"、"20"和"30"的哈希码,也是升序存放。如果栈顶元素的哈希码是其中某一个值,就根据case值对应的行号跳转到对应的字节码指令;如果不是,就跳转到default指向的行。我们会发现,每一个case值对应的字节码片段,都是调用了2号引用类型变量(a)的equals()方法,入参则是栈顶元素,值为来源case值("10"、"20"或"30")。如果equals()方法返回值为0,那么入栈某一个值(0、1、2)并弹出存到局部变量表3号位(默认为-1,第2行字节码)。所有的case包括默认值都会聚焦到第83行,也就是入栈三号整型局部变量,再使用tableswitch(因为0、1、2是连续的)进行字面量(0、1、2分别代表字面量10、20和30,-1为默认值)的多分支跳转。

综上,字符串的switch-case会先进行字符串哈希码的多分支跳转,再进行字面量的多分支跳转。也就是先hashcode(),再equals()

无条件跳转指令

无条件跳转指令为goto,接收两个字节的无符号操作数作为偏移量,来计算要跳转的位置。goto_w和goto类似,只是它的参数为4个字节的操作数,可以表示更大的地址范围。

指令jsr、jsr_w和ret也表示无条件跳转,但主要用于try-finally语句,且已经被JVM逐步抛弃。

无条件跳转指令见下表

指令

描述

goto

无条件跳转,两字节索引

goto_w

无条件跳转,四字节索引

jsr

无条件跳转,两字节索引,并将jsr下一条指令地址入栈

jsr_w

无条件跳转,四字节索引,并将jsr_2下一条指令地址入栈

ret

返回到由指定局部变量给出的指令地址,一般与jsr或jsr_w联合使用

看以下示例代码

public void f10() {
    int i = 0;
    while (i < 10) {
        String s = "s";
        i++;
    }
}

对应字节码指令如下

0 iconst_0
1 istore_1
2 iload_1
3 bipush 10
5 if_icmpge 17 (+12)
8 ldc #22 <s>
10 astore_2
11 iinc 1 by 1
14 goto 2 (-12)
17 return

第2行到第14行就实现了循环体(特别第14行的goto,直接实现了循环),第5行则是循环退出的条件

如果把i的类型改成double,则示例代码变成下面的样子

public void f11() {
    double i = 0.0;
    while (i < 10.1) {
        String s = "s";
        i++;
    }
}

对应的字节码如下

0 dconst_0
1 dstore_1
2 dload_1
3 ldc2_w #23 <10.1>
6 dcmpg
7 ifge 20 (+13)
10 ldc #22 <s>
12 astore_3
13 dload_1
14 dconst_1
15 dadd
16 dstore_1
17 goto 2 (-15)
20 return

主要就是是把if_icmpge改成了dcmpg和ifge、iinc 1 by 1改成了dload_1、dconst_1和dadd的组合

我们再看看第一个示例代码的for循环版

public void f12() {
    for (int i = 0; i < 10; i++) {
        String s = "s";
    }
}

对应字节码指令如下

0 iconst_0
1 istore_1
2 iload_1
3 bipush 10
5 if_icmpge 17 (+12)
8 ldc #22 <s>
10 astore_2
11 iinc 1 by 1
14 goto 2 (-12)
17 return

跟while循环的字节码一模一样,唯一的区别(i的作用域)没体现出来

最后看看do-while版

public void f13() {
    int i = 0;
    do {
        String s = "s";
        i++;
    } while (i < 10);
}

对应的字节码如下

0 iconst_0
1 istore_1
2 ldc #22 <s>
4 astore_2
5 iinc 1 by 1
8 iload_1
9 bipush 10
11 if_icmplt 2 (-9)
14 return

唯一的区别就是do-while版把比较条件跳转if_icmplt放到了最后,这意味着循环体至少执行一次

异常处理指令

异常及异常的处理:

  1. 异常对象的生成过程:throw(手动或自动) ---> athrow指令
  2. 异常的处理:抓抛模型(try-catch-finally) ---> 异常表

异常抛出指令

抛异常通过athrow指令来实现。除了使用throw语句显式抛异常外,JVM规范还规定了许多运行时异常会在其他字节码指令检测到异常情况时自动抛出,例如除数为0时,JVM会在idiv或ldiv指令中抛出ArithmeticException异常

正常情况下,操作数栈的出栈压栈都是一条条指令完成的。唯一的例外就是在抛异常时,JVM会清除操作数栈上的所有内容,而后将异常对象的引用压入到调用者的操作数栈上。

请看以下示例代码

public void f0(int a) throws FileNotFoundException {
    if (a == 0) {
        throw new FileNotFoundException();
    }
}

对应字节码指令如下

0 iload_1
1 ifne 12 (+11)
4 new #2 <java/io/FileNotFoundException>
7 dup
8 invokespecial #3 <java/io/FileNotFoundException.<init>>
11 athrow
12 return

第1行判断栈顶是否不为0,是的话跳到第12行的return;否则进行下面的异常处理,创建并复制一个异常对象,并出栈栈顶调用其构造方法,然后调用athrow指令抛出栈顶的异常对象到调用者的操作数栈栈顶,此后当前栈帧的操作数栈就会被销毁。

而且f0()方法会多一个异常信息

再看以下示例代码

public void f1(int a) {
    float s = a / 0;
}

对应字节码指令如下

0 iload_1
1 iconst_0
2 idiv
3 i2f
4 fstore_2
5 return

没有athrow指令,也没异常表

可见如果不对运行时异常进行抛出或捕获的话,是不会有异常处理指令或异常表来进行处理的

异常处理与异常表

异常处理:在JVM中,负责异常处理的catch语句采用异常表实现

异常表:如果一个方法定义了try-catch、try-finally代码块,它就会创建一个异常表,包含了每个异常处理或者finally块的信息,包括起始位置、结束位置、程序计数器记录的代码处理偏移地址、被捕获的异常类名在常量池中的索引

当一个异常被抛出时,JVM会在当前方法里寻找一个匹配的处理。如果没找到,这个方法会强制结束并弹出当前栈帧,异常则会被重新抛给上层调用的方法。如果在所有栈帧弹出前都没有找到合适的异常处理,这个线程随即终止。如果这个异常在最后一个非守护线程(如main线程)里抛出,将会导致JVM自己终止

不管何时抛出异常,如果匹配了一个异常处理,那么代码就会继续执行。这时,如果方法结束后没有抛出异常,仍然会执行finally块,在return前,它直接跳到finally块中来完成目标

请看以下示例代码

public void f2() {
    try {
        File f = new File("");
        FileInputStream inputStream = new FileInputStream(f);

        inputStream.close();
    } catch (FileNotFoundException e1) { }
    catch (IOException e2) { }
    catch (Exception e3) { }
}

对应字节码指令如下

0 new #4 <java/io/File>
3 dup
4 ldc #5
6 invokespecial #6 <java/io/File.<init>>
9 astore_1
10 new #7 <java/io/FileInputStream>
13 dup
14 aload_1
15 invokespecial #8 <java/io/FileInputStream.<init>>
18 astore_2
19 aload_2
20 invokevirtual #9 <java/io/FileInputStream.close>
23 goto 35 (+12)
26 astore_1
27 goto 35 (+8)
30 astore_1
31 goto 35 (+4)
34 astore_1
35 return

异常表如下,五个字段分别是捕获类型索引、try代码块的起始PC和结束PC、捕获成功时的跳转目的地PC、捕获类型(常量索引)

先看字节码指令,从第0行到第23行都是try代码块里的内容,如果没有异常发生,就会执行第23行的goto语句跳转到第35行的return,从此方法返回。一旦异常发生,就会从异常表的捕获类型里匹配发生的异常类型,如果匹配成功,就跳转到跳转PC指向的行数去执行捕获逻辑对应的字节码指令,把异常对象的引用出栈并存到1号局部变量里。注意每一个catch字节码都以一个goto结束,因为捕获逻辑执行完后,都要继续往下执行

最后看以下示例代码

public String f3() {
    String s = "a";
    try {
        return s; // a
    } finally {
        s =  "szc";
    }
}

对应字节码指令如下

0 ldc #12 <a>
2 astore_1
3 aload_1
4 astore_2
5 ldc #13 <szc>
7 astore_1
8 aload_2
9 areturn
10 astore_3
11 ldc #13 <szc>
13 astore_1
14 aload_3
15 athrow

异常表如下

字节码执行过程为:首先加载常量"a"到操作数栈栈顶,然后弹出栈顶存到1号局部变量中,再把1号局部变量加载到栈顶,然后弹出栈顶存到2号局部变量中(注意此时局部变量表里有两个s,分别为1号和2号局部变量)。而后加载常量szc到栈顶,弹出存到1号局部变量中,然后加载2号局部变量(a)到栈顶,返回栈顶。如果有异常发生,就跳到到第10行,弹出栈顶存到3号局部变量中,加载szc存到栈顶,弹出并存到1号局部变量,把3号局部变量(异常)加载到栈顶,抛出异常。因此这个方法返回值为a

操作数栈(左,栈顶为右)和局部变量表(右)的变换过程如下所示:

正常情况:a;this -> ; this a -> a;this a -> ; this a a -> szc; this a a -> ;this szc a -> a;this szc a

异常发生(假设第8行字节码报异常):...... -> a 异常对象的引用;this szc a -> a;this szc a 异常对象的引用 -> a szc;this szc a 异常对象的引用 -> a;this szc a 异常对象的引用 -> a 异常对象的引用;this szc a 异常对象的引用

同步控制指令

JVM支持两种同步结构:方法级的同步和方法内代码块的同步,这两种都是通过monitor来实现的。

方法级的同步

这是隐式同步,也就是无需通过字节码指令来实现的同步,它的实现在方法的调用和返回操作中。JVM可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志来判断一个方法是否为同步方法。

当调用同步方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否设置。如果设置了,执行线程将先持有同步锁,再执行方法,最后在方法完成(正常或非正常)时释放同步锁。在方法执行期间,执行线程持有同步锁,别的任何线程都无法再获得同一个锁。如果一个同步方法在执行过程中抛出了异常,并且在此方法内没有处理,那么这个同步方法持有的同步锁将会在异常抛出时自动释放

请看下面示例代码

private int i;
public synchronized void f4() {
    i++;
}

方法f4()的对应字节码如下

0 aload_0
1 dup
2 getfield #14 <ExceptionTest.i>
5 iconst_1
6 iadd
7 putfield #14 <ExceptionTest.i>
10 return

里面没有任何与同步有关的信息,但是在方法的描述信息中,却有同步的标识

方法内指定指令序列的同步

也就是常说的同步代码块,由monitorenter和monitorexit两条指令实现。

在JVM中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定。当监视器被持有后,对象则处于被锁定状态。

当一个线程进入同步代码块时,它使用monitorenter指令请求进入。如果当前锁对象的监视器计数器为0,则线程会被准许进入;若为1,则判断持有监视器的线程是否为自己,若是则进入,否则就阻塞等待,直到对象监视器计数器为0,才回进入下一轮请求,请求允许时才方可进入同步代码块。

当线程退出同步代码块时,需要使用monitorexit指令声明退出,监视器计数器-1,必要时还会捕获一些异常。

指令monitorenter和monitorexit在执行时,都需要在操作数栈栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是针对这个对象的监视器进行的。下图展示了监视器如何保护临界区代码不被多个线程访问

只有当线程4离开临界区后,线程1、2、3才可能进入。

请看以下示例代码

private Object object = new Object();
private void f5() {
    synchronized (object) {
        i = 2;
    }
}

方法f5()的对应字节码如下

0 aload_0
1 getfield #3 <ExceptionTest.object>
4 dup
5 astore_1
6 monitorenter
7 aload_0
8 iconst_2
9 putfield #16 <ExceptionTest.i>
12 aload_1
13 monitorexit
14 goto 22 (+8)
17 astore_2
18 aload_1
19 monitorexit
20 aload_2
21 athrow
22 return

先把0号局部变量加载到栈顶,然后弹出栈顶获取字段object并存到栈顶,复制一份,弹出栈顶存到1号局部变量里。然后针对栈顶元素(还是object)进行monitorenter请求,第7行到第14行是同步代码块的逻辑,在此不表。同步代码块执时如果没有异常发生,就执行第13行的monitorexit声明退出同步块,跳转到第22行返回。第17行到第21行为异常处理,虽然我们没有声明异常的抛出或处理,但JVM依旧为我们生成了这部分的代码。关于这段同步代码的异常表,我们可以看下

可见在同步块的执行和异常发生后的退出处理都可能发生异常,而且是任何异常。如果发生了异常,就执行第17行,把栈顶的异常对象弹出保存到2号局部变量,加载1号局部变量object到栈顶,monitorexit声明退出,然后加载2号局部变量(异常对象)到栈顶,抛出。

从异常表中可以看到,不管是同步代码块还是异常处理,只要发生了异常,都要进行异常处理,因为必须要把监视器计数器的值-1

结语

下一篇文章分析类的加载过程

猜你喜欢

转载自blog.csdn.net/qq_37475168/article/details/113833173