深入透析虚拟机字节码执行引擎工作原理


前言

虚拟机是一个相对物理机的概念,物理机主要负责在操作系统层面上的,也就是底层,而虚拟机主要负责软件层面上的。


1. 运行时栈帧结构

每一个栈帧都包含局部变量表,操作数栈,动态连接,方法返回地址和一些额外附加的信息。
对于执行引擎来讲,一个线程目前生效并运行的只有一个放方法,且一定是栈顶的那个方法,称为当前栈帧,当前方法。

2.局部变量表

虚拟机通过索引的方式查找表中的变量,索引N就代表第N个插槽。如果虚拟机每个插槽只有32位,那么64位数据如long 和double 需要放在两个连续的插槽中,且对这个插槽的操作不管是不是原子性,都是线程安全的,因为虚拟机栈是线程独有。
调用方法时,虚拟机会将参数存于局部变量表。如果通过实例调用方法,局部变量表自动将第0号索引返回代表着这个实例的引用,即我们熟知的this就是指向这个0号索引。
局部变量表的插槽允许复用,当局部变量已经越过了其生命周期,(比如for循环中定义的变量,此时for循环结束了)。这个变量所占有的插槽就可以被其他新创建的变量占用,这样节省了空间但是也出现了一些问题:因为当此变量插槽等待复用的时候,这个插槽还保留着对这个变量的引用,这个变量还没被标记为垃圾,就不能被回收。
局部变量表不会像类或接口经历准备准备阶段,这就表明局部变量没有初始化是不能在JAVA中使用的,因为不会给它们赋初始值。

3.操作数栈

这个更像是方法执行时的小工具,比如做多次加法运算时,会被运算中产生的中间值压栈后取出,且支持任何数据类型的插入。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器 必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为 例,这个指令只能用于整型数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型, 不能出现一个long和一个float使用iadd命令相加的情况。
按理说操作数栈在不同栈帧之间应该是相互独立的,但是JVM做了一些优化,允许不同栈帧的操作数栈有一些重叠,这样节省了空间,也在方法互相调用较少了复制参数的消耗。

4.动态链接

类加载时的解析阶段会将一部分符号引用转化为直接引用,动态链接代表每一次程序运行都会将一部分符号引用转化为直接引用。

5.方法返回地址

方法有两种返回方式:1、遇到返回指令2、出现异常且无法有效处理
当返回时,方法一定要回到方法开始的地方,可以同PC计数器记住这个位置,方法返回后跳转到这个地方

6.方法调用

其他语言在编译后就已经给每个方法分配了内存地址,而JAVA编译后,只是用一个符号引用来代替方法的引用,这样就具有很大的动态意义。

6.1解析

私有方法和静态方法一般被解析在类加载过程中,因为静态方法是类自带的,不能随意改变,私有方法不能被外部访问,打消了子类继承重写等改变的方式,所以它们具有“运行时不可改变”的特性。(还包含实例构造器、父类方法、非虚方法,这些方法统称为非虚方法)
1)invokestatic。用于调用静态方法。
2)invokespecial。用于调用实例构造器()方法、私有方法和父类中的方法。
3)invokevirtual。用于调用所有的虚方法。
4)invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
5)invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

6.2 分派

静态分派

当一个方法传入的参数实现了一个接口或者抽象类(这里我称为父亲),静态分派会更倾向于按照这个父的版本去执行方法,因为这个父亲的类型是不会发生改变的,我们称为静态类型,而实际对象的类型在类加载结束之后还是不确定的,如下面代码所示:

// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化
sr.sayHello((Man) human)
sr.sayHello((Woman) human)

如果没有下面的强转换,你怎么确定这个hunman实例到底是man还是woman,所以方法sayhello在解析时更偏向于将human本来的类型插入临时变量表。
当方法重载时,传入的参数和所有要求的参数类型都不同,这个时候方法会寻找一个最合适的类型来转换,比如传入的是char参数,最适合的就是将其转化为int类型。但当出现了两个最合适的转换类型时,执行目的变得模糊,编译器会拒绝编译。

动态分派

方法运行根据实际对象来确定方法执行版本的分派方式,我们可以根据invokevirtual方法来看到动态分配的规律:

字段是没有多态这个特性的,当一个类要访问某个名字的字段时,该方法指向的就是唯一确定的那个名字,比如就算子类中有和父类中同名的字段,该子类的方法访问字段会屏蔽父类的那个字段,唯一指向自己的那个字段。

单分派和多分派

但分派和多分派的区分依据是:当一个方法选择执行版本的时候,有几个考虑的宗量,如下面代码所示

public class Dispatch {
	static class QQ {}
	static class _360 {}
	public static class Father {
	public void hardChoice(QQ arg) {
		System.out.println("father choose qq");
	}
	public void hardChoice(_360 arg) {
		System.out.println("father choose 360");
	}	
}
	public static class Son extends Father {
	public void hardChoice(QQ arg) {
		System.out.println("son choose qq");
	}
	public void hardChoice(_360 arg) {
		System.out.println("son choose 360");
	}
}
	public static void main(String[] args) {
		Father father = new Father();
		Father son = new Son();
		father.hardChoice(new _360());
		son.hardChoice(new QQ());
	}
}

HardChoice这个方法参数如果写成son.hardChoice(random.nextInt()>10? New QQ():new 360()),那么它需要考虑的是实例用father还是用son,参数用360还是用qq,这就是两个宗量,那么他就是多分派,但是像图片中的方法,参数已经确定了,只用考虑father还是son,一个宗量,那就是单分配。

虚拟机实现分派

因为动态分派执行的较为频繁,所以采用建立虚方法表和接口方法表的方式来优化执行效率,如下图所示:
在这里插入图片描述

每个表中的方法都对应着一个执行数据实际地址,当子类没有重写父类的方法,子类和父类的执行地址和数据都相同,但是hardChoice子类都重写了,那么两个类对应的数据地址就不相同。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值