字节码执行引擎
执行引擎是Java虚拟机最核心的部件之一,是由Java自己实现的,可自定义指令集。
执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备(实际上很多Java虚拟机都是如此)。但不论是哪种执行过程,其实质都是方法的调用和字节码的执行,直白的说就是由字节码执行引擎控制的方法实际执行过程,下面首先记录了方法的重要数据结构:栈帧,再记录方法调用和解释执行的概念。
该篇的重点是栈帧和解释执行的概念,难点是静态分派和动态分派的理解。
关于编译执行该篇不做记录,其详细内容在 Java虚拟机(八)——运行期优化
1. 栈帧
栈帧(Stack Frame)是虚拟机运行时数据区中虚拟机栈中的处理元素,存储了局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法的调用开始到完成的过程,对应着一个栈帧在虚拟机栈从入栈到出栈的过程。
1.1 局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,存放方法的参数和方法内的局部变量。局部变量表的容量以变量槽(Variable Slot,简称Slot)为单位,大小一般为32位或64位,要求是一个Slot可以存放一个32位以内的数据类型。虚拟机通过索引定位的方式使用局部变量表,索引值范围从0到最大的Slot数量。对实例方法而言,第0位索引是对对象实例本身的引用,即this
,其后按顺序依次为方法的参数和方法体内的局部变量。
不使用的对象应手动置为null ————《Practical Java》
这句话看起来很奇怪,实际上这与Slot的重用(内存的重用)有关。在同一个方法体内,后面的代码有一些耗时很长的操作,而前面的代码又定义了大量内存、实际上已经不会再使用的变量,手动置为null值,把变量对应的Slot清空,让Slot占用的空间能够被其他变量复用。需要注意的是,这种手动置null的用法仅在某种特殊的情况下使用,理由有两点:
- 恰当的变量作用域来控制变量回收时间才是最优雅的解决办法;
- 代码经过JIT编译器后,会对代码产生很大程度上的优化,置null值的操作会被JIT编译优化后消除掉。
另外,局部变量不同于类变量,如果一个局部变量定义了但没有赋初值是不能使用的(没有系统默认值),好在编译时就会有提醒。
2. 方法调用
方法调用不等于方法执行,方法调用唯一的目的是确定被调用的方法的版本(调用哪一个方法),而不涉及方法内部的执行。一切方法在Class文件中只是符号引用,需要找出对应的直接引用才能执行。
2.1 再论类加载的解析
类加载的解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。而该时期解析的方法是非虚方法,即
- 静态方法
- 私有方法
- 实例构造器
- 父类方法
- final修饰的方法
除此之外的方法属于虚方法。解析调用在编译时期就完全确定,在类加载时期的解析阶段就好将符号引用替换为直接引用,不需要等到运行期。
2.2 静态分派(静态绑定)
虚方法的静态分派(静态绑定)与Java面向对象特性中的重载密切相关,发生在编译期。看下面的一段代码,试想输出是什么。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
44package com.jvm;
/**
* 静态分派
*
*/
public class StaticDispatch {
static class Human {
}
static class Man extends Human {
}
static class Women extends Human {
}
public void sayHello(Human guy) {
System.out.println("hello, guy!");
}
public void sayHello(Man guy) {
System.out.println("hello, man!");
}
public void sayHello(Women guy) {
System.out.println("hello, women!");
}
public static void main(String[] args){
Human man = new Man();
Human women = new Women();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(women);
}
}
输出1
2hello, guy!
hello, guy!
上面代码中的“Human”称为变量的静态类型(Static Type),后面的Man称为变量的实际类型(Actual Type)。静态类型的最终类型在编译期是可知的,但实际类型只在运行期才确定。,虚拟机(准确来说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判断依据的。另外,对于字面量,如sayHello('a')
中的a参数,由于其本身不需要定义,故没有显示的静态类型,因此编译器只能选择更接近、更合适的类型的方法来调用。
对应的字节码指令是invokestatic
或invokespecial
加符号引用
2.3 动态分派(动态绑定)
虚方法的动态分派(动态绑定)与Java面向对象特性中的重写密切相关,发生在运行期。看下面的一段代码,试想输出是什么。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
42package com.jvm;
/**
* 动态分派
* @author renhj
*
*/
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("hello man!");
}
}
static class Women extends Human {
@Override
protected void sayHello() {
System.out.println("hello women!");
}
}
public static void main(String[] args){
Human man = new Man();
Human women = new Women();
man.sayHello();
women.sayHello();
man = new Women();
man.sayHello();
}
}
输出1
2
3man say hello
woman say hello
woman say hello
相同的静态类型调用同一方法却输出不同结果,原因是重写使用动态分配的方式,根据实际类型去调用相关的方法,字节码对应的指令为invokevirtual
加上符号引用
2.4 动态类型语言
动态类型语言的特征是类型检查的主体过程是在运行期而不是编译期,比如JavaScript、PHP、Ruby、Groovy等。动态类型语言编译时最多只确定方法名称、参数、返回值等信息,而不会去确定方法所在的具体类型(方法接受者不固定)。可理解为:“变量无类型而变量值才有类型”。
静态类型语言在编译器确定好类型,编译器可提供严谨的类型检查,利于稳定性与大规模的代码;动态类型语言运行期确定类型,为开发人员提供更大的灵活性,代码更加的简洁、清晰。
Java属于动态类型语言,JDK7中提供的java.lang.invoke包中提供的API实现了一定程度上动态语言的特性,JDK7以前单纯依靠符号引用来确定调用的目标方法,而invoke包提供了一种新的动态确定目标方法的机制,称为“MethodHandle”(可获得一个虚方法的句柄),为JDK8的Lambda表达式提供了实现基础。
3. 解释执行
解释执行:将编译好的字节码一行一行地翻译为机器码执行。
编译执行:以方法为单位,将字节码一次性翻译为机器码后执行。
Java被定位为“解释执行”的语言,但随着技术的发展,主流的虚拟机都包括了JIT编译器,Class文件的代码到底是解释执行还是编译执行只能虚拟机自己才知道了。
典型的解释执行概念模型中,一个实例方法中的简单的四则远算的具体执行步骤如下:
- 操作数入操作数栈;
- 操作数栈中的操作数存入局部变量Slot;
- 所有操作数都存入完毕后,从局部变量表加载所需要的数据到操作数栈;
- 以栈顶两个操作数为一次运算的运算元素进行运算,结果放栈顶,重复运算知道算出最终结果;
- 返回栈顶元素。
现大多高级语言都遵循经典编译原理,在执行前对源码进行词法分析和语法分析,把源码转换为抽象语法树,如下图所示。其中最下方的路径为生成目标机器代码的过程,代表语言C/C++;中间的路径就是解释执行,代表语言Java;又或者把这些步骤和执行引擎一起封装,如JavaScript执行器。
3.1 基于栈的指令集与基于寄存器的指令集
Java虚拟机使用基于栈的指令集,而主流的PC大都使用基于寄存器的指令集。
两者对比,以简单的相加运算“1+1”为例子(仅用于说明,实际会有差异):
基于栈的指令集会是这样子的:1
2
3
4iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈,相加,然后把结果放回栈顶,最后istore_0把栈顶的值放入到局部变量表的第0个Slot中。
Java虚拟机的指令由一个字节长度的操作码,及其后若干的操作数组成。由于Java虚拟机采用面向操作数栈而不是寄存器的架构,因此大多数指令只有一个操作码,没有操作数。
如果基于寄存器,那程序可能会是这样子:1
2mov eax, 1
add eax, 1
两套指令集各有特点,各有优势。
使用基于栈的指令集有以下优点:
- 可移植性高,不必依赖硬件寄存器;
- 编译器实现简单;
- 代码更加紧凑(字节码大都是单字节)
但使用基于栈的指令集一个显著的缺点是执行效率慢,原因有:
- 完成相同功能需要更多的指令;
- 频繁访存。
基于寄存器的指令集则是反过来,其优点弥补了基于栈的指令集的缺点,而造成了基于栈的指令集的优点的缺失。
Copyright © 2018, GDUT CSCW back-end Kanarien, All Rights Reserved