《深入理解Java虚拟机》读书笔记7-虚拟机字节码执行引擎

时间:2022-12-27 19:45:36

虚拟机字节码执行引擎

 

  启动java程序,包含程序入口main方法的class文件将会率先被JVM获取到,然后就是类加载阶段处理这个class文件,最终通过调用man方法开始一个java程序的执行。可以说Java程序的执行就是一个或多个方法调用链,而初始方法就是main。接下来我们介绍java方法的内存模型-栈帧和调用机制。

一、运行时栈帧结构

  在运行时内存区域那一节我们介绍过-程私有的虚拟机栈,虚拟机栈的栈元素是-栈帧,每个栈帧包含局部变量表、操作栈、返回地址、动态链接等信息。每个方法对应于一个栈帧,一个方法的执行对应于栈帧的入栈和出栈过程。一个线程的虚拟机栈的模型图可如下所示:  

《深入理解Java虚拟机》读书笔记7-虚拟机字节码执行引擎

 

其中,只有栈顶的栈帧是有效的,称为当前栈帧,对应的方法称为当前方法。所有的字节码指令都是针对当前栈帧操作的。

1、局部变量表

  局部变量表用于存储方法的参数和局部变量,有以下需要注意项:

  • l  局部变量表索引从0开始,按照参数顺序和局部变量定义顺序存储
  • l  对于实例方法,隐含参数this指向实例对象,存储于索引0
  • l  局部变量表的大小在类编译为class文件时就已经固定,记录在方法表的code属性表的max_locals项。
  • l  局部变量表的最小单位为Slot(变量槽),虚拟机规范只规定了每个slot能够存储下一个boolean,byte,char,short,int,float,reference和returnAddress类型的数据(没有固定指明是32位,也可以使用64位通过对齐和补白方式使外观一致。对于32位的虚拟机,long和double类型需要两个slot存储,此时两个slot的操作必须绑定,不允许单独访问一个)。
  • l  Slot是可复用的,当字节码PC计数器超过了slot中存储的局部变量的作用域,该局部变量失效,则可以再次使用该slot。(可能会存在一个局部变量已经失效,但还未将slot分配给新的局部变量,此时若原局部变量是一个reference类型,仍然持有了一个对象的引用,则可能导致该对象无法被垃圾收集器回收。之所以是可能是因为我们是在概念模型上讨论,实际虚拟机的实现没有固定,比如当使用JIT编译后,就可以正常回收。)
  • l  栈帧中的局部变量没有类变量的准备阶段,所以不会说默认值为0,一定需要先赋值后使用
 2、操作栈
  • l  操作栈也称为操作数栈,是一个先入后出的栈结构。
  • l  每个元素可以是java的任意类型。需要注意的是栈中的元素类型必须和字节码指令序列对应,不允许存在iadd指令,但栈顶是两个float或其他非int类型的栈元素。
  • l  多虚拟机实现时,会让两个栈帧的局部变量表和操作栈共享部分区域,,减少参数复制传递。
3、动态链接、返回地址以及附加信息

  动态链接是指:栈帧中拥有指向运行时常量池中该栈帧所属方法的引用,从而实现动态链接(不懂~~)。

  返回地址:方法返回方式有两种正常方法出口异常方法出口。两种方式都需要回到方法调用者,返回地址用于记录调用者原先的PC计数器。

  还有可能存在有关调试的一些附加信息。把动态链接、返回地址、附加信息归为一类,统称为栈帧信息。

二、方法调用

  介绍完方法的内存模型,接下来我们看看方法调用(即确定调用哪一个方法的过程,因为java并没有传统的连接步骤,方法调用在class文件中存储的只是符号引用)。

1、解析 

  在类加载的解析阶段,部分符号引用会转化为直接引用。这类符号引用转化成功的前提是:方法在程序真正运行前就确定了可用版本,而且这个方法版本在运行期是不可变的。把调用目标在程序代码写好、编译器进行编译时就必须确定下来的方法的调用称为解析(即编译器可知,运行期不可变)。

   Java语言中符号这个条件的有静态方法、私有方法、实例构造器、父类方法、以及使用final修饰的方法这5类,即使使用invokestatic、invokespecial字节码调用的和final修饰的方法,这些方法称为非虚方法。与之相对的是虚方法(即被invokevirtual、invokeinterface、invokedynamic字节码调用的方法,除final方法)。

  解析调用是一个静态过程,在解析阶段就会获得方法的直接引用。

2、分派

  除了解析调用,还有分派调用,但需要注意这两种方式不是在非此即彼,他们不是在同一层次上。分派根可分为静态分派动态分派。而通过分派所依据的宗量又可分为单分派多分派宗量包括方法的接收者和方法的参数。以下从java语言多态的重载和重写来具体描述。

2.1 重载

 

abstract class Human{}

class Man extends Human{}

class Woman extends Human{}

public class Test{

 public static sayHello(Human human){

  System.out.println(“hello human”);

}

public static sayHello(Man man){

  System.out.println(“hello man”);

}

 public static sayHello(Woman Woman){

  System.out.println(“hello woman”);

}

 

 public static void main(String[] args){

  Human man = new man();

 Human woman = new Woman() ;

 Test t = new Test() ;

 t.sayHello(man);

 t.sayHello(woman) ;

}

}

 

上面代码的执行结果将是:

 

Hello human

Hello human

 

对于:

 

Human man = new Man();

  

我们把Human称为变量的静态类型(或外观类型),后面的Man称为变量的实际类型。其中变量本身的静态类型并不会变化,只是在使用时可以改变,如:

 

t.sayHello((Man) man) ;

 

而实际类型的变化只有在运行期才可知。 

  再看源代码,之所以是这个结果,是因为javac编译器会根据参数的静态类型决定使用哪个版本的方法,使用javap –verbose获得字节码指令可以看到t.sayHello(man)翻译之后是invokevirtual后面跟了sayHello(Human human)这个方法的符号引用。这种依据静态类型来定位方法执行版本的分派动作称为静态分派,上述重载代码就是典型例子。我们还能知道这个过程是根据方法的调用者Test t和方法参数两个宗量确定的,所以静态分派也是多分派。

    此外,需要注意,方法重载在遇到多个适用方法时,会按一定的优先级选择更加合适的版本比如:

 

char c = ‘a’ ;

sayHello(c) ;

//存在如下的方法:

sayHello(Char c);

sayHello(int c);

sayHello(double c);

sayHello(Character c);

会按char->int->long->float->double的顺序转型进行寻找最适方法,若没有则char->Character或Comparable<Character>->Object等,若有变长参数char… arg,优先级最后

 

2.2 重写

 

abstract class Human{

 abstract void sayHello() ;

}

class Man extends Human{

 void sayHello(){

  System.out.println(“hello man”);

 }

}

class Woman extends Human{

 void sayHello(){

  System.out.println(“hello woman”) ;

 }

}

public class Test{

 public static void main(String[] args){

  Human man = new Man() ;

  man.sayHello();

  Human woman = new Woman();

  woman.sayHello() ;

 }

}

 

运行结果为: 

hello man

hello woman

  为什么是这个运行结果呢?通过javap –verbose输出字节码指令可以看到调用语句为invokevirtual #Human.sayHello:()V,即指令后面跟的方法符号引用是指向Human父类的方法,但这个指令还有个参数是取栈顶的引用类型,作为方法的调用者,也就是说在运行期才获得了存在于栈顶的引用指向的对象作为方法调用者,这样才实现了重写时正确定位方法版本。这种在运行期才确定方法执行版本的分派过程称为动态分派。此外,此时方法的版本选择只和方法的调用者有关,所以动态分派属于单分派类型

   上面只是说了大致的过程,在具体实现中,动态分派要去方法元数据区搜索合适的目标方法,因此基于性能的考虑会使用虚方法表(针对接口则是接口方法表)。如下所示:

《深入理解Java虚拟机》读书笔记7-虚拟机字节码执行引擎

 

通过方法表的索引来代替元数据查找以提高性能。需要注意的是相同签名的方法,在父类和子类的虚方法表都应当具有一样的索引号,这样类型变换时只需更换要查找的方法表。方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,会把该类的方法表也初始完毕。

三、基于栈的字节码解释执行

        Java字节码基本上算是一种基于栈的指令集架构(也不算完全,因为指令偶尔后面会带参数),相对的是基于寄存器的指令集。基于栈的优点是可移植,不受限于硬件提供的寄存器,代码相对紧凑等,但基于栈需要额外的出栈和入栈操作,更重要的是基于栈则意味着访问内存,这样比访问寄存器慢很多,所以性能相对较低。