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

  1. 云栖社区>
  2. 博客>
  3. 正文

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

江左煤郎 2018-08-31 17:19:28 浏览1161
展开阅读全文

1. 运行时栈帧结构

1.1 认识栈帧

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

timg?image&quality=80&size=b9999_10000&s

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”)

 

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,而对于下一段代码

 

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执行时也会有接口方法表),使用虚方法表索引来代替元数据查找。

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

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

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

网友评论

登录后评论
0/500
评论
江左煤郎
+ 关注