深入JAVA虚拟机之字节码执行引擎

前言:
class文件结构、类加载机制、类加载器、运行时数据区这四个java技术体系中非常重要的知识,学习完了这些以后,我们知道一个类是通过类加载器加载到虚拟机,存储到运行时数据区,而且我们也知道了我们方法体内的代码被编译成字节码保存在方法表中的code属性中,那么虚拟机又是怎么执行这些代码的,得出方法输出结果的呢?这一节我们就要来学习,关于虚拟机字节码执行引擎的相关知识。通过这章节的学习,我们要掌握一下知识点:

1.运行时栈帧结构
2.方法调用
3.基于栈的字节码执行引擎

运行时栈帧结构

栈帧是用于支持方法调用和方法执行的数据结构。他是虚拟机运行时数据区中虚拟机栈中的栈元素。栈帧存储了:方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法调用从开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
深入JAVA虚拟机之字节码执行引擎

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

操作数栈
操作数栈也常被称为操作栈,它是一个后入先出栈。跟局部变量表一样,操作数栈的最大深度也在编译的时候被写入Code属性的max_stacks数据项中。当方法开始执行的时候,这个方法的操作栈是空的,在方法执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈和出栈操作。比如说做算术运算的时候通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

动态连接
每一个栈帧都包含一个指向运行时常量池的该栈帧所属的方法引用,持有这个引用是为了支持方法调用过程中的动态连接。通过Class文件结构我们知道,常量池中存在大量的符号引用,字节码中方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候转化为直接引用,这种转化叫做静态解析。而另外一部分将在每一次运行期间转化为直接引用,这部分叫做动态连接。

方法返回地址
当一个方法被执行后,有两种方式退出该方法,一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会返回值给上层调用者,这种方式叫做:正常完成出口。
另外一种方式就是方法执行遇到异常,并且异常在方法体内没有被处理,这时候就会导致退出方法,这种方式叫做:异常完成出口。异常完成出口方式是不会给上层调用者任何返回值的。

方法返回地址等同于当前栈帧出栈,恢复上层栈帧的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈,pc计数器加1,执行pc计数器的值指向的方法调用指令。

方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(调用哪个方法),还没有涉及到方法体内的具体运行过程。那么我们都知道java有方法重载和方法重写,那么如何确定调用方法的版本呢?一切方法调用在Class文件里面存储的只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这个特性给java带来了更强大的动态扩展能力,但也使得java方法调用过程变得相对复杂起来,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。

在类加载的解析阶段,会将一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个确定的调用版本,并且这个方法的调用版本在运行期间不可改变的,这种调用被称为解析调用。符合“编译期可知,运行期不可变"的方法主要有静态方法和私有方法两大类。这两种方法都不可能通过继承或者别的方式重写出其他版本,因此他们都适合在类加载阶段进行解析。

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

与之相对应,在JAVA虚拟机中提供了四条方法调用字节码指令,分别是:
invokestatic:调用静态方法
invokespecial:调用实例构造方法,私有方法和父类方法
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

只要能被invokestatic \invokespecial指令调用的方法,都可以在解析阶段确定唯一调用版本,比如说静态方法、私有方法、实例构造器和父类方法四类,在类加载的时候就会把符号引用转化为直接引用,这类方法又称为非虚方法。final方法虽然是用invokevirtual调用的,但是它无法覆盖,没有其他版本,在java语言规范中明确说明了final方法是一种非虚方法。

因为java具备面向对象的三大基本特征:继承、封装、多态。多态的基本体现就是重载和重写,那么重载和重写的方法在虚拟机如何确定正确的目标方法?

分派调用
分派调用可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派,两类组合就构成了静态单分派、静态多分派、动态单分派、动态多分派。我们来看一下下面这段代码。

import com.sun.deploy.net.proxy.StaticProxyManager;

import java.util.Map;

/**
 * @Author:Administrator.
 * @CreatedTime: 2018/8/13.
 * @EditTime:2018/8/13.
 * @Version:
 * @Description:
 * @Copyright: 
 */
public class StaticDispatch {

    static abstract class Human {

    }

    static class Man extends Human {

    }

    static class Women 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(Women guy) {
        System.out.println("Hello,lady!");
    }

    public static void main (String[] args) {
        Human women = new Women();
        Human man = new Man();
        StaticDispatch sd = new StaticDispatch();
        sd.sayHello(women);
        sd.sayHello(man);
    }
}

执行结果是:
Hello,guy!
Hello,guy!

有经验的开发者,一看就能看出结果来,那为什么虚拟机会调用参数为Human的sayHello方法呢?在说明这个之前,我们先来理解两个概念:
Human man = new Man();
Human是变量的静态类型或者叫外观类型,而Man是变量的实际类型。静态类型和实际类型在程序中都可以发生一些变化,区别在于静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变。并且最终的静态类型是在编译期可知的,而实际类型变化的结果在运行期才可以确定,编译器在编译程序的时候并不知道一个对象实际类型是什么。

所以回到上面代码,main方法中两次调用sayHello方法,使用哪一个版本就完全取决于传入参数的数量和数据类型。代码中刻意定义了两个静态类型相同,实际类型不同的变量,但是虚拟机(具体的说应该是编译器)在重载时是通过参数的静态类型而不是实际类型作为判断依据的。并且静态类型是在编译期可知的,所以在编译阶段,javac编译器就根据参数的静态类型决定使用哪一个版本,所以选择了sayHello(Human)作为调用目标。

静态分派

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派最典型的例子就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是虚拟机来执行的。更多时候重载的版本不是唯一的,往往只能确定一个更合适的版本。看下面代码:

/**
 * @Author:Administrator.
 * @CreatedTime: 2018/8/13.
 * @EditTime:2018/8/13.
 * @Version:
 * @Description:
 * @Copyright: 
 */
public class StaticDispatch {

    public static void sayHello(Object obj) {
        System.out.println("Hello,object!");
    }

    public static void sayHello(int c) {
        System.out.println("Hello,int!");
    }

    public static void sayHello(double c) {
        System.out.println("Hello,double!");
    }

    public static void sayHello(float c) {
        System.out.println("Hello,float!");
    }

    public static void sayHello(long c) {
        System.out.println("Hello,long!");
    }

    public static void sayHello(Character c) {
        System.out.println("Hello,Character!");
    }

    public static void sayHello(char c) {
        System.out.println("Hello,char!");
    }

    public static void sayHello(char... c) {
        System.out.println("Hello,char...!");
    }

    public static void sayHello(Serializable c) {
        System.out.println("Hello,Serializable!");
    }

    public static void main (String[] args) {
        sayHello('c');
    }
}

执行结果是:Hello,char!,然后我们把sayHello(char c)注释再执行,发现结果是:Hello,int!
这里发生了自动转换的过程,‘c’->65;我们继续注释掉这个方法继续执行,输出结果为Hello,long!这里就发生了两次自动转换:c->65->65L.这一的方式自动转换可以持续多次:char->int->long->float->double.注释掉double参数重载方法后执行:Hello,Character!这里就存在一次自动装箱的过程。那么我们继续把这个方法注释掉,继续执行,发现:Hello,Serializable!这里跟序列化有什么关系呢?自动装箱后,发现还是找不到匹配的参数类型,却找到了装箱类实现的接口Serializable,那么就继续自动转型,注意这里封装类型Character是不能够转换成Integer的,它只能安全的转换为它实现的接口或者父类。Character还实现了一个java.lang.comparable<Character>接口,如果同时出现Serializable、comparable<Character>的方法重载时,它的优先级是一样的,这个时候编译器就会报错:类型模糊,编译不通过。这个时候必须指定对应的接口才能通过编译(如sayHello(comparable<Character> 'c'))。继续注释掉Serializable参数的重载方法,执行!这个时候就是Hello,object!自动装箱后转为父类类型,如果有多重继承,那么由下往上找,越往上优先级越高。继续注释,最后就执行char...变长参数的重载方法,由此可见变长参数的匹配优先级是最低的。这个例子就是java实现方法重载的本质,这个例子是个极端例子,通常工作中是几乎没有用的,一般都是放到面试题里“为难”一下面试者。

动态分派
我们了解了静态分派后,我们继续看下动态分派是如何实现的.动态分派是多态特性的另一个重要体现重写(override).看如下代码:


/**
 * @Author:Administrator.
 * @CreatedTime: 2018/8/13.
 * @EditTime:2018/8/13.
 * @Version:
 * @Description:
 * @Copyright: 
 */
public class StaticDispatch {

    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man Say Hello!");
        }
    }

    static class Women extends Human {
        @Override
        protected void sayHello() {
            System.out.println("Women Say Hello!");
        }
    }

    public static void main (String[] args) {
        Human man = new Man();
        Human women = new Women();
        man.sayHello();
        women.sayHello();
        man = new Women();
        man.sayHello();
    }
}

运行结果:
man Say Hello!
Women Say Hello!
Women Say Hello!

相信这个运行结果肯定都在你们预料之中的,因为习惯了面向对象编程的你们来说这是理所当然的了。但虚拟机怎么知道该调用哪一个方法的呢?显然这里是无法通过参数静态类型来确定!
Human man = new Man();
Human women = new Women();
这两行在内存中分配了man和women的内存空间,调用man和women的实例构造器,把两个实例放到局部变量表的第一和第二个slot位置上。
深入JAVA虚拟机之字节码执行引擎

在运行期将符号引用转化为直接引用,所以man和women被解析到不同的直接引用上,这过程就是方法重写的本质。我们把运行期根据实际类型确定方法版本的分派过程叫做动态分派。

基于栈的字节码解释执行引擎

上面已经把java虚拟机是如何调用方法讲完了,那么接下来就是虚拟机是怎么执行这些字节码指令的.虚拟机在执行代码时都有解释执行和编译执行两种选择。

解释执行
java语言刚开始的时候被人们定义为解释执行的语言,在jdk1.0来说是比较准确的,但随着虚拟机的发展,虚拟机中开始包含了即时编译器后,class文件中的代码到底是解释执行还是编译执行恐怕只有虚拟机自己才能判断了。

不过不管是解释还是编译,不管是物理机还是虚拟机,对于应用程序,机器肯定是无法像人一样阅读和理解,然后获得执行能力。大部分的程序代码到物理机或者虚拟机可执行的字节码指令集,都需要经历多个步骤,如下图,而中间那条就是解释执行的过程。
深入JAVA虚拟机之字节码执行引擎

Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历树生成线性的字节码指令流的过程。因为一部分在虚拟机外,而解释器在虚拟机的内部,所以java程序的编译就是半独立的实现。

基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流里面大部分都是零地址指令看,他们依赖操作数栈进行工作。与之相对的另外一套常用指令集架构是基于寄存器的指令集。

两者优缺点:
1.基于栈的指令集主要优点就是可移植性,但因为相同的动作该指令集需要频繁操作内存,且多于寄存器指令集,速度就慢。
2.基于寄存器指令集主要优点就是速度快,操作少。但是因为寄存器是依赖于硬件的,所以它的移植性受到影响。

基于栈的解释器执行过程
这一内容通过一个一个四则运算进行讲解,下面是代码:

public int calc() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a+b) * c;
}

下面是字节码执行过程图(包含字节码指令、pc计数器、操作数栈、局部变量表):

深入JAVA虚拟机之字节码执行引擎

深入JAVA虚拟机之字节码执行引擎
深入JAVA虚拟机之字节码执行引擎
深入JAVA虚拟机之字节码执行引擎
深入JAVA虚拟机之字节码执行引擎

上面的演示,是一个概念模型,实际上肯定不会跟这个一样的,因为虚拟机中的解释器和即时编译器都会对输入的字节码进行优化。

总结:
学到这里,我们已经把Java程序是如何存储(Class文件结构),如何加载(类加载机制、类加载器),运行时数据区、以及如何执行的相关知识都学习完了。接下来我们应该进行学习的章节就是垃圾收集器和内存分配策略。怎么判断该对象所持有的内存可以回收了?回收主要在运行时数据区的那些区域?以及内存分配与回收策略。

猜你喜欢

转载自blog.51cto.com/4837471/2159773