深刻学习Java虚拟机——虚拟机字节码执行引擎

 

1. 运行时栈帧结构

1.1 认识栈帧

    1. 栈帧:用于支持虚拟机方法调用和方法执行的数据结构,它是由虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操做数栈、动态链接和方法返回值地址等信息。每个方法从调用开始到执行完成的过程都对应着一个栈帧的入栈到出栈。在代码编译完成时,栈帧中须要多大的局部变量表,多深的操做数栈都已经彻底肯定,而且写入到方法表的Code属性中。对于执行引擎来讲,在活动线程中,只有位于虚拟机栈顶的栈帧才是有效的,或者说执行引擎的全部字节码指令都只针对当前栈帧操做,最顶端的栈帧被称为当前栈帧,这个栈帧所对应的方法叫当前方法。栈帧结构的概念模型以下java

1.2 栈帧中的数据区域之一——局部变量表

    1. 是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序源码编译为Class文件时,就在方法表中的Code属性的max_locals数据项中肯定了该方法所需分配的局部变量表的最大容量。数组

    2. 局部变量表的容量的最小单位:变量槽,即Slot,一个Slot所占内存大小没有明确指定,但每一个Slot都应该可以存储一个32位之内的数据类型,好比boolean、byte、short、char、int、float、reference(也有64位的)和returnAddress8种类型。对于reference,虚拟机应当能经过这个引用直接或间接地查找对象在Java堆中数据存放的起始地址索引,还能够经过此引用直接或间接的查找到对象所属的数据类型在方法区中的存储的类型信息。缓存

而对于long和double(还有64位的reference类型的数据)这类64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间,而这种分割存储的方式也致使了在进行读写时也会分割为两次32位读写,但对于局部变量是线程私有的,不会出现数据安全问题,并且虚拟机也不容许任何方式单独的访问64位数据的两个Slot空间中的某一个,而之因此会出如今多线程中处理64位数据出现数据安全问题的缘由在个人博客的多线程部分也会有解释。安全

对于实例方法(非static)的局部变量表,其中的第一个也就是第0位索引的Slot存储的是当前方法的类的实例对象的引用,在方法中能够经过关键字 this 来访问这个隐含参数。而后其他方法参数再按照参数表的顺序进入局部变量表,占用从索引1开始的Slot,参数表分配完毕后,再分配方法体内的其余局部变量。数据结构

    3. Slot的复用:为了尽量节省栈空间,局部变量表中的Slot可复用。方法体中定义的变量其做用域不必定会覆盖整个方法体,若是程序计数器(程序计数器,当前栈中执行字节码的行号指示器)的值超过了某个变量的做用域,那么该变量对应的Slot就能够交给其余变量使用。好比说如下代码多线程

public void main(String[] args){
    int[] arr=new int[10];
    for(int i=0;i<10;i++){
        arr[i]=i;
    }
    int m=1;
    System.out.println(arr);
}

其中局部变量m就有可能占用变量i的Slot性能

    4. Slot复用对垃圾回收工做的影响:以三段代码的比较为例优化

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

这段代码很简单,即向内存填充的1Mb的数据,而后调用gc进行垃圾回收,可是并不会回收arr所占的内存空间,由于gc执行时arr还在做用域内,或者说main方法尚未返回退出,因此虚拟机不能回收arr的内存。(观察GC过程能够添加运行参数“-verbose:gc”)this

 

public void main(String[] args){
    {
        int[] arr=new int[1024*1024];
    }
    System.gc();
}

 这段代码中,arr的做用域被限制在花括号之中,从代码逻辑上看,执行gc时arr已经不可能被访问,gc应该能够对arr进行回收工做,可是实际上却没有,由于即便字节码执行已经超过了arr的做用域,可是在局部变量表的Slot中并无进行新的Slot读写操做,也就是说arr这个引用仍然占用着原来的Slot空间,那么arr仍然引用着他的数组对象,因此此时gc判断对于arr引用所指向的数组对象仍然与arr存在关联,也就没法进行gc,而对于下一段代码spa

 

public void main(String[] args){
    {
        int[] arr=new int[1024*1024];
    }
    int m=1;
    System.gc();
}

在添加一行int m=1的代码以后,运行程序,能够发现arr能够被gc回收了。由于 int m=1 这行代码就对arr所占用的Slot空间进行了复用,或者说对arr所占据的Slot空间进行的读写操做,删除了arr引用在Slot空间中的数据,致使arr的数组对象失去了关联的引用,此时gc就能够进行回收了。因此,在平常应用中,若是遇到像arr这种前一部分代码定义了一些占据较大空间且后面不在使用的变量,然后面的代码又会有耗时较长的操做,在这种状况下推荐将arr这种类型的引用设置为null值。

1.3  栈帧中的数据区域之一——操做数栈

    1. 一个先入后出的栈结构。操做数栈的最大深度在编译后便已经肯定,并写入Code属性的max_stacks数据项中。操做数栈中的每个元素能够是任意的Java数据类型,包括long,double。可是,对于32位长度的数据类型,占一个栈容量,64位的数据类型占2个。

    2. 操做数栈的执行:方法刚开始执行时,操做数栈为空,在方法执行过程当中会有各类字节码指令向栈中写入或读取内容,也就是出/入栈操做。作算数运算用操做数栈执行,或者调用其余方法时经过操做数栈来进行参数传递。好比执行整数相加的字节码指令iadd,会将操做数栈存放在最顶端的两个int类型数值进行相加而且将这两个值出栈,而后将相加的结果入栈,将结果赋予某变量时就会将该结果值出栈。

1.3  栈帧中的数据区域之一——动态链接

    1. 每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用就是为了支持方法调用中的动态链接。字节码中的方法调用指令就是以常量池中指向方法的符号引用做为参数,这些符号有一部分会在类加载阶段或者第一次使用时就替换为直接引用,这种转化称为静态解析;另外一部分将在运行期间转化为直接引用,这部分就叫动态链接

1.4 栈帧中的数据区域之一——方法返回地址

    1. 方法返回的方式:第一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的调用者,这种退出方式叫正常完成出口;另外一种方式是方法执行过程当中出现异常,且方法体内没有任何对这个异常的处理,就会致使方法退出,这种退出方式叫异常完成出口,异常完成不会给上层调用者任何返回值。

    2. 方法返回地址:如论何种方式退出方法,都要返回到被调用的位置,程序才能继续执行,因此栈帧中会保存一些数据来恢复上层方法的执行状态,这一部分数据就是方法返回地址。通常来讲,调用者的程序计数器的值能够做为返回地址,方法返回地址可能就会保存这个值,而方法异常退出时,栈帧通常不会保存这个信息。

    3. 当前方法退出时可能执行的操做步骤有:恢复上层方法的局部变量表和操做数栈,把返回值(若是有)压入调用者栈帧的操做数栈中,调整调用者程序计数器的值指向方法调用的下一条指令

2. 方法调用

    方法调用不是方法执行,而是肯定执行的是哪个方法,或者说是哪个版本的方法。

2.1 解析

全部方法在Class文件中都是常量池中的一个符号引用,在类加载过程的解析阶段中,会将其中一部分符号引用替换为直接引用,而实现这一步的前提是编译时就能肯定所执行的方法版本(执行的是哪个方法),而且这个方法的调用版本在运行期不可更改,这类方法的调用就叫解析。

知足这两种条件(编译器可知,运行期不可变)的方法主要是静态方法和私有方法两类,也就是说不可能经过继承或其余方式被重写的方法,都适合在类加载阶段解析。

    1. 虚拟机中5中方法调用指令:

(1)invokestatic:调用静态方法

(2)invokespecial:调用实例构造器方法、私有方法、父类方法

(3)invokevirtual:调用虚方法。

(4)invokeinterface:调用接口方法。

(5)invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,而后再执行该方法。

invokestatic和invokespecial指令调用的方法均可以在解析阶段肯定惟一的调用版本,好比静态方法、私有方法、实例构造器、父类方法4类,他们在类加载时就会将符号引用替换为直接引用,这些方法被称为非虚方法。其余方法(final方法除外)为虚方法。

fianl方法也是非虚方法的一种,虽然final方法由invokevirtual指令调用,但其符合非虚方法的特色,即没法覆盖,没有其余版本,多态选择的结果确定是惟一的,因此final方法是非虚方法。

解析调用必定是一个静态的过程,在编译期就彻底肯定,类加载过程当中将涉及的符号引用所有替换为肯定的直接引用。而分派调用多是静态也多是动态,还可分为单分派和多分派,这两类分派方式组合就构成了静态单分派、静态多分派、动态单分派、动态多分派。

2.2 分派

Java具备面向对象的3个基本特征:继承、封装和多态,对于方法的重载与重写,分派是虚拟机正肯定位目标方法的关键。

    1. 静态分派——重载

public 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(Man man){
		System.out.println("hello man");
	}
	public void sayHello(Woman woman){
		System.out.println("hello woman");
	}
	public static void main(String[] args) {
		StaticDispatch s=new StaticDispatch();
		Human m1=new Man();
		Human m2=new Woman();
		s.sayHello(m1);
		s.sayHello(m2);
	}
}
//输出结果
hello guy
hello guy

在上面这段代码中,“Human”称为变量的静态类型,或者叫作外观类型,后面的“Man”则称为变量的实际类型,静态类型和实际类型在程序中均可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量自己的静态类型不会被改变,而且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才能够肯定,编译程序在编译期并不知道一个对象的具体类型是什么。对于重载方法的调用,彻底取决于参数数量和数据类型。编译期在重载时是经过参数的静态类型而不是实际类型做为判断依据的,而且静态类型是编译期可知的,所以,在编译阶段编译器就会根据静态类型决定用哪一个重载版本,因此选择了sayHello(Human)做为调用目标,并把这个方法的符号引用写到main方法里的两条invokevirtual指令的参数中。

全部依赖静态类型(引用的类型)来定位具体执行方法版本的分派动做称为静态分派,静态分派的典型应用就是重载。静态分派发生在编译阶段,所以肯定静态分派的动做是由编译器执行。另外,编译器能肯定方法重载的版本,但重载版本有时并非惟一的,每每只能选择一个更加合适的版本。好比sayHello(int)、sayHello(long)、sayHello(char),若是方法调用为sayHello(‘a’),那么首先会调用sayHello(char),若是没有sayHello(char)方法,就会调用sayHello(int),而后才是sayHello(long)。

    2. 动态分派——重写

public class DynamicDispatch {
	static abstract class Human{
		public abstract void sayHello();
	}
	static class Man extends Human{
		public void sayHello(){
			System.out.println("man");
		}
	}
	static class Woman extends Human{
		public void sayHello(){
			System.out.println("woman");
		}
	}
	public static void main(String[] args) {
		Human man=new Man();
		Human woman=new Woman();
		man.sayHello();
		woman.sayHello();
		
	}
}
//执行结果
man
woman

(1)在这里天然不可能根据静态类型来决定方法的调用,而是经过对象的实际类型来找到相应的方法。

man和woman这两个对象是将要执行的sayHello方法的全部者,也成为接收者,而编译后的字节码文件中两行sayHello方法的调用指令invokevirtual执行的方法经过索引值(索引值指向常量池中的符号引用,该符号引用对应方法 Human.sayHello())来看是同一个方法,但最终执行的目标方法却不一样。这就是由于invokevirtual指令在运行时解析方法的符号引用的过程大概以下

  • 找到操做数栈顶的第一个元素所指向的对象的实际类型(由于调用方法首先会把引用从局部变量表压入操做数栈顶,而后经过引用找到对象),记为类型C。
  • 若是在类型C中找到与索引值对应的常量池中的常量中描述符和简单名称都相符的方法,则进行权限校验,若是经过则返回这个方法的符号引用所对应的直接引用,查找过程结束;若是权限校验不经过,则抛出java.lang.IllegalAccessError异常。
  • 不然按照继承关系从子类向上对C的父类进行第2步的查找和验证过程。
  • 若是始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

invokevirtual指令的执行就是方法重写的本质,在运行期根据实际类型肯定方法执行版本的分派过程称为动态分派。

    3. 单分派与多分派

方法的接收者和方法的参数统称为方法的宗量,单分派就是根据一个宗量对目标方法进行选择,多分派就是根据多个宗量来对目标方法进行选择。Java中,静态分派(好比重载)经过接收者的静态类型以及方法参数进行选择目标方法,因此Java的静态分派是多分派类型。而动态分派(重写)只依据接收者的实际类型来选择目标方法,也就是一个宗量,因此动态分派也是单分派类型。因此,Java语言是一门静态多分派,动态单分派的语言。

    4. 动态分派的优化实现

    动态分配的方法选择过程当中须要运行时在类的方法元数据中搜索合适的目标方法,并且动态分派动做很频繁,因此为了优化虚拟机性能,会为类在虚拟机的方法区中创建一个虚方法表(专门存储虚方法索引的,调用该方法时会执行invokevirtual字节码指令的方法,而对应的,在invokeinterface执行时也会有接口方法表),使用虚方法表索引来代替元数据查找。

    虚方法表中存放着各个方法的实际入口地址,若是某个方法在子类中没有被重写,那么子类的虚方法表里面的该方法地址入口和父类中的虚方法表里面的该方法是同样的,都指向父类的实现入口;若是过子类重写了该方法,那么子类方法表中的地址将会替换为指向子类实现版本的入口地址。

    方法表通常在类加载的链接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

    除了上面分派调用的优化手段以外,还有内联缓存和守护内联两种方法来获取更高性能。

相关文章
相关标签/搜索