JVM 基础 — Java 字节码

前言

我们通常表述的 JVM 通常有三种意思,JVM 是 Java virtual machine 即 java 虚拟机的缩写,也就是我们通常所指的 JVM。JVM还是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。因此 JVM 也是图灵完备的。当然 JVM 还可以表示一个虚拟机的实例。Java 一个非常重要的特性就是和平台无关,而 JVM 是实现这一点的关键。JVM 底层使用 C 进行编写,只要平台能执行 C 语言,这也就能启动 JVM 运行 Java 程序。JVM 通过编译 Java 语言生成字节码文件,字节码文件在通过 JVM 进行解释执行。因此只要运行在不同的平台上的JVM 能拿到字节码文件,就能解释执行出相同的结果。这就是 Java 可以 “一次编译,到处执行” 的原因。

当然并不是Java 语言是跨平台的语言,拿 C++ 举个例子,C++ 也是一门跨平台的语言,但是和 Java 语言不同的是,C++ 需要到不同的平台生成不同的文件,然后进行执行,也就是源码跨平台,而 Java 是二进制跨平台。

Java 字节码技术

概述

Java 代码通过编译生成 Java 字节码即 .class 文件,不同的 JVM 通过执行 .class 文件实现跨平台。Java bytecode 由但字节 (byte)的指令组成,理论上最多支持 256 个操作码(opencode)。实际上 Java 只用了 200 左右的操作码,还有一些操作码则保留给调试操作。操作码, 下面称为 指令 , 主要由 类型前缀 和 操作名称 两部分组成。

例如,’ i ‘ 前缀代表 ‘ integer ’,所以,’ iadd ‘ 很容易理解, 表示对整数执行加法运算。

根据指令性质,主要分为 4 个大类:

  1. 栈操作指令, 包括与局部变量交互的指令。
  2. 程序流程控制指令。
  3. 对象操作指令,包括方法调用指令。
  4. 算术运算以及类型转换指令。

获取字节码

可以使用 javap 命令来获取 class 文件中的字节码, javap 是 jdk 中内置的用于反编译字节码的工具

1
2
3
4
5
6
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode obj = new HelloByteCode();
System.out.println("hello");
}
}

现使用 javac 命令编译出 .class,在使用 javap -c 命令编译得到字节码文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
daiwei@daiweideMacBook-Pro test % javap -c HelloByteCode.class
Compiled from "HelloByteCode.java"
public class HelloByteCode {
public HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public static void main(java.lang.String[]);
Code:
0: new #2 // class HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc #5 // String hello
13: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: return
}

在上面拉出 java 的字节码清单之后,下面对其进行简单的解读

解析字节码清单

在上面的输出信息中,第二行也就是打印出来的字节码的第一行Compiled from "HelloByteCode.java"表示我们是从 HelloByteCode这个类反编译而来的字节码文件。4~8 行是一个构造函数,总所周知,如果我们在编写代码时不编写构造函数,就会生成一个默认的构造方法,这个就是默认的构造方法。而这个构造方法里也只有一条指令就是 invokespecial 这个是调用父类Object 对象的构造方法即 super() 方法。

如果想要看到更多的信息则需要使用javap -c -verbose 命令输出更多信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
daiwei@daiweideMacBook-Pro test % javap -c -verbose HelloByteCode.class
Classfile /Users/daiwei/study/java-course/JVM/test/HelloByteCode.class
Last modified 2021-1-7; size 442 bytes
MD5 checksum 8e2a795fb147ef48ba63f55886005f32
Compiled from "HelloByteCode.java"
public class HelloByteCode
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#16 // java/lang/Object."<init>":()V
#2 = Class #17 // HelloByteCode
#3 = Methodref #2.#16 // HelloByteCode."<init>":()V
#4 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream;
#5 = String #20 // hello
#6 = Methodref #21.#22 // java/io/PrintStream.println:(Ljava/lang/String;)V
#7 = Class #23 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 main
#13 = Utf8 ([Ljava/lang/String;)V
#14 = Utf8 SourceFile
#15 = Utf8 HelloByteCode.java
#16 = NameAndType #8:#9 // "<init>":()V
#17 = Utf8 HelloByteCode
#18 = Class #24 // java/lang/System
#19 = NameAndType #25:#26 // out:Ljava/io/PrintStream;
#20 = Utf8 hello
#21 = Class #27 // java/io/PrintStream
#22 = NameAndType #28:#29 // println:(Ljava/lang/String;)V
#23 = Utf8 java/lang/Object
#24 = Utf8 java/lang/System
#25 = Utf8 out
#26 = Utf8 Ljava/io/PrintStream;
#27 = Utf8 java/io/PrintStream
#28 = Utf8 println
#29 = Utf8 (Ljava/lang/String;)V
{
public HelloByteCode();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc #5 // String hello
13: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: return
LineNumberTable:
line 3: 0
line 4: 8
line 5: 16
}
SourceFile: "HelloByteCode.java"

这次的输出比之前一次多了不少的信息,首先是 10~39 行的常量池信息。这里记录了代码中用到的常量。40~68行是之前代码部分的字节码,这一次多了一些如 descriptor, flags 的信息。descriptor 是方法的描述符,flags 则是方法的访问描述符,这是个 public static 方法。第45行的 stack=1, locals=1, args_size=1 表示方法栈的深度为1, 本地局部变量表大小为1 ,方法的入参为1。这里有个比较有意思的地方,就是无参构造的 args_size 不为0 而是 1 ,这是因为对于构造方法需要有一个引用地址,也就是 this 的引用地址,这个可以类比反射里面Method#invoke(Object obj, Object... args); 第一个参数是被调用对象一样。

线程栈和字节码执行模型

JVM 是基于栈的计算机模型,每一个线程都有自己的线程栈(JVM stack)和用于存储的栈帧(Frame),每调用一个方法JVM都会自动创建一个栈帧,栈帧中包括操作数栈局部变量数组和一个 class 引用构成,class 引用指向当前方法在运行时常量池中对应的class。

局部变量数组 也成为 局部方法表(LocalVariableTable),其中包括方法的参数和局部变量。局部变量数组长度在编译时就已经确定。和局部变量和方法形参有关,但是具体长度还要看具体每个字段占用的长度。操作数栈(Operand Stack)是一个 LIFO 的结构栈,通过于压入弹出进行数据操作,其大小在编译时确定。

一些操作码/指令可以将值压入操作数栈,还有一些操作码/指令从操作数栈获取操作数,并进行计算,然后再压入操作数栈。操作数栈还用于接受调用其他方法的返回值。

方法体中的字节码解析

在前面的几个例子当中,字节码看起来问题都不大,但是看方法体中的字节码的编号有点看不懂,也就是下面的一些字节码

1
2
3
4
5
0: new           #2                  // class HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return

前面的一排数字是指令集数组中的索引 new 操作占用一个槽位,并且 new 指令需要消耗两个操作数,所以 dup 指令是从3开始的,dup 指令不需要操作数,所以只占一个槽位。位于4号槽位中的 invokespecial 指令同样需要消耗两个操作数,所以 astore_1 从7号槽位开始。astore_1意思是将栈顶元素存入局部变量表1号槽位,不需要操作数,所以 return 位于8号槽位。

通过操作码/指令对照表并换算十六进制(HEX)表示形式之后。

也就是我们通过十六进制打开.class 文件所能看到的数据片段了。

对象初始化指令

new 是Java 的关键字,但是在字节码中,也有一个指令 new 但是整个 new 的逻辑,可以分为以下的字节码

1
2
3
0: new           #2                  // class HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V

new 指令和 invokespecial 指令在一起,那么这段字节码一定是在实例一个对象。那为什么实例一个对象不是一个指令而是三个指令呢?

  • new 指令只是创建对象,而并没有调用构造函数。
  • dup 复制栈顶元素。这里为什么要复制栈顶元素呢?因为构造函数不会返回实例对象引用,所以没用dup指令,操作数栈是空的,初始化之后就会有问题。
  • invokespecial 字面意思,调用特殊的方法,在这里就是调用构造函数。

完成了上面的代码后一般会执行的指令会有下面几种:

  • astore {N} 或者 astore_{N} 给局部变量赋值,其中{N}代表局部变量表中的位置。
  • putfield 将值赋给实例。
  • putstatic 将实例赋给静态字段。

这个时候如果没有那个 dup 的引用的话这里就没有就没法进行出栈赋值操作。

在调用构造函数之前,还会执行一个类似 的方法。但是 并不能被直接调用,而是由 newgetstaticputstaticinvokestatic 触发。

也就是说,在实例化一个对象,访问静态字段或一些静态方法,就会触发这个类的静态初始化方法。

栈内存操作指令

有很多指令可以操作方法栈。压入栈数据和从数据栈弹出数据等一些基础的操作,有 dup 复制栈顶元素,和 pop 弹出栈顶元素的指令。还有一些复杂点的指令例如:

  • swap :交换两个栈顶元素。
  • dup_x1:复制栈顶的值, 并将复制的值插入到最上面2个值的下方。
  • dup2_x1:制栈顶 1个64位/或2个32位的值, 并将复制的值按照原始顺序,插入原始值下面一个32位值的下方。 配合使用可用于交换两个64位数据的位置。

数据类型分组(1代表32 位元素, 2代表64位元素)

实际类型 JVM 计算类型 类型分组
boolean int 1
byte int 1
char int 1
short int 1
int int 1
float float 1
refrence refrence 1
retrunAddress retrunAddress 1
long long 2
double double 2

⚠️ 理解这些字节码的诀窍在于

给局部变量赋值时,需要使用相应的指令来进行 store ,如 astore_1store 类的指令都会删除栈顶值。 相应的 load 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。

算术运算指令与类型转换指令

Java 字节码有很多的指令可以执行算术运算。对于所有数值类型(int, long, double, float)都有各自的加、减、乘、除、取反指令。当然boolean、byte、short、char 等都是当 int 类型处理。

在java 是一个强类型的语言,如果类型不匹配需要进行类型转换,如果int需要转换为 double 会调用 i2d 指令进行类型转换。

唯一一个不需要将数值load到操作数栈的指令是 iinc,他可以直接对 LocalVariableTable 中的数值进行运算。其他的操作均使用操作数栈进行运算。

操作码对照表

方法调用指令

在前面已经到了 invokespceial 调用构造方法,那调用其他不同类型的方法用什么指令呢?

  • invokestatic 调用静态方法,也是几个调用指令当中最快的。
  • invokespeical 可以用来调用构造方法,同时这个指令也可以用来调用 private 方法 和可见的 super 中的方法。
  • invokevirtual 可以调用目标对象的实例方法。
  • invokeinterface 用于调用目标接口方法,可以在运行时搜索一个实现这个接口的对象,并找出合适的方法进行调用。
  • invokedynamic jdk 1.7 新加入的一个虚拟机指令,前四条指令的分派逻辑在虚拟机内部是固定的,invokedynamic 它允许应用代码来确定具体执行的是那个方法,从而到达对动态语言的支持。Lambda 表达式基于 invokedynamic 实现。

总结

这个部分简单介绍了Java 字节码,从开篇介绍怎么拉取一个 Java 代码的字节码开始,逐步复习了jvm 方法栈的栈帧,也就是 字节码的执行环境,这个部分由一个操作数栈,一个本地局部变量表和一个class 引用构成。通过分析一些常见的字节码例如构造函数和一些简单的方法体,熟悉了解了一些基本的操作码,例如 dup、pop、istore、iload 等指令。其中理解的诀窍在于 给局部变量赋值时,需要使用相应的指令来进行 store ,如 astore_1store 类的指令都会删除栈顶值。 相应的 load 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。当然在这些基本的操作指令中还包括一些类型转换的指令和方法的调用的指令,方法调用到后面会有专门梳理。

学习资料

  • Java 字节码技术:不积细流,无以成江河。