虚拟机字节码执行引擎
本节主要内容:
- 了解虚拟机运行时栈帧结构
- 了解方法定位的过程:解析和分派
- 了解jvm对动态类型语言的支持
- 了解jvm的基于栈的字节码解释执行引擎
概述
虚拟机和物理机的区别?
物理机的执行引擎是由硬件实现(exe文件,可执行二进制)的,和物理机的执行过程不同的是虚拟机的执行引擎由于自己实现的。
运行时栈帧结构
相关说明:每一个线程都有一个栈,也就是前文中提到的虚拟机栈,栈中的基本元素我们称之为栈帧。栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。每个栈帧都包括了一下几部分:局部变量表、操作数栈、动态连接、方法的返回地址 和一些额外的附加信息。栈帧中需要多大的局部变量表和多深的操作数栈在编译代码的过程中已经完全确定,并写入到方法表的Code属性中
。在活动的线程中,位于当前栈顶的栈帧才是有效的,称之为当前帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令只针对当前栈帧进行操作。需要注意的是一个栈中能容纳的栈帧是受限,过深的方法调用可能会导
致StackOverFlowError,当然,我们可以认为设置栈的大小。其模型示意图大体如下:
局部变量表
局部变量表法的存放?
局部变量表中存储入参、局部变量、异常
code属性中的指令会使用到局部变量表
一个存储单位称为Slot
一个Slot的大小?能存放哪些数据类型?
虚拟机规范并没有明确规定一个Slot的大小,只是很有导向性地说到每个 Slot 都应该能存储一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据(32位或者更小),而 long 和 double(64位)可以使用两个slot去存储。
reference类型表示什么?
虚拟机规范并没有明确指明它的长度,也没有明确指明它的数据结构,但是虚拟机通过 reference 数据可以做到两点:
- 通过此 reference 引用,可以直接或间接的查找到对象在 Java 堆上的其实地址索引
- 通过此 reference 引用,可以直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息
Slot复用对GC的影响?
Slot在离开作用域之后、离开方法之前,如果没有重新覆盖值,有可能还会持有对象的引用,这会影响GC的回收
需要把“赋null值来优化内存回收”作为编码规则吗?
- 注意以恰当的作用域来控制变量的回收即可,上述情况不常见
- 赋null值的操作只在解释执行下的概念模型中有效,在JIT模式下无效
局部变量和类变量的区别?
类变量有准备阶段
,局部变量无,因此必须赋初值
操作数栈
相关说明:
- Java 虚拟机的解释执行引擎称为”基于栈的执行引擎”,其中所指的”栈”就是操作数栈。也就是说,指令的执行过程也就是入栈、出栈的过程(代替寄存器)
- 操作数栈的最大深度也是在编译时期就写入到方法表的 Code 属性的 max_stacks 数据项中。
- 栈容量的单位是 “字宽”,对于 32 位虚拟机来说,一个 “字宽” 占 4 个字节,对于 64 位虚拟机来说,一个 “字宽” 占 8 个字节
- 操作数栈的每一个元素可以是可以是任意 Java 数据类型,包括 long 和 double,32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2
动态连接
什么是动态连接?为什么需要动态连接?
在指令执行期间进行符号地址的转换,就是动态连接。
这么做的原因是有些指令操作的对象的实际类型只有在指令执行的时候才可以确定。
方法返回地址
方法返回的两种方式:
- 正常完成出口,栈中需要保存调用处的PC值作为返回地址。
- 异常完成出口:返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法调用
方法调用的任务是什么?
方法调用的任务是确认调用哪个方法,并不是执行。
也就是说,这里的方法调用的含义是方法“确认”
解析(Resolution)
什么是解析?
有几种方法的调用,在加载阶段就可以确认该方法的直接引用,前提是:方法在程序真正运行之前就有一个可确定的调用版本(调用哪一个方法),并且这个方法的调用版本在运行期是不可变的(也就是说不包括接口方法和抽象方法)。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用(确认)称为解析。
这里的解析和类加载的解析阶段概念有些区别
哪些方法符合解析的条件?
有四种方法是进行的方法的解析:静态方法、私有方法、实例构造器、父类方法,这四类方法称为非虚方法,与之对应的就是续方法(final 方法除外),调用这四类方法的字节码指令是:
- invokestatic:调用静态方法
- invokespecial:调用实例构造器方法
、私有方法、父类方法
除了这两种方法调用的指令外还有:
- invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法,会在运行时确认一个实现此接口的对象
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
分派
什么是分派?
根据入参、出参(都称为宗量)来确定调用的方法就是分派
静态分派
静态分派的案例: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
35public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human guy) {
System.out.println("hello, guy");
}
public void sayHello(Main guy) {
System.out.println("hello, man");
}
public void sayHello(Woman guy) {
System.out.println("hello, woman");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch dispatch = new StaticDispatch();
dispatch.sayHello(man);
dispatch.sayHello(woman);
}
}
//result:
//hello, guy
//hello, guy
什么是静态类型(外观类型)和实际类型?
- 静态类型:编译时变量的类型
- 实际类型:执行时变量的类型
什么是静态分派?
依赖静态类型定位目标方法的分派动作称为静态分派
静态分配发生在编译阶段,静态分派的动作实际上不是由虚拟机来执行的,而是由编译器来执行的。
分派和解析的关系?
分派和解析并非“非黑即白”的关系
而是从不同的角度去确认调用方法的一个概念
静态分派发生在编译时期
解析发生在加载阶段
动态分派发生在执行阶段
动态分派
动态分派的案例: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
32public class DynamicDispatch {
static abstract class Human {
abstract void sayHello();
}
static class Man extends Human {
void sayHello() {
System.out.println("hello, man");
}
}
static class Woman extends Human {
void sayHello() {
System.out.println("hello, woman");
}
}
public static void main(String[] args){
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
//result
//hello, man
//hello, woman
//hello, man
什么是动态分派?
执行阶段根据实际类型来定位方法
指令上表现为invokevirtual和invokeinterface
invokevirtual的指令运行时大致解析过程?
- 找到操作数栈顶的引用所指的对象的实际类型,记做C
- 在类型C中查找与常量中的描述符和简单名称相同的方法,如果找到则进行访问权限的判断,如果通过则返回这个方法的直接引用,查找结束;如果权限不通过,则返回 java.lang.IllegalAccessError 的异常
- 如果在C中没有找到描述符和简单名称都符合的方法,则按照继承关系从下往上依次在 C 的父类中进行查找和验证过程
- 如果最终还是没有找到该方法,则抛出 java.lang.AbstractMethodError 的异常
单分派和多分派
什么是单分派和多分派?
根据分派时依据的宗量多少,可以分为单分派和多分派。
java(1.8前)的分派类型是什么?
Java 语言还是一门 “静态多分派、动态单分派” 的语言:
- 静态多分派:静态分派时根据参数的静态类型和方法接受者的静态类型选择目标方法
- 动态单分派:动态分派时根据方法接受者的实际类型替换目标方法
虚拟机动态分派的实现
实际分派需要考虑什么问题?
虚拟机中的动态分派是十分频繁的动作,并且是在运行时在类方法元数据中进行搜索的,因此基于性能的考虑,虚拟机会采用各种优化手段优化动态分派的过程,最常见的”稳定优化”的手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据以提高性能。
虚方法表介绍:
上图就是一个虚方法表,Father、Son、Object 三个类在方法区中都有一个自己的虚方法表,如果子类中实现了父类的方法,那么在子类的虚方法表中该方法就指向子类实现的该方法的入口地址,如果子类中没有重写父类中的方法,那么在子类的虚方法表中,该方法的索引就指向父类的虚方法表中的方法的入口地址。
有两点需要注意:
- 为了程序实现上的方便,一个具有相同签名的方法,在子类的方法表和父类的方法表中应该具有相同的索引,这样在类型变化的时候,只需要改变查找方法的虚方法表即可。
- 虚方法表是在类加载的连接阶段实现的,类的变量初始化完成之后,就会初始化该类的虚方法表
动态类型语言支持
什么是动态类型语言?
主要特性:
- 在运行期进行类型检查
- 变量没有类型而变量的值有类型
什么是“在编译器/运行期进行”?
某种检查/验证进行的时期
什么是“类型检查”?
类型检查指验证操作接收者是否为合适的类型数据以及赋值是否合乎类型要求。
动态语言和静态语言谁更好?
动态语言:
- 优点:动态检查的一个显著优点是它提供了宽松、少限制的程序设计环境,这在交互式语言中是十分有用的;动态检查允许对变量的后期约束,从而给予编程较大灵活性,还可以在引入某些新定义类型时,不需改变现行程序代码,即具有动态多态性;动态检查可允许更丰富的类型系统,特别地,易于将类型作为一阶对象处理,可允许异质的结构类型,便于数组下标分析。
- 缺点:(1)增加了程序运行时间,影响了效率;(2)需要数据具有类型标志;(3)错误发现太晚,不能防止运行错的出现。
静态语言:
- 优点:静态分析有利于错误的早时发现和代码优化、效率提高,也防止了关于类型的运行出错。
- 缺点:没有了动态方式所具有的灵活性,引入新的类型可能需对原程序的修改和重编译;静态检查也必须要求类型系统是可判定的以保证终止性,从而使类型系统有较大限制,许多类型是不允许的。
与JAVA语言,JVM有什么关系?
动态类型语言最重要的是对方法调用的影响,也就是在运行时才去定位需要调用的方法,简称动态定位
。
早期的java一直都是静态类型的语言,而JVM的目标是能更好的支持更多的语言,这其中包括动态语言,jdk1.7之前基本不支持动态语言,1.7引出新指令invokedynamic来支持动态语言,java1.7中使用MethodHandle来支持动态,但是也并没有使用到invokedynamic指令,从1.8的lambda中才有明显的运用。
动态分派和动态类型语言的区别?
动态分派利用运行期的实际类型
来实现,但依然会用编译期的静态类型
进行类型检查
而动态类型语言要求编译期静态类型
也不确定,在运行时期才进行类型检查。
JDK1.7如何支持动态类型(MethodHandle)?
MethodHandle的关键是让分派逻辑不固化在class文件上(连静态类型也不确定),而是通过一个具体的方法来完成方法的定位,MethodHandle的含义就是分派的结果,也就是方法调用的位置
- 创建MethodType对象,指定方法的签名(即方法参数以及方法返回值的类型)。
- 在MethodHandles.Lookup中查找类型为MethodType的MethodHandle;
- 传入方法参数并调用MethodHandle.invoke或者MethodHandle.invokeExact方法。
案例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 public static void main(String[] args) {
MethodHandleTest mht = new MethodHandleTest();
MethodHandle mh = getString(mht);
try {
String o = (String) mh.invoke(mht, "ssss");
System.out.println(o);
}catch (Throwable throwable) {
throwable.printStackTrace();
}
}
public static MethodHandle getString(Object Recevier){
MethodType mt = MethodType.methodType(String.class,String.class);
MethodHandle mh = null;
try {
mh = MethodHandles.lookup().findVirtual(Recevier.getClass(),"toString",Recevier);
} catch (Exception e) {
e.printStackTrace();
}
return mh;
}
MethodHandle和反射(Reflection)的区别?
- 反射是模拟java代码层面的调用,MethodHandle是模拟字节码层面的调用。
- Reflection是重量级,而MethodHandle是轻量级。
- 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。
CONSTANT_MethodHandle_info和MethodHandle类的关系?
InvokeDynamic指令介绍:
InvokeDynamic和MethodHandle机制一样,只是MethodHandle是java代码的实现,InvokeDynamic是字节码的实现。
InvokeDynamic指令与前4条invoke*指令的区别?
基于栈的字节码解释执行引擎
解释执行和编译执行的定义?
- 解释执行:代码由生成字节码指令之后,由解释器解释执行
- 编译执行:通过即时编译器生成本地代码执行
java编译包含了哪些步骤?
生成字节码的过程发生在虚拟机外,解释器在虚拟机内,因此属于半独立编译
基于栈的指令集合基于寄存器的指令集区别以及优缺点?
基于栈的指令集中的指令是依赖于操作数栈运行的,基于寄存器的指令是依赖于寄存器进行工作的。用一个简单的例子说明:1 + 1 这个例子来说明:
基于栈的指令如下:
1
2
3
4iconst_1
iconst_1
iadd
istore_0两条 iconst_1 指令分别把两个 1 压入到工作栈中去,然后执行 iadd 两条指令,对栈顶的两个 1 进行出栈并相加的动作,然后将相加的结果 2 压入到栈中,接着执行 istore_0 将栈顶的 2 存入到局部变量表中第 0 个 Slot 中去
基于寄存器的指令如下:
1
2mov eax, 1
add eax, 1mov 指令将寄存器 eax 中的值设置为 1,然后执行 add 指令将寄存器 eax 中的值加 1,结果就保存在 eax 寄存器中
基于栈的指令的特点:
- 可移植:寄存器由硬件决定,限制较大,但是虚拟机可以在不同硬件条件的机器上执行(虚拟机本身需要不同的实现)
- 代码相对更加紧凑:字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数
- 编译器实现更加简单
- 基于栈的指令缺点就是执行速度慢:因为虚拟机中操作数栈是在内存中实现的,频繁的栈访问也就意味着频繁的访问内存,内存的访问还是要比直接操作寄存器要慢的
案例:
注意:
- 案例的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述。
- 更准确的说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解释器和即时编译器都会对输入的字节码进行优化。