HotSpot JVM源码分析 - 方法调用与解释执行(1):流程综述
本系列文章基于 OpenJDK 9,聚焦 x86 平台。
为避免陷入代码和细节的汪洋大海中,本文尽量只做文字描述,并忽略一些细枝末节,目的是对整个流程有个宏观上的把握。
背景
先看一段代码:
1 | public void sendUserVerify(String to, String subject, String content) throws RuntimeException { |
编译成字节码文件,方法体对应的可读字节码片段为:
1 | 0: new #3 |
站在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。
好在继承一个类(实现一个接口),同一个方法在基类(接口)中的类虚表索引(接口虚表索引)不会变。那么对于 virtual
或interface
的方法,我们记下类虚表索引或接口虚表索引就好,然后运行时在动态类型的类元数据里,在类虚表或接口虚表里根据索引找到重写的方法数据。对于 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方法参数等。