java虚拟机之虚拟机字节码执行引擎

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/weixin_43847987/article/details/101111158

执行引擎概述
执行引擎是java虚拟机最核心的组成部分。虚拟机的执行引擎是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件支持的指令集格式。
在java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型。所有的虚拟机的外观上看都是一样的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

栈帧适用于支持虚拟机方法调用方法执行的数据结构,他是虚拟机运行时数据区中虚拟机栈的栈元素
栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息。每一个方法从调用开始到执行完成的过程,都对应着一个栈帧虚拟机栈里面从入栈到出栈的过程。
在编译程序代码就确定了栈帧的内存大小。
在这里插入图片描述
执行引擎只对当前栈帧进行操作,当前栈帧就是位于栈顶的栈帧。

局部变量表
局部变量表是一组变量存储空间,用于存放方法参数方法内定义的局部变量

局部变量表以变量槽(Slot)为最小单位,一个变量槽的具体占用的内存大小并没有被指定,但是一个Slot可以存放一个32位以内的数据类型。
对于64位的数据类型(java中有long和double类型),虚拟机会以高位对其的方式为其分配两个连续的Slot空间

虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始到局部变量表最大的Slot数量。访问的是32位数据类型的变量,索引n就代表使用第n个Slot,如果使用的是64位数据类型,就代表同时会使用n和n+1这两个Slot(在访问时不允许单独访问其中的任何一个)。

为了节省空间,局部变量表是可以重用的。方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值超过了某个变量的作用域,那么这个变量的变量槽就可以交给其他变量使用。但是也带来了一些坏处,Slot的复用会直接影响到系统的收集行为。如下演示:

public static void main(String [] args){
		byte[] b=new byte[64*1024*1024];
		System.gc();
}

垃圾收集结果如下:

[GC 66846K -> 65824K(125632K), 0.0032678 secs]
[Full GC 65824K -> 65746K(125632K), 0.0064131 secs]

在这种情况下确实回收不了b的内存。因为b还处于作用域内。
修改代码如下:

public static void main(String [] args){
	{
		byte[] b=new byte[64*1024*1024];
	}
	System.gc();
}

查看回收结果发现还是如上面的结果。让人有点不能理解,这回已经在作用域之外了可是还是没有被回收。
继续修改代码如下:

public static void main(String [] args){
	{
		byte[] b=new byte[64*1024*1024];
	}
	int a=0;
	System.gc();
}

这次查看回收结果(如下):发现已经回收了。

[GC 66846K -> 65824K(125632K), 0.0032678 secs]
[Full GC 65824K -> 218K(125632K), 0.0064131 secs]

变量b被回收的根本原因是:局部变量表中的Slot是否还有关于b数组对象的引用。在第一次修改时,虽然离开做b的作用域但是并没有对局部变量表读写操作,b原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联,因此才没有被回收。

注:一个局部变量定义了但是没有赋初值是不能使用的。

操作数栈
操作数栈是一个后入先出的栈。当一个方法执行开始时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
在概念模型中,一个活动线程中的两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处就是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。
在这里插入图片描述
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的,有一部分符号引用会在类加载阶段第一次使用的时候转化为直接引用这种转化称为静态解析,另一部分在每次的运行期间转化为直接引用,这部分称为动态连接

方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。

  • 第一种方法是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。这种退出方式称为正常完成出口
  • 第二种方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内被处理掉(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种方式称为异常完成接口,这种退出方式不会给上层调用者产生任何返回值。

无论采用哪一种退出方式,在方法退出后,都需要返回方法被调用的位置,程序才能继续执行。

方法调用

方法调用的目的:确定被调用方法的版本(哪一个方法),不涉及方法内部的具体运行过程,在程序运行时,进行方法调用是最普遍的、最频繁的操作。
一切方法调用在Class文件里存储的都只是符号引用,这时需要在类加载期间或者是运行期间,才能确定为方法在实际运行时内存布局中的入口地址(相当于说是直接引用)。

解析
“编译期可知,运行期不可变”的方法,在类加载解析阶段,会将其符号引用转化为直接引用(入口地址),这类方法的调用称为解析

在java虚拟机中的5条方法调用字节码指令:

  • invokestatic 调用静态方法

  • invokespecial 调用实例构造器、私有方法和父类方法
    只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段种确定唯一的调用版本,符合条件的有静态方法、私有方法、实例构造器、父类方法,他们在类加载时候就会把符号引用解析为该方法的直接引用

  • invokevirtual 调用所有的虚方法

  • invokeinterface 调用接口方法

  • invokedynamic 先在运行时动态解析出调用点限定符所引用的方法,然后再执行方法,在吃之前的4条调用命令的的分派逻辑是固话在虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

分派
就是确定如何执行哪个方法
静态分派
所有依赖静态类型来定位方法执行版本的分派动作,都成为静态分派。静态分派发生在编译阶段。
静态分派最典型的应用就是方法重载

public class M{

	class Book{
	}
	
	class StoryBook extends Book{
	}
	
	public void decribe(Book b){
			System.out.println("Book");
	}
	
	public void decribe(StoryBook b){
			System.out.println("StoryBook");
	}
	
	public static void main(String[] args){
			Book b=new StoryBook();
			M m=new M();
			m.decribe(b);
	} 
	
}

输出结果为:Book
这个地方将Book类称为静态类型或者外观类型,StoryBook为实际类型。
编译器在运行前只知道一个对象的静态类型,并不知道对象的实际类型。
decribe方法经过了重载,有两个不同的参数,虚拟机方法调用时,他会直接使用静态类型进行匹配。也就是说:重载时是通过参数的静态类型而不是实际类型作为判断依据。
静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用的时候变化变量本身的静态类型不会变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实例类型是什么

在上面的代码中Book b=new StoryBook(),Book是静态类型,实际类型是StoryBook,而虚拟机在重载时是通过参数的静态类型而不是实际类型作为判断的。并且静态类型在编译期是可知的,所以在编译阶段,编译器会根据参数的静态类型决定使用哪一个重载的版本。

  • 所有依赖静态类型的来定位方法执行版本的分派动作称为静态分派
  • 静态分派的典型应用就是方法重载
  • 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的

动态分派
运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
主要和重写有密切的关系。
看下面这样一段代码:

public class M(){
class Book{
		public void decribe(){
					System.out.println("Book");
		}
	}
	
	class StoryBook extends Book{
			@Override
			public void decribe(){
					System.out.println("StoryBook");
			}
	}

	class PEBook extends Book{
			@Override
			public void decribe(){
					System.out.println("PEBook");
			}
	}
	
	public static void main(String[] args){
			Book sb=new StoryBook();
			Book pb=new PEBook();
			sb.decribe();
			pb.decribe();
			sb=new PEBook();
			sb.decribe();
	} 
}

运行结果为:

StoryBook
PEBook
PEBook

上面代码的javap编译的字节码文件为:

在这里插入代码片

主要和invokevirtual方法调用字节码有关,运行过程如下:

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记为C。
  2. 在C中寻找与常量的描述符合简单明都一致的方法,进行权限校验,如通过则返回这个方法的直接引用,查找结束;如果不通过,返回异常。
  3. 否则按照继承关系从下往上依次对C的各个父类进行第二部搜索和验证。
  4. 如果始终没有找到合适的方法,则抛出异常。

虚拟机的动态实现
最常用的手段就是在方法区建立一个虚方法表,如果一个方法在子类中没有被重写,那么子类的虚方法表里的地址入口和父类对应方法地址入口一致。
在这里插入图片描述
即每一个对象都建立一个如上图的虚方法表,表中列出每个对象的所有方法,包括继承的方法,如果重写了对应的方法,则对应的地址就是重写方法的地址,如果没有重写就是原来的方法地址。

猜你喜欢

转载自blog.csdn.net/weixin_43847987/article/details/101111158