JVM第七卷---类加载机制
- 概述
- 运行时栈帧结构
-
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
- 方法调用
-
- 解析
- 分派
-
- 静态分派
- 动态分派
- 单分派与多分派
- 虚拟机动态分派的实现原理
- 基于栈的字节码解释引擎
-
- 解释执行
- 基于栈和基于寄存器的指令集
- 基于栈的解释器执行过程
导读:代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步
概述
物理机的执行引擎是直接建立在处理器,缓存,指令集和操作系统层面的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件限制定制指令集与执行引擎结构体系,能够执行哪些不被硬件直接支持的指令集格式。
在《JAVA虚拟机规范》中规定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为了各大发行商JAVA虚拟机执行引擎的统一外观。
执行引擎执行字节码时,通过有解释执行(通过解释器执行)和编译执行(通过及时编译器产生本地代码执行)两种选择,或者二者兼备,还可能同时包含几个不同级别的及时编译器一起工作的执行引擎。
但是从外观上看,所有的java虚拟机执行引擎输入,输出都是一致的:输字节码的二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
运行时栈帧结构
“栈帧”用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。
栈帧中存储了方法的局部变量表,操作数栈,动态链接和方法返回地址等信息。
每一个方法从调用开始到执行结束,都对应着一个栈帧从虚拟机栈中入栈到出栈的过程。
每一个栈帧包括了局部变量表,操作数栈,动态链接,方法返回值和一些额外的附加信息。
在编译java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来了,并写入方法表的code属性中,换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体虚拟机实现的栈内存布局形式。
同一时刻,同一条线程中,在调用堆栈的所有方法都处于执行状态,而对执行引擎来说,在活动线程中,只有位于栈顶的方法才是正在运行的,才是生效的,也被称为"当前栈帧"。
与该栈帧关联的方法被称为当前方法,执行引擎运行的字节码指令都只对当前栈帧进行操作。
局部变量表
局部变量表:存放方法参数和方法内定义的局部变量,在程序被编译为calss文件时,方法code属性中的max_locals就确定了该方法所需局部变量表的最大容量
局部变量表以变量槽位单位,32以内的数据类型占据一个变量槽,64位两个
常见32位数据类型有: boolean byte char int float reference和returnAddress
reference表示一个对象的实例引用,虚拟机能够通过这个引用做成两件事:
- 根据引用找到对象在java堆中的数据存放的起始地址索引
- 根据引用找到对象所属数据类型在方法区中的存储的类型信息
局部变量表示建立在线程堆栈中的,属于线程私有的数据,因此无论读写两个连续变量槽是否为原子操作,都不会引起数据竞争和线程安全问题
当一个方法被调用,虚拟机通过局部变量表完成实参到形参的传递。
如果执行的是实例方法(无static修饰),那么局部变量表中第0为索引的变量槽默认是用来传递方法所属对象的实例引用,即我们熟悉的this隐含参数。
局部变量表中的变量槽可以重用,如果方法体中定义的变量,其作用域未覆盖整个方法体,并且当前字节码pc计数器的值已经超过了这个变量的作用域,那么这个变量槽就可以交给其他变量重用
但是变量槽的复用,有时会影响系统的垃圾收集行为,主要原因在于变量虽然死了,但是由于方法在此之后没有对局部变量表进行读写操作,导致变量槽中仍然存有对死亡对象的引用,导致作为Gc Roots一部分的局部变量表仍然保持对死亡对象的关联,进而该对象无法被回收
一般有些人会通过赋变量为null来解决这个问题,但是没有必要,因为虚拟机底层的及时编译器会对这种情况进行优化处理
局部变量没有准备阶段,即没有初始化过程,所以如果一个局部变量定义了但是没有赋初始值,它是无法使用的
操作数栈
同局部变量表一样,操作数栈的最大深度也在编译时候就被写入到Code属性的max_stacks数据项之中
操作数栈是用来进行运算处理和方法参数传递等操作的,可以参考cpu中的算术单元ALU
举例: 执行iadd指令的时候,会取出栈顶两个int元素,然后进行累加,将结果入栈
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用时为了支持方法调用过程中的动态链接。
class文件的常量池中存在大量符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候被转换为直接引用。
另外一部分会在每一次运行期间都转换为直接引用,这部分成为动态链接。
方法返回地址
退出方法的两种方式
- 遇到方法返回的字节码指令,正常调用返回,如果有返回值就将返回值返回给上层的调用者
- 异常完成返回,不提供返回值
方法的退出过程总结为如下一句话:
恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整pc计数器的值指向方法调用指令后面一条指令。
方法调用
方法调用不等于方法体内代码被执行,方法调用阶段唯一任务是确定被调用的是哪个方法。
Class文件在编译过程中不包含传统程序语言编译连接的步骤,一切方法调用在class文件里面存储的都是符合引用,而不是方法在实际运行时内存布局中的入口地址。
某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
所有方法调用的目标方法在calss文件里面都是一个常量池中的符合引用,在类加载阶段,会将其中一部分符号引用转换为直接引用,这种解析成立的前提是: 方法符合"编译器可知,运行期不可变"的原则,这类方法的调用被称为解析。
符合上述原则的方法主要有静态方法和私有方法两大类,前者与类型直接关联,后者外部不可访问。
虚拟机支持以下五种方法调用的字节码指令:
- invokestatic : 调用静态方法
- invokespecial: 调用实例构造器方法,私有方法和父类的方法
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法,在运行时确定一个实现该接口的对象
- invokedynamic:运行时动态解析出调用点限定符所引用的方法,方法执行该方法
能被invokestatic 和invokespecial调用的方法都会在类加载阶段就把符号引用解析为方法的直接引用,java中符合这个条件的有静态方法,私有方法,实例构造器,父类方法,被final修饰的方法(尽管它使用invokevirtual指令调用)
这些方法称为非虚方法,其他方法称为虚方法
分派
分派对应面向对象基本特性中的多态。
静态分派
先看例子:
/**
* <p>
* 静态分派演示
* </p>
*
* @author 大忽悠
* @create 2022/2/26 11:00
*/
public class Main {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human peo) {
System.out.println("human");
}
public static void sayHello(Man peo) {
System.out.println("man");
}
public static void sayHello(Woman peo) {
System.out.println("woman");
}
public static void main(String[] args) {
Human human = new Man();
Human human1 = new Woman();
Main main = new Main();
main.sayHello(human);
main.sayHello(human1);
}
}
结果
为什么虚拟机选择执行参数类型为Huamn的重载版本呢?
Human称为变量的静态类型,或者叫外观类型
Man被称为变量的实际类型或者叫运行时类型
静态类型编译器可知,而实际类型只有在运行时才可知
举例:
//实际类型变化
Human human=(new Random()).nextBoolean()?new Man():new Woman();
//静态类型变化
main.sayHello((Man)huaman);
main.sayHello((Woman)huaman);
编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的,由于静态类型编译器可知,所以在编译阶段,javac编译器就会根据参数的静态类型决定使用哪个重载版本了,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写入到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来决定方法执行版本的分派动作,成为静态分派。
静态分派发生在编译阶段,因此确定静态分派的动作实际不是由虚拟机执行的,这也是为什么有些资料把它归入解析而不是分派。
有些情况下,由于重载版本不是唯一的,javac编译器往往只能够确定一个相对合适的版本
public class Main {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(int peo) {
System.out.println("human");
}
public static void sayHello(long peo) {
System.out.println("man");
}
public static void sayHello(char peo) {
System.out.println("woman");
}
public static void main(String[] args) {
sayHello('a');
}
}
这里说白了就是适配优先级的问题,如果没有char peo,就会走int peo,如果没有int peo,就会走long peo
解析和静态分派不是对立关系,他们是在不同层次上进行筛选,确定目标方法的过程,例如: 静态方法会在编译期确定,在类加载期进行解析,而静态方法也可以有重载版本,选择重载版本的过程也是通过静态分派完成的
动态分派
动态分派与java中的父类方法重写密切相关
public class Main {
static class Human {
public void sayHello() {
System.out.println("human");
}
}
static class Man extends Human {
@Override
public void sayHello() {
System.out.println("man");
}
}
static class Woman extends Human {
@Override
public void sayHello() {
System.out.println("woman");
}
}
public static void main(String[] args) {
Human human = new Man();
Human human1 = new Woman();
human.sayHello();
human1.sayHello();
}
}
显然这里方法版本的选择不可能在更据静态类型来决定了,因为静态类型同样都是Human的两个变量,在调用sayHello方法时产生了不同的行为,导致这个原因很简单,因为两个变量实际类型不同。
下面在字节码层面剖析一下:
这两条字节码指令无论从指令还是参数来看,都完全一样。
虽然指令相同,但是最终执行的方法却不同,这是怎么肥事呢?
我们需要深入了解一下invokevirtual指令本身才可以,invokevirtual指令的运行时解析过程大致分为以下几步:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C
- 如果在类型C中找到与常量池的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,否则抛出异常
- 否在,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到,则抛出异常
invokevirtual指令第一步确定接受者实际类型,确保了后面调用方法时,会根据实际类型来选择方法版本,这个过程就是java中的方法重写本质,我们把这种在运行期更据实际类型确定方法执行版本的分派过程称为动态分派。
invokevirtual指令只对方法有效,对字段无效,因此字段没有虚字段之说,换句话说,字段没有多态性。
当子类中声明了与父类同名字段的时候,虽然子类内存中两个字段都会存在,但是子类的字段会遮蔽父类同名的字段
举例:
public class Main {
static class Human {
int i;
public Human() {
this.i = 1;
sayHello();
}
public void sayHello() {
System.out.println(i);
}
}
static class Man extends Human {
int i;
public Man() {
this.i = 2;
sayHello();
}
@Override
public void sayHello() {
System.out.println(i);
}
}
public static void main(String[] args) {
Human human = new Man();
System.out.println(human.i);
}
}
Man类创建时,隐式调用Human类的构造函数,在父类构造函数中调用了sayHello方法,这是一次虚方法调用,实际调用的是子类的sayHello方法,但是子类构造函数还没初始化,因此子类的i为0。
main方法中最后一句通过静态类型访问到父类中的money,输出了1
单分派与多分派
可以先理解为需要重写的方法,同时存在重载现象
方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种总量,可以将分派划分为单分派和多分派两种。
单分派是更据一个宗量对方法进行选择,多分派是更据多个宗量进行选择。
public class Main {
static class ONE {
}
static class TWO {
}
static class Human {
public void sayHello(ONE one) {
System.out.println("Human: one你好");
}
public void sayHello(TWO two) {
System.out.println("Human: two你好");
}
}
static class Man extends Human {
@Override
public void sayHello(ONE one) {
System.out.println("man: one你好");
}
@Override
public void sayHello(TWO two) {
System.out.println("man: two你好");
}
}
public static void main(String[] args) {
Human man = new Man();
Human human = new Human();
man.sayHello(new ONE());
human.sayHello(new TWO());
}
}
我们首先关注编译阶段编译器的选择,也就是静态分派的过程:
- 这此时选择目标方法依据两点: 静态类型是Human还是Man; 方法参数是ONE还是TWO;
- 这次选择最终产生两条Invokevirtual指令
- 两条指令的参数分别为常量池中指向Man:sayhello(ONE)和HUMAN:sayhello(TWO)方法的符号引用。因为是更据两个宗量进行选择,所以java语言的静态分派属于多分派类型。
再关注运行阶段虚拟机的选择,也就是动态分派的过程:
- 执行man.sayHello(ONE)代码的时候,准确说是这行代码对应InvokeVirtual指令的时候,由于编译器已经目标方法签名必须是sayHello(ONE),虚拟机此时不关心传递过来的参数是什么,因为这时候参数静态类型,实际类型不会对方法的选择构成影响。唯一影响的是接受者的实际类型是Human还是Man,因为只有一个宗量作为选择依据,所以java语言的动态分派属于单分派语言。
总结:java语言是一门静态多分派,动态单分语言
虚拟机动态分派的实现原理
首先按常理来处理动态分派的话,过程如下:
运行时在接受者类型的方法元数据中搜索合适的目标方法
但是动态分派是非常频繁的动作,频繁搜索元数据非常消耗性能。
为了进行优化,我们可以在方法区中建立一个虚方法表,使用虚方法表索引来替代元数据查找,从而提高性能。
虚方法表存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。
如果子类重写了父类方法,子类虚方法表中的地址会被替换为指向子类实现版本的入口地址。
虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
查询虚方法表示对分派调用的一种优化手段,由于java对象里面的方法(即不使用final修饰)就是虚方法,虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用类型继承关系分析,守护内联,内联缓存等多种技术进行优化。
基于栈的字节码解释引擎
下面重点分析一下虚拟机如何执行方法里面的字节码指令的
解释执行
java语言常被认为是解释执行的语言,这种说法在jdk1时代还算靠谱,但是当前虚拟机包含了及时编译器后,Class文件中的代码到底会被解释执行还算编译执行,就成了只有虚拟机自己才可以准确判断的事情。
无论是解释还是编译,大部分程序转换为物理机的目标代码或虚拟机能执行的指令集前,都需要经过如下的步骤:
对于java语言来说,javac编译器完成了程序代码从词法分析,语法分析到抽象语法树,再遍历语法树生成线性字节码指令流的过程。
这部分动作是在java虚拟机外进行的,而解释器在虚拟机内部,所以java程序的编译时半独立实现的。
基于栈和基于寄存器的指令集
javac编译器输出的字节码指令流,是一种基于栈的指令集架构,字节码指令流里面大部分都是零地址指令,他们依赖操作数栈进行工作。
与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二进制指令集,如果说的更通俗些就是我们现在主流pc机中物理硬件直接支持的指令集架构,这些指令依赖寄存器工作。
栈指令集架构和寄存器指令集架构的区别?
举例: 使用两种指令集去计算1+1的结果
基于栈的指令集会是下面这样:
iconst_1
iconst_1
iadd
istore_0
该指令流中的指令通常不带参数,而是使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈中。
基于寄存器的指令集:
mov eax, 1
add eax, 1
mov指令把eax寄存器的值设置为1,然后add指令把这个值加1,结果保存在eax寄存器里面,这种二进制地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。
简单比较一下二者的优缺点
- 栈指令集的可移植性更好
- 栈指令集的速度更慢
基于栈的解释器执行过程
public int calc()
{
int a=100;
int b=200;
int c=300;
return (a+b)*c;
}
对应字节码:
public int calc();
Code:
Stack=2,Locals=4,Args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
javap提示这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间。
下面通过图解的形式来看一下操作数栈和局部变量表的变化情况:
上面只是java虚拟机规范的做法,但是实际真实的虚拟机厂商会对执行过程做出很多优化,因此真实运作可能会和概念模型有很大区别,主要是因为虚拟机中的解析器和及时编译器都会对输入的字节码进行优化