深入理解Java虚拟机笔记(五)虚拟机字节码执行引擎

概述

从外观上看,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。

栈帧组成:方法局部变量表,操作数栈,动态链接和方法返回地址等信息

每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程

在这里插入图片描述

局部变量表

局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,它允许Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。

一个slot:有boolean、byte、char、short、int、float、reference和returnAddress

reference类型表示对一个对象实例的引用,虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束约束。

returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。

两个slot:long和double

为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过,这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为

情况1

/**
*-verbose:gc
**/
public class testOne {
    public static void main(String[] args){
        byte[] placeholder=new byte[64*1024*1024];
        System.gc();
    }
}

在这里插入图片描述

执行gc时变量placeholder还处于作用域之内,虚拟机自然不会去回收。

情况2

/**
 * -verbose:gc
 */
public class testTwo {
    public static void main(String[] args) {
        {
            byte[] placeholder = new byte[64 * 1024 * 1024];
        }
        System.gc();
    }
}

在这里插入图片描述

情况3

/**
 * -verbose:gc
 */
public class testThree {
    public static void main(String[] args) {
        {
            byte[] placeholder = new byte[64 * 1024 * 1024];
        }
        int a = 0;//可手动将placeholder设为null值来替代
        System.gc();
    }
}

在这里插入图片描述

回收的原因

placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。

第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为null值(把变量对应的局部变量表Slot清空)不见得是一个绝对无意义的操作,这种操作可以在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件)来使用。

不过需要注意JIT优化后该操作已被清除,第二种情况GC也会进行垃圾回收。

还需注意的要点

局部变量没有两次初始化,所以局部变量需要程序员手动初始化后才能使用。

操作数栈

一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,方法执行过程中会有各种字节码指令往操作数栈中写入和提取内容,即出/入栈操作。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。

大多数虚拟机中的实现中都会做一些优化处理,令两个栈帧出现一部分重叠。这样方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。

在这里插入图片描述

动态链接

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。

这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。

另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

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

  • 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口
  • 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能
继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行
状态。

一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复
上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈
中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

方法调用

方法调用不等于方法执行,方法调用的任务是确定被调用方法的版本(即哪一个方法)

解析

类加载解析阶段,会将其中的一部分符号引用转化为直接引用。

前提:方法在程序真正运行之前有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。即编译器可知,运行期不可变

主要包括静态方法和私有方法两大类,它们都不可能通过继承或别的方式重写其他版本

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。

Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

而分派调用则可能是静态的也可能是动态的

分派

静态分派

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("guy");
    }
    public void sayHello(Man man){
        System.out.println("man");
    }
    public void sayHello(Woman woman){
        System.out.println("woman");
    }
    public static void main(String[] args){
        Human man=new Man();
        Human woman=new Woman();
        StaticDispatch staticDispatch=new StaticDispatch();
        staticDispatch.sayHello(man);
        staticDispatch.sayHello(woman);
    }

}

在这里插入图片描述
在这里插入图片描述

静态类型不可变,实际类型会发生多次变化,Javac编译器会根据参数的静态类型决定使用哪个重载版本

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,典型应用是方法重载。

不过所选择的重载版本可能不是唯一的 会选择出更加合适的版本

动态分派

和多态另外一个重要的体现—重写有很大的关联

在运行器确定了接收者的实际类型

单分派与多分派

单分派是根据一个宗量对目标方法进行选择

多分派是根据多于一个宗量对目标方法进行选择

静态分派选择目标方法的依据有静态类型和方法参数,属于多分派

多态分派选择目标方法的依据是接受者的实际类型,属于单分派

虚拟机动态分派实现

建立虚方法表

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_43958969/article/details/95315043