jvm学习笔记7:虚拟机字节码执行引擎

1.概述

JVM架构
见上图,执行引擎是Java虚拟机最核心的组成部分之一。
执行引擎分为java解释器和JIT编译器两种。

  • JAVA解释器每次将一条指令解释为字节码,交给CPU执行。
  • JIT编译器每次将多条指令编译为字节码,一次性交给CPU执行。

2.运行时栈帧结构

栈帧(Stack Frame)是用于虚拟机进行方法调动和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈。栈帧中存储了方法的局部变量表、操作数栈、动态链接、方法返回等信息。

  • 一个栈帧需要分配多少内存,在编译成class文件时已经确定了,在code属性。
  • 在活动线程中,只有栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧关联的方法称为当前方法。
    在这里插入图片描述

2.1 局部变量表

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

  • 局部变量表的最小单位为Slot(称为变量槽,大小为4个字节);
  • 实例方法的索引为0的局部变量为this;
  • 为了节省栈帧空间,Slot是可以重用的(PC计数器的值以及超过了某个变量的作用域,则这个变量对应的Slot可以 交给其他变量使用),见以下代码(均以-verbose:gc参数启动);
  • 有时候,为了对象能够被及时回收,我们会手动将某个变量设为null值,达到回收目的。但是JIT编译,赋值为null的操作会被编译优化掉;

例1:

public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        byte[] buffer = new byte[64 * 1024 * 1024];
        System.gc();
        /**
         * 结果:因为局部变量表还有引用指向这个数组,所以没有被回收
         * [GC (Allocation Failure)  1300K->571K(15872K), 0.0012861 secs]
         * [Full GC (Allocation Failure)  571K->570K(15872K), 0.0013342 secs]
         * [Full GC (System.gc())  66196K->66117K(81476K), 0.0012132 secs]
         */

    }
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        {
            byte[] buffer = new byte[64 * 1024 * 1024];
        }
        System.gc();
        /**
         * 结果:虽然变量的作用域以及超过了,但是由于局部变量表的值还没有被覆盖,
         * 所以没有回收
         *[GC (Allocation Failure)  1300K->564K(15872K), 0.0013242 secs]
         *[Full GC (Allocation Failure)  564K->563K(15872K), 0.0013419 secs]
         *[Full GC (System.gc())  66189K->66141K(81476K), 0.0014003 secs]
         */
    }
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        {
            byte[] buffer = new byte[64 * 1024 * 1024];
        }
        int a = 10;
        System.gc();
        /**
         * 结果:超过了作用域,Slot被重用,所以buffer数组被回收了 
         *[GC (Allocation Failure)  1300K->570K(15872K), 0.0011912 secs]
         * [Full GC (Allocation Failure)  570K->569K(15872K), 0.0014142 secs]
         * [Full GC (System.gc())  66195K->605K(81476K), 0.0013806 secs]
         */
    }

2.2 操作数栈

在JAVA程序编译为Class文件时,就在方法的code属性的max_stacks数据项确定了该方法所需操作数的最大容量。操作数栈的每一个元素都可也以是任意的数据类型,包括long和double。32位数据类型所占的栈容量是1,64位数据类型为2。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中的栈指的就是操作数栈。

2.3 动态链接

指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法中的动态链接(后续再进行详解)。

2.4 方法返回地址

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

  • 遇到任何一个方法返回的字节码指令(正常完成出口);
  • 执行过程中遇到异常(异常方法出口)。

无论通过何种方式退出,在方法退出后,都需要返回方法被调动的位置。方法退出的过程等同于把当前栈出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压如调用者栈帧的操作数栈,调整PC计数器的值。

3.方法调用

方法调用并不等同于方法的执行,方法调用的目的是确定被调用方法的版本。前文中提到,class文件中,方法的调用存储的只是一个符号引用,需要确认其在内存布局中的入口地址(直接引用)。

3.1 解析

前文提过,在类的加载-解析阶段,会将符号引用转换为直接引用,其中就包括方法的符号引用。当将方法的符号引用转换为直接引用的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个这个方法的调用版本在运行期是不可变的。

符合上述条件的方法主要包括静态方法、私有方法、构造器、父类方法、final方法。Java虚拟机提供5种方法调用字节码指令,如下:

  • invokestatic:调用静态方法;
  • invokespecial:调用方法、私有方法和父类方法;
  • invokevirtual:调用所有的虚方法;
  • invokeinterface:调用接口方法;
  • invokedynamic:现在运行时动态解析调用点限定符所引用的方法,然后再执行该方法。前面4条指令,分派逻辑在jvm虚拟机内部的,而invokedynamic指令的分派逻辑由用户所设定的引导方法决定。

虚方法:invokestaticinvokespecial调用的方法和final修饰的方法,都是虚方法。这些方法都可以在解析阶段将符号引用转为直接引用。

3.2 分派(dispatch)

分派调用可能是静态的由,也可能是动态的。根据分派的宗量数可分配单分派和多分派。

3.2.1 静态分派(重载)

Father son = new Son();

前者称为静态类型,或者叫做外观类型,后者称为变量的实际类型。虚拟机在进行重载时是通过参数的静态类型而不是实际类型作为判断依据的,javac编译器会根据参数的静态类型决定使用哪个重载版本。如下:

public class Test1 {
    public static void main(String[] args) {
        Father son = new Son();
        Father daughter = new Daughter();
        Dispatch dispatch = new Dispatch();
        dispatch.eat(son);
        dispatch.eat(daughter);

        /**
         * 运行结果为:
         * father eat
         * father eat
         */
    }
}
class Dispatch{
    void eat(Father father){
        System.out.println("father eat");
    }

    void eat(Son son){
        System.out.println("son eat");
    }

    void eat(Daughter daughter){
        System.out.println("daughter eat");
    }
}
class Father{
}
class Son extends Father{
}
class Daughter extends Father{

}

关于方法的重载,是有一定的优先级的,如下代码,如果从上往下注释掉相应的sayHello()方法,则会分别打印出:hello arg、hello long、hello Character、hello object。

package org.example;
public class Test2 {
    public static void main(String[] args) {
        Overload.sayHello('a');
    }
}

class Overload {
    public static void sayHello(char arg){
        System.out.println("hello arg");
    }

      public static void sayHello(long arg){
          System.out.println("hello long");
      }
    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }

    public static void sayHello(Object arg) {
        System.out.println("hello object");
    }


}

3.2.2 动态分派(重写、多态)

先看下面的代码,我们很容易看到执行结果son eat、daughter eat。我们称之为方法的重写,或者说多态。但是再看main方法的字节码,重点看17和21行字节码,发现都是invokevirtual 指令,参数也完全一样,但是最终的执行结果却不一样。原因就需要从invokevirtual指令的多态查找过程说起,大致分为以下几个步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C;
  2. 如果C中找到与常量中描述符和简单名称都相符的方法,则进行权限检验,如果通过则返回这个方法的引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上一次对C的各个父类进行第2步的搜索和验证过程;
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

其实以上过程就是方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态分配。

package org.example;

public class Test1 {
    public static void main(String[] args) {
        Father son = new Son();
        Father daughter = new Daughter();
        son.eat();
        daughter.eat();
    }
}
class Father {
    public void eat() {
        System.out.println("father eat");
    }
}

class Son extends Father {
    public void eat() {
        System.out.println("son eat");
    }
}

class Daughter extends Father {
    public void eat() {
        System.out.println("daughter eat");
    }
}
 0 new #2 <org/example/Son>
 3 dup
 4 invokespecial #3 <org/example/Son.<init>>
 7 astore_1
 8 new #4 <org/example/Daughter>
11 dup
12 invokespecial #5 <org/example/Daughter.<init>>
15 astore_2
16 aload_1
17 invokevirtual #6 <org/example/Father.eat>
20 aload_2
21 invokevirtual #6 <org/example/Father.eat>
24 return

3.2.3 单分派与多分派

方法的接受者与方法的参数统称为方法的宗量。静态分派由调用方法对象的静态类型和方法参数来决定目标方法的选择,所以静态分派是多分派。

在动态分派过程中,已经不再关注方法的参数,应为参数已经确定。这是方法的选择只与对象的实际类型有关,所以动态分派是单分派。

3.2.4 虚拟机动态分派的实现

动态分派是非常频繁的动作,而且动态分配的方法版本选择过程需要运行时在类的方法元数据中搜索合适目标方法,因此虚拟机的实际实现中基于性能的开率,会采用一些优化手段。

  • 虚方法表:这是最常见的“稳定优化”手段,为每个类在方法区中建议一个虚方法表(vtable)和接口方法表(itable)。虚方法表中存放各个方法的实际入口地址,如果方法没有被子类重写,那么子类的虚方法表的地址入口和父类相同方法的地址入口一致。如果子类重写了这个方法,这个入口就是子类该方法的实际入口。
  • 内联缓存:非稳定,后续介绍;
  • 基于“类型继承关系分析”技术的守护内联:非稳定,后续介绍;

3.3 java的动态语言支持

静态类型语言在编译器确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题在编译器就能发现,利于稳定性和代码达到更大规模。而动态语言类型在运行期确定类型,为开发人员提供了更大的灵活性。

在jdk1.7之前,4条方法调用指令(invokestatic/invokespecial/invokevertual/invokeinterface)的第一个参数都是方法的符号应用,方法的符号引用在编译期产生,而动态语言类型在运行期才能确定方法的接受者类型。这就是说Java的动态支持必须采用别的方式来实现。这就是invokedynamic指令和Java.lang.invoke包出现的技术背景。关于这两种技术不在此进行讲解,如有需要,自行学习。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值