HotSpot JVM源码分析 - 方法调用与解释执行(1):流程综述

本系列文章基于 OpenJDK 9,聚焦 x86 平台。

为避免陷入代码和细节的汪洋大海中,本文尽量只做文字描述,并忽略一些细枝末节,目的是对整个流程有个宏观上的把握。

背景

先看一段代码:

1
2
3
4
5
6
7
8
public void sendUserVerify(String to, String subject, String content) throws RuntimeException {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject(subject);
message.setText(content);
mailSender.send(message);
}

编译成字节码文件,方法体对应的可读字节码片段为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 0: new             #3
3: dup
4: invokespecial #4
7: astore 4
9: aload 4
11: aload_0
12: getfield #5
15: invokevirtual #6
18: aload 4
20: aload_1
21: invokevirtual #7
24: aload 4
26: aload_2
27: invokevirtual #8
30: aload 4
32: aload_3
33: invokevirtual #9
36: aload_0
37: getfield #2
40: aload 4
42: invokeinterface #10, 2
47: return

站在JVM的角度,想要执行这个字节码文件的这个方法,该如何处理以执行呢?

正文

首先,需要 加载 这个文件,并进行一系列 验证。此时文件内容已经在内存中,开始分析这个文件。

字节码文件格式

从头开始,依次看到魔数,版本号,常量池,基类,接口列表,字段表等等,直到方法表。

在方法表的第N项,看到了这个方法。通过分析这个方法条目,可以知道方法的访问标志、名字、签名信息、属性表等。

想要执行这个方法,那必然要关心方法体。在方法条目的属性表里,找到一个名为 Code 的属性,在这个属性条目里,看到了上面的字节码片段。

终于找到你。

操作数到符号引用

如前所列,一条接一条的字节码呈现在眼前。

选择一条字节码来解析,比如 15: invokevirtual #6,其对应的二进制代码为 B6 00 06

第1个字节 B6 为字节码指令,查询JVM源码可知对应字节码指令为 _invokevirtual = 182, // 0xb6;后续2个字节为操作数。由于尚未解析过,其代表常量池条目索引。查询常量池,可知该条目可读字节码片段为 #6 = Methodref #3.#50,二进制代码为 0A 00 03 00 32,表示它一个 Methodref 类型的常量池条目,其字段#3.#50也是常量池条目索引,又可以依此分析下去。

最后,知道了字节码指令为 _invokevirtual,调用目标是一个方法,得到了这个方法所在类名、方法名称、方法签名。这些信息都是字符串形式的,即 符号引用

符号引用到直接引用

光有符号引用, 是没法执行的,因为需要的是方法代码,一个方法地址。

还好,已经知道了这个目标方法的各种信息。通过方法所在类名,可以找到这个类位于元空间(方法区)的类元数据。所谓类元数据就是已经加载好的类字节码文件在内存中的表示,里面必然有这个类的字段表,方法表等。这样再根据方法名、方法签名,就能在这个类元数据的方法表里找到对应的方法数据。这个方法数据地址,即 直接引用

常量池缓存

我们肯定不希望每次执行这个字节码,都像上面解析一遍,所以要把这个方法引用缓存起来。

存在哪呢?

既然符号引用在常量池里,那就开辟一个 常量池缓存,里面为 常量池缓存条目,用以保存直接引用信息。

存哪些信息呢?

似乎,可以把方法地址存起来,这样下次就能直接找到这个方法了。

但是,方法情况可能各有不同。有些方法不能再被重写,比如已经是 final 的。这种好办,直接记下方法引用就好,反正它不会再变。可还有些方法可以被重写,比如 virtual 的,或者 interface 的。我们找到的只是通过静态类型查到的方法数据,子类可能已经重写了这个方法。实际调用对象如果是子类的实例,调用的方法应该是子类重写后的方法,也即动态类型的方法数据。

静态类型:变量的声明类型

动态类型:变量的实际类型

比如:Animal a = new Dog(),Dog继承或实现了Animal, a 的静态类型是Animal,动态类型是Dog。

好在继承一个类(实现一个接口),同一个方法在基类(接口)中的类虚表索引(接口虚表索引)不会变。那么对于 virtualinterface 的方法,我们记下类虚表索引或接口虚表索引就好,然后运行时在动态类型的类元数据里,在类虚表或接口虚表里根据索引找到重写的方法数据。对于 virtual 的方法,这样是可行的,但对于 interface 的方法,还要多记录接口引用,因为继承的基类只有一个,但实现的接口可能有多个,所以运行时还需要通过接口引用找到对应的接口虚表。

字节码重写

解析完成,有了常量池缓存,那么怎么跟原字节码指令关联起来使用呢?

前面提到,指令操作数解析前为常量池索引,解析后,就用不到了,那么可以重写这个操作数,改为常量池缓存索引。

那问题来了,字节码执行的时候,怎么知道这个操作数是常量池索引还是常量池缓存索引?

索性,字节码文件加载进来后,就对 字节码重写,使操作数变为常量池缓存索引,并开辟好常量池缓存。

此时,指令操作数为常量池缓存索引。因为尚未解析,还是需要常量池索引。那就在常量池缓存条目中增加一个字段,低16位保存常量池索引。

字节码执行时,通过操作数直接找到常量池缓存条目。如果解析过,就直接使用;如果没解析过,就根据这个字段的低16位,获得常量池索引,进行上面的引用解析流程。

怎么知道是否解析过?

解析过后,在这个字段的高16位保存下对应的字节码指令。执行时,判断其是否等于字节码指令即可,因为没解析过时,高16位未赋值,必然不等。

字节码执行

前面说的操作数解析过程,实际上就是invoke类型字节码的部分执行步骤。

对于一个字节码,实际是怎么执行的呢?

最简单的,采用switch case,是哪种字节码指令就执行哪些操作。这种指令有200多个,根据经验,还可以采用查表方式,一种字节码指令对应一个指令执行函数。

JVM有两种解释器:CPP解释器模板解释器。前者采用了switch case方式,后者采用了查表方式。

因为基本只使用模板解释器,所以只关注这个。

既然是查表,key就是字节码指令,value是指令执行函数。这个执行函数执行极度频繁,必须尽可能高效,所以我们自己决定它的每行代码,确定执行步骤。JMV面向众多平台,我们就用汇编命令形式描述执行步骤,然后不同平台的汇编器将这些汇编命令翻译成各自平台的机器码。这些机器码,构成执行函数。

字节码到机器码

模板,就是这些汇编命令形式函数的集合。模板表,key是字节码指令,value是对应的模板。

模板解释器初始化时,模板解释器生成器依次调用各个字节码指令对应的模板的生成函数,生成平台相关的执行函数的机器码。

我们分配一块内存,作为一个 CodeBuffer,把它传给汇编器。上面提到的汇编命令,其实是汇编器类相应名称的函数,生成的过程,就是执行这些汇编命令形式函数的过程。每执行完一个汇编命令形式函数,就向CodeBuffer写入一段对应的机器码,全部执行完成,模板生成函数也就执行完了,就得到了一块全是机器码的内存块。这块机器码,构成执行函数,称作 Codelet,Codelet的入口地址,称作 entry_point

模板解释器初始化完成后,每个字节码指令与其指令执行函数entry的映射关系也构建完成,即 字节码指令派发表

需要执行字节码指令时,找到其指令执行函数entry,因为执行函数本身就是由机器码构成,跳转过去执行即可。

字节码派发

上面只是说了一条字节码的执行,一个方法有很多字节码,怎么依次执行这些字节码呢?

简单说,可以使用一个循环,执行完一条就找到下一条。

模板解释器采用了不同的做法。它在生成上面所述执行函数的字节码之后,还会插入一段派发代码。

当前执行的字节码指令地址保存在 _bcp_register,即PC,程序计数器。字节码长度包括字节码指令长度 + 指令操作数长度。当前字节码指令地址 + 当前字节码长度就下一条字节码指令地址。例如,看前面字节码片段,15:invokevirtual #6,15即当前字节码指令地址偏移,invokevirtual字节码长度为 1字节指令长度 + 2字节操作数长度 = 3字节,15 + 3 = 18,即下一条字节码指令地址偏移,对应 18: aload 4

找到了下一条字节码指令地址偏移,加上方法字节码指令起始地址,即为下一条字节码指令地址。由此可获取到下一条字节码指令,从字节码指令派发表找到其对应的执行函数entry,跳转过去,就开始执行下一条字节码指令。

插入的派发代码就是执行这个过程。

方法调用

前面所说,都是在字节码层面。一段字节码构成一个方法,那么方法是如何开始执行的?毕竟,方法不开始执行,又怎么会执行到字节码指令呢。

JVM中,方法分很多种,有些是普通Java方法,有些是native方法,有些是预先高效实现好了的,比如数学方法sin/cos等。这些方法,执行的环境差别很大,所以需要抽象出一层,来封装这些不同的操作,用以构建不同的执行环境。

在JVM初始化时,用汇编器以汇编命令形式,生成一段机器码,类似字节码指令的执行函数,称作 方法例程。在解释器上,有一个映射表,存储着这些例程入口地址 entry_point。在方法链接阶段,找到这种方法的例程入口,设置到方法数据上。

以普通Java方法为例,其例程需要分配和初始化局部变量,生成和维护Java栈帧,如果是 synchronized,还会加锁。之后找到方法的代码入口,即第一条字节码指令地址,设置到 _bcp_register,开始执行,类似前面的字节码派发。

一条字节码指令执行完成后,就走前面的字节码派发过程,执行下一条字节码指令。

方法的最后一条字节码指令,为 return 类指令。其工作大体是方法例程的反操作,比如,加过锁就解锁。最重要的就是清除该方法的栈帧,恢复栈到调用前状态。

无论是从JVM C函数执行一个Java方法,还是invoke指令这种从Java方法调用另一个Java方法,都先把被调用方法的参数入栈,然后通过方法引用获取到方法例程入口。跳转过去,之后的工作就由例程接管,走前述流程。

JVM执行Java方法

JVM是用C++编写的,其运行后,是一个普通的进程,为了区分,称为C环境。而我们平时看到的Java代码,它们的调用运行等,称为Java环境。

我们习以为常的,都是Java环境的认识。比如一个Java线程就是一个线程,一个Java方法就是一个方法,一个字节码指令就是一个指令。

但站在C环境的角度,一切会变得截然不同。

在C环境看来,Java字节码文件就是个普通的二进制文件,Java线程、Java方法、字节码指令,都是定义的一种不同对象,再普通不过。针对每个对象的不同操作,只是不同的程序逻辑而已。

前面描述的那些,就是站在JVM的角度,处于C环境,看到的程序逻辑。比如Java方法调用,从C环境看,开始要生成新的Java栈帧并设置相关数据,结束要清除栈帧;从Java环境看,就是方法开始就自动有了一块栈空间,栈里还有方法执行需要的数据,结束了这块栈空间就自动回收了。这些Java环境的自动,就是C环境的程序逻辑保证的。

凡事皆有开始。

Java环境寄生于C环境,第一个Java方法被C环境执行后,Java环境才开始形成。

一个Java方法,必然是由一个C函数负责执行的。

Java方法需要栈,可以额外创建一个栈的对象,给Java方法执行用,入栈、出栈等,都是执行栈对象的函数而已。但是这样效率太低了,前面也说了,无论字节码指令还是方法例程,最终都是可直接执行的机器码。机器码可不认识这么个栈对象(事实上是逻辑上不用,机器码才不用),它们入栈出栈,这个栈是C环境的栈。所以自然而然,Java方法的栈,就直接寄生在负责执行它的C函数的栈里。除了栈操作,这些机器码还会使用各种寄存器。C环境的栈和各种寄存器,在执行的每一刻,它们的状态都是确定的,这是程序正确稳定执行的基础。如果这个C函数,因为执行Java方法,导致它的栈和各种寄存器,被修改得面目全非,JVM程序就乱了。

所以,这个C函数,可以先在栈里分配一块空间,相当于局部变量,后面Java方法执行将用到的所有寄存器,都存到栈里。之后C函数会入栈Java方法参数,然后调用Java方法的方法例程。上面说过,Java方法执行完成后,会保证恢复栈到调用前状态,同时C函数也会把保存到栈里的各个寄存器恢复,C环境就恢复到了Java方法执行前的状态,仿佛从来没有执行过。事实上,C环境也不知道也不关心有没有执行过Java方法,因为可以认为这是其所在C函数的逻辑的一部分。

这个C函数,为了效率,同样在JVM初始化时,用汇编器以汇编命令形式,生成为一段机器码,称作 call_stub 例程。其例程入口地址 entry_point 同样会保存起来,JVM需要执行Java方法的时候,直接调用。

方法例程和 call_stub 例程都是服务于方法执行,分别处理了不同层面的工作。方法例程负责处理Java方法执行相关的逻辑,比如初始化局部变量、生成栈帧等,而 call_stub 例程负责协调处理C函数到Java方法执行的准备和保护工作,比如保存、恢复寄存器,入栈Java方法参数等。