JAVA 字节码初探

2022-05-12

Java Virtual Machine

JVM 是一个虚拟机,它能够让计算机运行 Java 程序或者使用其他语言编写的程序,这些程序能够被编译成 Java 字节码。JVM 由一个规范详细描述,该规范正描述了 JVM 实现中所需的内容,有了这个规范,可以确保 Java 程序在不同实现之间的互操作性,这样使用 Java 开发工具包(JDK)的程序作者就不用担心底层硬件平台的特性。

JVM 参考实现由 OpenJDK 项目作为开放源代码开发,包括一个名为 HotSpotJIT compiler,Oracle 提供商业支持的 Java 版本是基于 OpenJDK runtime。Eclipse OpenJ9 是 OpenJDK 的另一个开源 JVM。

JVM Languages

Java / Kotlin / Groovy / Scala / Clojure …

Java bytecode

举个栗子

1
2
// Foo.java
public class Foo {}

下文都将基于该类的字节码做分析和扩展,使用 javac 将 java 文件编译成 class 文件:javac Foo.java

Foo.class 是二进制文件,我们查看二进制内容(vscode 安装 hexdump Hex Editor 扩展):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 	
00000000: CA FE BA BE 00 00 00 34 00 0D 0A 00 03 00 0A 07 J~:>...4........
00000010: 00 0B 07 00 0C 01 00 06 3C 69 6E 69 74 3E 01 00 ........<init>..
00000020: 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 .()V...Code...Li
00000030: 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65 01 00 0A neNumberTable...
00000040: 53 6F 75 72 63 65 46 69 6C 65 01 00 08 46 6F 6F SourceFile...Foo
00000050: 2E 6A 61 76 61 0C 00 04 00 05 01 00 03 46 6F 6F .java........Foo
00000060: 01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A ...java/lang/Obj
00000070: 65 63 74 00 21 00 02 00 03 00 00 00 00 00 01 00 ect.!...........
00000080: 01 00 04 00 05 00 01 00 06 00 00 00 1D 00 01 00 ................
00000090: 01 00 00 00 05 2A B7 00 01 B1 00 00 00 01 00 07 .....*7..1......
000000a0: 00 00 00 06 00 01 00 00 00 01 00 01 00 08 00 00 ................
000000b0: 00 02 00 09 ....

我们对照 class 文件的结构:

A class file consists of a single ClassFile structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];

文件开头的4字节是魔数(magic number),何为魔数,魔数是一个用于标识文件格式的固定数值或文本值,.class 文件的魔数固定为 CA FE BA BE,表示该文件为 class 格式的文件;

很多格式的文件文件头都有魔数,例如 png 格式文件的魔数为 89 50 4E 47 0D 0A 1A 0A,doc 格式文件的魔数为 D0 CF 11 E0 A1 B1 1A E1,大家可以自己试一下。

再向后偏移2字节,表示的是 Java 的 minor version,再向后偏移2字节,表示的是 major version。所以这里的 minor version 是 0x0000,major version 是 0x0034,0x34转换为十进制为52,根据下面的 Java major versions 对照表,我们可以知道 Foo.class 是使用 JDK8 版本编译的。

class file format major versions

Java SE Released Major Supported majors
1.0.2 May 1996 45 45
1.1 February 1997 45 45
1.2 December 1998 46 45 .. 46
1.3 May 2000 47 45 .. 47
1.4 February 2002 48 45 .. 48
5.0 September 2004 48 45 .. 49
6 December 2006 50 45 .. 50
7 July 2011 51 45 .. 51
8 March 2014 52 45 .. 52
9 September 2017 53 45 .. 53
10 March 2018 54 45 .. 54
11 September 2018 55 45 .. 55
12 March 2019 56 45 .. 56
13 September 2019 57 45 .. 57
14 March 2020 58 45 .. 58
15 September 2020 59 45 .. 59
16 March 2021 60 45 .. 60
17 September 2021 61 45 .. 61

JVM 在加载 class 文件的时候会对其进行校验(verification),如果不是 class 类型的文件,会直接报错,如果校验通过,才会进行后续的 preparation,resolution 等流程。

jvm.drawio

以上是对 class 二进制文件的简单分析,直接看二进制文件太吃力了,所以我们要借助 javap 来输出可视化的字节码内容,运行 javap -v Foo.class

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
Classfile /E:/IdeaProjects/BytecodeDemo/src/Foo.class
Last modified 2022-4-21; size 234 bytes
MD5 checksum 438034a2632f6d7a0b7e9ef06674f44d
Compiled from "Foo.java"
public class Foo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // Foo
#3 = Class #15 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 LFoo;
#11 = Utf8 SourceFile
#12 = Utf8 Foo.java
#13 = NameAndType #4:#5 // "<init>":()V
#14 = Utf8 Foo
#15 = Utf8 java/lang/Object
{
public Foo();
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
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LFoo;
}
SourceFile: "Foo.java"

flags 即 access_flags,表示该类或接口的访问权限和属性,这里是 ACC_PUBLIC, ACC_SUPER,

Class access and property modifiers

Flag Name Value Interpretation
ACC_PUBLIC 0x0001 Declared public; may be accessed from outside its package.
ACC_FINAL 0x0010 Declared final; no subclasses allowed.
ACC_SUPER 0x0020 Treat superclass methods specially when invoked by the invokespecial instruction.
ACC_INTERFACE 0x0200 Is an interface, not a class.
ACC_ABSTRACT 0x0400 Declared abstract; must not be instantiated.
ACC_SYNTHETIC 0x1000 Declared synthetic; not present in the source code.
ACC_ANNOTATION 0x2000 Declared as an annotation interface.
ACC_ENUM 0x4000 Declared as an enum class.
ACC_MODULE 0x8000 Is a module, not a class or interface. (jdk9 add, e.g. module-info.java)

ACC_PUBLIC 很好理解,表示该类是 public 的访问权限,ACC_SUPER 是什么意思呢?官方手册上的意思是“当被invokespecial 指令调用时,会特别处理超类方法”。没听懂… 查阅相关资料了解到,这个可能和历史遗留问题有关,在 JDK1.1 之前,JVM 使用 invokenonvirtual 指令调用父类方法,而现在使用的是 invokespecial ,改动的原因是因为 invokenonvirtual 不会进行虚函数查找,即总是静态绑定的。在 class 文件中使用 CONSTANT_Methodref_info 来表示一个方法,CONSTANT_Methodref_info 中有一个指向类的成员,invokenonvirtual 会直接使用 CONSTANT_Methodref_info 中的类进行方法调用,而不去进行虚函数查找,因此,需要在编译器在编译时就绑定到最近的父类,而JDK1.1以后,JVM 会忽略 CONSTANT_Methodref_info 中的class,直接查找最近的超类方法。·

CONSTANT_Methodref_info(常量池中的项命名通常以 _info 结尾)的数据结构:

1
2
3
u1 tag;
u2 class_index;//指向的是CONSTANT_Class_info,表示类信息
u2 name_and_type_index;//指向的是CONSTANT_NameAndType_info,表示当前字段或者方法的名字和类型信息

举个栗子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// A.java
public class A {
public void call() {
println("A#call()");
}
}

// B.java
public class B extends A {

}

// C.java
public class C extends B {
@Override
public void call() {
super.call();
println("C#call()");
}
}

编译运行,很明显 C 中调用的是 A#call(),

如果在 JDK1.1 之前,继续更改 B 的代码,实现 call(),重新编译,但是不重新编译 C,按照 invokenonvirtual 的调用方式,C 依然调用的是 A#call();但是在 JDK1.1 以后的版本,使用了 invokespecial 指令,会按照类的继承层级,找到最近的一个父类的方法调用,即 C 调用的是 B#call(),这种结果才是符合预期的。

所以,现在的编译器都会生成 ACC_SUPER 来支持以正确的父类调用。

我们接着分析 Foo.class,

Constant pool 即为常量池,包含 Java 中的常量,如 fianl 修饰的常量,字符串等,和符号引用。

符号引用包含:

  • 类或接口的全限定名称(Full Qualified Name)
  • 方法的名称和描述符 (Descriptor)
  • 字段的名称和描述符

接着往下看,哎,我们的 Foo.java 是一个空类,为什么反编译出来会有方法呢?因为这是编译器为我们生成的一个默认的无参构造方法。因为如果类中没有显式声明构造方法,则编译器会插入默认的无参构造方法,如果类中有显式声明了构造方法,则编译器只会在没有显示调用父类构造方法的构造方法最开始出插入对父类构造方法的调用。那么问题来了,为什么如果没有构造方法,要插入一个无参构造方法呢?都是为了类的实例化:

  1. 类默认的实例化,new Constructor()
  2. 父类的实例化
  3. 通过反射实例化类, Class.newInstance()

关于父类的实例化,这里举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
// A.java
public class A {
// empty class
}

// B.java
public class B extends A {
// constructor
public B(int value) {
println("B value: " + value);
}
}

我们知道子类的实例化是在父类的实例化之后,所以假设如果调用了 new B(0);,实际上是会先实例化 A,再实例化 B,可是 A 中没有构造方法,没法实例化,所以会在编译期在 A 中插入一个默认的无参构造方法。其实上述的代码等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// A.java
public class A {
public A() {
super();
}
}

// B.java
public class B extends A {
public B(int value) {
super();
println("B value: " + value);
}
}

我们再回过头来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
public Foo();
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
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LFoo;

这其实就是编译器自动插入的一个默认的无参构造方法,即:

1
2
3
public Foo() {
super();
}

我们来分析一下这段字节码:

descriptor: ()V

descriptor 表示的是方法的描述符,即对方法的参数和返回值进行描述,方法描述符的格式为:

MethodDescriptor :
( {ParameterDescriptor} ) ReturnDescriptor

ParameterDescriptor :
FieldType

ReturnDescriptor :
FieldType
VoidDescriptor

VoidDescriptor :
V

括号里面描述的是方法的参数类型,括号后面表示方法的返回值类型。那么 ()V 则表示该方法是无参无返回值的。

Interpretation of field descriptors

FieldType term Type Interpretation
B byte signed byte
C char Unicode character code point in the Basic Multilingual Plane, encoded with UTF-16
D double double-precision floating-point value
F float single-precision floating-point value
I int integer
J long long integer
L ClassName ; reference an instance of class ClassName
S short signed short
Z boolean true or false
[ reference one array dimension

上面表格列出了各个类型的属性描述符,我们可以举一反三,例如 int onStartCommand(Intent intent, int flags, int startId) 方法描述符为 (Landroid/content/Intent;II)I

继续往下分析:

1
stack=1, locals=1, args_size=1

stack 表示操作数栈(Operand Stacks)的最大深度。这里顺便讲一下 JVM 中的栈,JVM 中的栈描述的是每个线程 JAVA 方法执行的内存模型,即每创建一个线程,JVM 就会为线程创建一个栈,是线程私有的,生命周期是跟随线程的生命周期的,线程结束栈内存也随之释放,所以对于栈来说不存在垃圾回收的问题。基本数据类型和对象的引用和实例方法都是在栈内存中分配的。栈里面存放的叫栈帧(Stack Frame),当一个方法开始执行的时候,会创建一个栈帧,栈帧中存储局部变量表,操作数栈,动态链接,方法返回地址等信息。而操作数栈用于保存计算过程中的中间结果,同时作为计算过程中的临时存储空间。操作数栈的最大深度在编译时就已经明确,即上面的 stack=1,为什么是1,下面分析。

The following exceptional conditions are associated with Java Virtual Machine stacks:

  • If the computation in a thread requires a larger Java Virtual Machine stack than is permitted, the Java Virtual Machine throws a StackOverflowError.
  • If Java Virtual Machine stacks can be dynamically expanded, and expansion is attempted but insufficient memory can be made available to effect the expansion, or if insufficient memory can be made available to create the initial Java Virtual Machine stack for a new thread, the Java Virtual Machine throws an OutOfMemoryError.

locals 表示局部变量所需的存储空间,单位为 Slot。Slot 是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小,即32位。在局部变量表里,32位以内的类型占用一个 Slot,long 和 double 64位类型占用两个 Slot。JVM 会为每个Slot分配一个索引,通过索引就可以访问到局部变量表中指定的局部变量值。当一个构造方法或实例方法被调用时,会依次将 this、方法参数、局部变量按照顺序存放在对应的Slot中。Slot 是可以复用的,所以 locals 的大小并不等于所有局部变量所占 Slot 的总和。这里 locals=1 是因为这是一个编译器创建的默认的无参构造方法,本地变量表中只有一个隐藏参数 this

args_size 表示该方法的参数个数。这里args_size=1,即 this

举一个 Slot 复用的栗子:

1
2
public void slot(long l) {
}

反编译后得到 stack=0, locals=3, args_size=2 ,locals=3:this 占一个 Slot,long 类型占两个 Slot;args_size 同理,不再赘述。

1
2
3
4
5
public void slot(long l) {
{
int i = 1;
}
}

反编译后得到 stack=1, locals=4, args_size=2 ,locals=4:this 占一个 Slot,long 类型占两个 Slot,int 占一个Slot。

1
2
3
4
5
6
7
8
public void slot(long l) {
{
int i = 1;
}
{
double d = 1d;
}
}

反编译后得到 stack=2, locals=5, args_size=2,locals=5:this 占一个 Slot,long 类型占两个 Slot,int 占一个 Slot,由于 int i 和 double d 两个局部变量的作用域不同,生命周期没有交集,所以 double 复用了 int 的 Slot,由于 double 占两个 Slot,所以 locals=5。

接着往下是具体的字节码指令:

1
2
3
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

每个指令的格式如下:

1
<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]

index 是方法的字节码数组中指令的操作码的索引;opcode 为操作码,占一个字节;operand 为操作数;comment 为注释。

0: aload_0 :表示将第一个引用类型的本地变量推到栈顶。如果方法是静态方法,aload_0 表示方法的第一个参数,如果方式是实例方法或构造方法,aload_0 表示 this。因为该方法是构造方法,所以这里指的是 this,0表示 aload_0 指令的索引是0。

invokespecial :其格式为

invokespecial
indexbyte1
indexbyte2

前文讲关于 ACC_SUPER 的时候有提到过,这里的含义是调用父类的构造方法,即 super()。invokespecial 指令紧跟 aload_0 其后,所以索引是1,invokespecial 后面跟的是 #1,我们查看 Constant pool 中 #1 是方法的引用(Methodref)。 (indexbyte1 << 8) | indexbyte2 的值为常量池中对应的索引。

return :表示从当前方法返回 void 。这里的 index 为4是因为 invokespecial 指令后面跟着两字节的方法引用。

完整的字节码指令集参见:Chapter 6. The Java Virtual Machine Instruction Set

到这里,回过头看一下上面的问题,为什么 stack=1 就一目了然了,因为该方法只有 aload_0 将 this push 到了栈顶。

再往下看 LocalVariableTable (本地变量表):

1
2
3
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LFoo;

Start:表示该变量基于字节码数组起始可见位置。这里表示从0开始可见。

Length:表示基于 Start 可见的行数。这里表示该变量的可见范围为 0~4,我们看上面的操作码指令 index,也是从0至4,表示 this 在该方法中全部可见,即在该方法中都可以使用 this 变量。

Slot:表示该变量在本地变量表数组中的索引。由于该方法是默认的无参构造方法,所以唯一的隐藏参数是 this,即从0开始,占用一个 Slot 单位。

Name:java 代码里面变量的名称。

Signature:变量的签名。

如果我们使用 javac 编译 .java 文件的时候,指定了 -g:none ,则编译出来的 class 文件中不会包含 LocalVariableTable 信息,意味着这个方法的所有参数名称都丢失了,变成类似 var0,var1 这样的参数名称。编译时指定 -g:vars 则会带上 LocalVariableTable 信息。

举个栗子:

1
2
3
4
public class LocalVar {
public void formatTime(long timestamp, String format) {
}
}

我们使用 javac -g:vars LocalVar.java

1
2
3
4
5
6
7
8
9
10
11
12
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

public class LocalVar {
public LocalVar() {
}

public void formatTime(long timestamp, String format) {
}
}

使用 javac -g:none LocalVar.java :

1
2
3
4
5
6
7
8
9
10
11
12
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

public class LocalVar {
public LocalVar() {
}

public void formatTime(long var1, String var3) {
}
}

以上就是关于字节码的一些简单的分析。下面我们看两个栗子:

栗1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface Callback {
void callback();
}

public class CallbackDemo {
public void fun1(Callback callback) {
callback.callback();
}

public void fun2() {

int value = 10;

fun1(new Callback() {
@Override
public void callback() {
System.out.println(value);
}
});
}
}

栗2:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 最后输出的 i 是多少
*/
public class Add {
public static void main(String[] args) {
int i = 0;
for (int j = 0; j < 10; j++) {
i = i++;
}
System.out.println(i);
}
}

References

https://en.wikipedia.org/wiki/Java_class_file

https://en.wikipedia.org/wiki/Java_class_file#Magic_Number

https://docs.oracle.com/javase/specs/jvms/se17/html/jvms-4.html

https://docs.oracle.com/javase/specs/jvms/se17/html/jvms-2.html#jvms-2.5.2

https://docs.oracle.com/javase/specs/jvms/se17/html/jvms-6.html#jvms-6.5.invokespecial

https://en.wikipedia.org/wiki/List_of_Java_bytecode_instructions

https://docs.oracle.com/javase/tutorial/java/javaOO/accesscontrol.html

https://blog.csdn.net/dshf_1/article/details/105787923

https://github.com/barry32/Pluto/wiki/%E6%B5%85%E6%9E%90JVM%E7%AC%AC%E4%BA%8C%E7%AF%87:-class%E6%96%87%E4%BB%B6%E6%A0%BC%E5%BC%8F

https://zhuanlan.zhihu.com/p/457993330

https://blog.csdn.net/weixin_35520787/article/details/114617301

https://blog.csdn.net/weixin_44364444/article/details/110248863

https://zhuanlan.zhihu.com/p/77663680

https://www.cnblogs.com/newber/p/15319657.html

https://juejin.cn/post/6868916019746996237