JVM之虚拟机字节码执行引擎(八)

虚拟机的执行引擎是自己实现的,有自己的指令集和执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。(物理机执行引擎是建立在处理器、硬件、指令集和操作系统层面)。
但在不同的虚拟机实现里,执行引擎在执行java代码的时候,可能会解释执行和编译执行,也可两者兼备,但外观看起来都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

栈帧是随着方法调用创建,随着方法结束而销毁,它的大小:局部变量表、最大操作数栈等都在编译确定,因此大小只在虚拟机具体分配的内存。
      栈帧是支持虚拟机进行方法调用和方法执行的数据结构,是运行时数据区虚拟机栈的栈元素。存储了局部变量表、操作数栈、动态连接和方法返回地址(方法正常退出pc值,但异常退出则需要异常表去确定)等信息。
每一个方法的调用开始至执行完成都对应一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译代码的时候,栈中需要多大的局部变量表,多深的操作数栈都已经完全确定并在方法表的code属性中存储。
1》局部变量表
是一组变量值存储空间,用于存放方法参数和方法内局部变量,在code中已经确定,单位为slot。虚拟机在方法执行的时候,是使用局部变量表完成参数值到参数列表的传递过程,如果是实例方法则在局部变量表的第0位索引的slot就是指向该方法的所属实例对象的引用(this),而类方法就不会有。且局部变量表中的slot是可以重复使用的,当超出作用域后就可以被其他变量使用。
虽然有时候建议不是用的大对象直接赋值null来让GC回收,但从编码的角度来讲可以用恰当的变量作用域来来控制变量的回收时间才是最优雅的方式。还有就是如果经过JIT编译成本地代码后,赋值null会被优化后消除,赋值null没有意义(从这点可以看出赋值null有些时候也是可以的,毕竟不是所得代码都能被JIT编译)
类变量有准备阶段或初始化阶段赋值操作,而局部变量表没有,所以必须赋值后才能用,否则编译报错。
2》操作数栈
操作数栈中每一个元素都可以是任意一个类型java数据类型,32位所占容量为2,62位所占容量为2。
对栈进行操作,调用其他方法是通过操作数栈来进行参数传递的或者调用其他方法返回值放在调用者的操作数栈里。
概念模型两个栈帧是相互独立的,但大多数虚拟机实现里都会做出一些优化处理,令上面的部分局部变量表与下面的栈帧的部分操作数栈重叠,这样进行方法调用时就可以共用一部分数据,无需额外的参数复制。(面向操作数栈)
3》动态连接
每一个栈帧都包含一个指向运行时常量池(Class文件中的常量池信息在类加载后存入此)中该栈帧所属方法的引用(区别于局部变量表可能会有的this指向所属实例对象),持有这个引用是为了支持方法调用过程中的动态连接。字节码指令需要以常量池中的符号引用作为参数,这些引用在类加载阶段或第一次使用时转为直接引用,那么属于静态解析,另一部分将在运行期间转化为直接引用,称为动态连接。(第七章类加载中:加载 验证 准备 (没有解析)初始化 卸载这五个顺序固定,解析替换地址的时候可能会在运行期间 动态连接
4》方法返回地址
方法执行后两种退出方式:
》遇到方法返回字节码指令,如果有返回值则把返回值放入调用者的操作数栈中,称为正常完成出口;
》没有在本方法对这个异常进行处理,则称为异常完成出口,这种方式不会给调用者返回任何值;
不过无论哪种方式退出都要返回方法被调用的位置,程序才能继续执行:正常退出时,栈帧中可能会保存的pc值可以返回地址,但异常退出,就只能用异常处理器表来确定。
方法退出等效于当前栈帧出栈:恢复上层方法的局部变量表和操作数栈,如果有返回值则把返回值压入操作数栈中,调整pc值指向方法调用指令后一条指令。
5》附加信息
虚拟机规范允许具体虚拟机实现增加一些规范里没有描述的信息到栈帧中,比如调试信息,这部分信息取决于具体虚拟机实现。

方法调用

方法调用并不是方法执行,此阶段唯一任务就是确定被调用方法的版本(即调用哪一个方法),由于java在编译期间没有连接,虽然使得java有更强大的动态扩展能力,但也使方法调用变得复杂。需要在类加载期间甚至运行期间才能确定目标方法的直接引用。
1》解析
由于方法调用的目标方法都是常量池中的符号引用,转为直接引用:
静态解析;类加载解析阶段就转为直接引用前提:方法在真正运行之前只有一个可以确定的调用版本,并且这个方法的调用版本在运行期是不可变的。即编译期就确定下来,主要是静态方法(与类型直接关联)和私有方法(外部不可访问)两大类。其实只要能被invokestatic和invokespecial指令调用的方法都可以在解析阶段确定调用版本,符合这个条件的方法有静态方法、私有方法、实例构造器、父类方法。这部分在类加载解析阶段转为直接引用。还有一种是被final修饰的方法,虽然使用invokevritual指令来调用,但无法被覆盖,所以也无需多选择。
解析调用是一个静态过程,在编译器就确定,而分派调用则可能是静态也可能是动态。
2》分派
对于多态中“重载”“重写”在java虚拟机中是如何实现的,即java虚拟机如何确定正确的目标方法?
静态分派
重载:

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 guy){
    System.out.println("hello gentleman!");
}
public void sayHello(Woman guy){
    System.out.println("hello ,lady!");
}
public static void main(String[] args) {
    Human man=new Man();
    Human woman=new Woman();
    StaticDispatch sd= new StaticDispatch();
    sd.sayHello(man);
    sd.sayHello(woman);
    //sd.sayHello((Man)man);
    //sd.sayHello((Woman)woman);
}
}
运行结果:
hello,guy!
hello,guy!
//hello gentleman!
//hello ,lady!

为什么都会执行参数为Human类型的重载那?
Human称为变量的静态类型(外观类型),Man则称为实际类型。两者都以发生一些变化:(实际类型变化)Human man=new Man();(静态类型变化 外观原来是Human)sd.sayHello((Man)man),实际类型的变化只有在运行期才可确定,编译器在编译的时候并不知道对象的实际类型(认为是human);静态类型的变化仅仅发生在使用时,变量本身(man还是Human类型指向却是Man类型)静态类型不会改变,最终的静态类型是在编译期可知的(变量的类型)。
在方法接受者是对象sd前提下,使用那个版本取决于传入的参数类型和数量。虽然参数实际类型不同,但虚拟机(的锅)在重载时是根据参数的外观类型来作为依据的,所以编译器选择那个版本并生成字节码(虚拟机标准产生这种现象,重载(参数)判断依据就是参数外观类型重写(调用对象)就是根据实际的调用者)。这种依赖静态类型定位执行方法的分派动作是静态分派,但分派也只是找最合适的 比如‘a’ 有字符char类型优先匹配,如果没有转为字符串,再或者int类型…(char->int->long->float->double)
注意:解析与分派这两者之间的关系并不是二选一的排它关系,他们是在不同层次上去筛选、确定目标方法的过程。比如:静态方法在类加载期进行解析,而选择静态方法重载版本也是通过静态分派完成(编译器可知,编译器确定版本)
动态分派它与多态的另一个重要体现重写有着密切的关联。

//查看编译class文件发现每个子类都会生成一个Class文件(不管静态非静态) 比如DynamicDispatch$Woman.class
public class DynamicDispatch {
 static abstract class Human{
     //抽象类如果是非抽象的方法则需要写方法体
     protected abstract void sayHello();
 }
 static class Man extends Human{

    @Override
    protected void sayHello() {
        // TODO Auto-generated method stub
        System.out.println("man say hello!");

    }

 }
 static class Woman extends Human{

    @Override
    protected void sayHello() {
        // TODO Auto-generated method stub
        System.out.println("woman say hello!");
    }

 }
 public static void main(String[] args) {
    Human man=new Man();
    Human woman=new Woman();
    man.sayHello();
    woman.sayHello();
    man =new Woman();
    man.sayHello();
}
 }
 运行结果:
 man say hello!
woman say hello!
woman say hello!

这里写图片描述
从字节码图中可以看出man和woman对于方法调用都是指向同一个符号引用,但最终的执行方法却不一样;主要是因为invokevirtual指令的多态查找过程说起:
1)找到操作数栈顶的第一个蒜素所指向的对象的实际类型,记作C;
2)如果在类型C中找到与常量中的描述符和简单名称相符的方法,则进行访问权限检验,通过则返回这个方法的直接引用,查找结束。如果权限效验不通过,则返回非法访问异常。
3)否则,按照继承关系从下往上对C类父类进行步骤2的搜索和验证。
4)如果最终也没有找到合适的方法,抛出java.lang.AbstractMethodError异常。
这个是根据invokevirtual指令运行解析来实现的。
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,如果确定一个方法只根据一个宗量比如参数则是单分派,否则是多分派。
这里写图片描述
从上图中可以看出在编译器进行静态分派的时候,根据调用者和参数进行分派(对比1、2、3)首先根据调用者的静态类型进行选择对象的方法,然后综合参数做出最终选择(看来静态分派并不限于重载中的方法参数)。静态分派属于多分派。
而在执行“son.hardChoice(new QQ())”,invokevirtual指令执行时,由于编译期已经确定方法签名为hardChoice(QQ),所以不会考虑参数(不管你是QQ还是其子类),这个时候可以影响虚拟机的只有方法的调用者是son还是father,因此动态分派属于单分派。
java是静态分派多分派,动态分派单分派语言。
这里有一点需要注意与我写的前面多态文章联系这样才能理解更深:点击
比如:静态分派的时候,ab.show(bb);选择调用对象A.show(B),但A中没有这个方法,由于B继承自A,所以分派成A.show(A),invokevirtual执行时动态查找根据实际类型查找,先查B.show(A),如果有则查找结束,否则查父类A.show(A)指令先查找方法,并输出“A-A”。
虚拟机动态分配的实现
动态分派是非常频繁的动作,而且动态分派的方法版本选择需要运行时在类的方法元数据中搜索合适的目标方法。基于性能考虑,在虚拟机实现的时候,在类的方法区建一个虚方法表,通过方法表的索引来提高查找性能。
虚方法表里面存着各个方法的实际入口地址,如果某个方法在子类中没有重写,则子类的虚方法表里面地址入口与父相同方法的入口地址一样,都指向父类的实现入口。如果子类重写了这个方法,子类方法表的地址将会替换为指向子类实现版本的入口地址。
java 对动态语言的支持
动态语言;关键特征是类型检查在运行期间而不是编译期间。
java.lang.invoke包
这个包的主要目的是除在之前单纯依靠符号引用来确定调用的目标方法方式外,提供一种新的确定目标方法的机制,称为MethodHandle。在C/C++中,可以把函数指针作为参数进行传递,java一般需要对象实现Comparator接口中的compare()方法作为参数,而java也可以获得方法的MethodHandle并作为参数传递。

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

public class MethodHandleTest {
    static class ClassA{
        public  void println(String s){
            System.out.println(s);
        }
    }
    public static void method(String s,MethodHandle mh) throws Throwable{
        mh.invokeExact(s);
    }
    private static MethodHandle getPrintlnMH(Object receiver) throws Throwable{
        MethodType mt=MethodType.methodType(void.class, String.class);
        //这里调用的一个虚方法,按照java语言的规则,方法的第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,以前是放在参数列表中进行传递的,而现在提供bindto()方法来完成这件事情
        return MethodHandles.lookup().findVirtual(receiver.getClass(),"println", mt).bindTo(receiver);
    }
    public static void main(String[] args) throws Throwable{
        Object obj=System.currentTimeMillis()%2==0?System.out:new ClassA();
        getPrintlnMH(obj).invokeExact("Ha hahaha");
        method("a ou",getPrintlnMH(obj));

    }
}
结果:
Ha hahaha
a ou

实际上getPrintlnMH()模拟了invokevirtual指令的执行过程(确定目标方法),只不过它的分派逻辑并非固化在class文件上的字节码上,而是通过一个具体的方法实现。这个方法的返回值(MethodHandle)可以作为一个最终调用方法的“引用”,以此为基础就可以写出类似下面的函数声明:
void sort(List list,MethodHandle compare)
可能会产生疑问,这种通过反射不就可以解决吗?但 他们有以下区别:
》从本质上讲,Reflection和MethodHandle机制都是在模拟方法的调用,但Reflection是模拟java代码层次的方法调用,而MethodHandle是模拟字节码层次的方法调用。
》Reflection中的java.lang.reflect.Method对象远比MethodHandle对象所包含的信息多;通俗点将Reflection是重量级的,MethodHandle是轻量级的。
》MethodHandle是对字节码的方法指令的模拟,所以理论上虚拟机在这方面做的优化都可以支持,反射则不行。
最关键一点是:Reflecttion设计的目标仅仅是为了 java语言服务的,而MethodHandle设计则是服务所有java虚拟机上的语言,当然也包括java。
invokedynamic指令
该指令面向对象并非java语言,所以仅依靠javac编译器没有办法生成带有invokedynamic指令的字节码,要使用java语言来演示这个指令就需要转换来完成(java虚拟机可以执行)。
与MethodHandle类似,为了解决原有4条invoke*指令方法分派固化在虚拟机之中,而如何把寻找目标方法的决定权从虚拟中转嫁到用户代码中,让用户有更高度自由度。只不过MethodHandle是采用上层java代码和API来实现,动态执行指令则是用字节码和class中的其他属性、常量来完成。
每一处含有invokedynamic指令的位置都称为动态调用点(Dynamic Call Site),这条指令的第一个参数不再是方法的符号引用CONSTANT_Methodref_info常量,而是CONSTANT_invokeDynamic_info常量;这个常量可以获得三项信息:
引导方法(Bootstrap Method)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值是Call Site对象,最终调用要执行的方法。
invokedynamic与前面四条指令最大的区别是它的分派逻辑没有固化在虚拟机,而是程序员决定的。下面通过一个例子来看:调用父类的父类的方法

import static java.lang.invoke.MethodHandles.lookup;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

class Test {

class GrandFather {
    void thinking() {
        System.out.println("i am grandfather");
    }
}

class Father extends GrandFather {
    void thinking() {
        System.out.println("i am father");
    }
}

class Son extends Father {

     void thinking() {
          try {
//                MethodType mtt = MethodType.methodType(void.class);
//                MethodHandle mhh = lookup().findSpecial(GrandFather.class, 
//"thinking", mtt,this.getClass());
//                mhh.invoke(this);
            MethodType mt = MethodType.methodType(void.class);
            MethodHandle mh = lookup().findVirtual(GrandFather.class,"thinking",mt).bindTo(new GrandFather());
            mh.invokeExact();

            } catch (Throwable e) {
            }
        }
    }

    public static void main(String[] args) {
        (new Test().new Son()).thinking();
    }
}
输出结果:
i am grandfather

书中给出的;例子有误,发现并不能得到输出结果,有评论说是规范改了,原来可以,后来限制不能用超类方法,所以就有别的方式获取。
基于操作数栈指令集和解释器

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值