1.前言

在《【编译引擎】学习阅读Class文件结构(javap版)》中,我们看到了Code属性包含了JVM指令序列。

image-20210702135411052

在理解了Class文件结构之后,学习JVM指令有助于我们更进一步地理解Class文件结构。

然而,JVM指令是JVM规范另一个大篇幅知识点,我们应该如何高效学习它呢?

本文将通过上述代码,阐述JVM指令的执行过程(例如:局部变量表中的数据入栈、栈上数据存储回局部变量表等),为读者展示JVM指令的全貌以及学习方法。

2.JVM指令概览

2.1.本质-信息压缩

在学习具体的JVM之前,我们应该先来看看JVM指令的本质以及它存在的意义。

不知道您是否考虑过这样一个问题:“如何表达一个方法的实现呢?”

您可能会说:可以用代码来描述,如下伪码所示:

1
2
3
4
void func1() {
    int i = 1;
    print(i);
}

那么,我们把问题增加一些限制:“如何用最小的信息量表达一个方法的实现?”

聪明的读者可能会想到"代号法”,即为上述伪码取个特殊的代号(假设"int i = 1"的代号是"帅哥”,“print(i)“的代号是"美女”),那么上述伪码可以简化为:

1
2
3
4
void func1() {
    帅哥;
    美女;
}

我们继续把问题增加一些限制:“如何用计算机能理解的最小信息量表达一个方法的实现?”

什么是计算机能理解的信息?显然是字节。所以,我们可以约定"0A"就是"帅哥”,“0B"就是"美女”,如下:

1
2
3
4
void func1() {
    0A;
    0B;
}

同样的信息,在某些场景下(对于人)越大越好,在某些场景下(对于机器)越小越好。

更关键的是,二者能相互转换——这就好像三体中智子的高维折叠和低维展开。

这或许就是计算机的世界中,最令人着迷的地方。

image-20210702144922740

2.2.JVM指令格式

JVM指令的格式一般是这样的:

操作码+操作数

操作码为1个字节,在JVM规范中约定了这个字节表达的具体的JVM指令含义。

这是一个有趣的细节:1个字节是8位,所以JVM指令的操作码最多只有255个

操作数可以没有,也可能有多个。

2.3.JVM指令分类

JVM规范中的JVM指令非常多,我们可以根据指令的使用场景分类,本文仅列举实战中最常用的场景:

  • 加载与存储:比如将局部变量表的数据放到虚拟机栈中
  • 算数指令:比如i++
  • 类型转换指令:比如int转double
  • 对象创建/字段访问:比如创建一个类的实例,并且访问该实例的某个属性
  • 方法调用:比如访问一个类的静态方法
  • 异常处理:比如抛出一个异常
  • ……

2.4.真理都在规范中

当我们具备了前面的基本知识,最高效、最准确地学习JVM指令,就要查阅JVM规范了:

image-20210702150908956

3.实例解读

为了方便阅读,我们将javap输出的日志简化后展示如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
  public static void main(java.lang.String[]);
	……………………
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String start
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: invokestatic  #5                  // Method test:()V
        11: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
        14: ldc           #6                  // String end
        16: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        19: return
	……………………

  private static void test();
	……………………
         0: new           #7                  // class java/lang/IllegalArgumentException
         3: dup
         4: ldc           #8                  // String exception
         6: invokespecial #9                  // Method java/lang/IllegalArgumentException."<init>":(Ljava/lang/String;)V
         9: athrow
	……………………

3.1对象创建指令

image-20210702152821188

new是操作码,#7是操作数。

#7表示常量池中的常量,从javap的输出可知,#7表示IllegalArgumentException异常。

new表示创建了IllegalArgumentException异常对象。

创建了异常对象后,对象本身放到堆中,对象的内存地址会放到栈中。

image-20210702152324438

下一步就是dup,dup只有操作码,没有操作数。

dup就是将栈中前一个元素进行了复制。

image-20210702152609221

那么为什么要dup呢?

这就涉及到后面方法调用的指令了,当执行了invokespecial指令,就会将栈中的#0001出栈。

所以,一般创建了对象后,会紧跟着dup一下。

3.2.常量入栈指令

image-20210702152857941

ldc是操作码,#8是操作数。

根据javap的输出提示,#8表示的是字符串类型常量"exception”。

ldc负责将这个常量入栈:

image-20210702153046669

3.3.方法调用指令

image-20210702153131436

invokespecial是操作码,#9是操作数

#9表示IllegalArgumentException异常的构造函数

invokespecial表示了调用特定的函数,在这里调用的是IllegalArgumentException异常的构造函数,并且需要给构造函数输入一个字符串参数"exception”。

此时运行时数据区变成了这样:

image-20210702153403860

3.4.异常处理指令

image-20210702153452470

athrow只有操作码,表示抛出IllegalArgumentException异常,并结束了test方法。

至此,我们通过test()方法,就覆盖了JVM指令主要的4个场景:创建对象->常量入栈->调用方法->抛出异常。

3.5.main函数的JVM指令执行过程

image-20210702153713484

有了3.1~3.4的基础,我们再看main函数的执行就很容易举一反三了:

getstatic #2属于类/对象的属性获取场景,即将System.out放到栈中

invokevirtual #4类似invokestatic指令,也是调用方法,即调用System.out.println

此时,控制台打印了"start”。

执行到invokestatic #5,即执行了test()方法,会抛出异常,导致main函数不再往下执行后面的JVM指令。

因此,最终控制台打印了start后抛出IllegalArgumentException异常

4.总结

本文接着javap案例,进一步解读了javap的JVM指令部分,具体如下:

  • JVM指令的本质是信息压缩
  • JVM指令格式一般包含1个操作码+N个操作数
  • 可以根据场景对JVM指令分类,有助于高效学习JVM指令
  • 通过一个示例代码,解读了JVM指令的运行过程

5.参考资料

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html

《深入理解Java虚拟机:JVM高级特性与最佳实践》