《深入理解JAVA虚拟机》第八章 虚拟机字节码执行引擎

第八章 虚拟机字节码执行引擎

  字节码执行引擎是java虚拟机的重要组成部分。执行引擎作用是接收字节码,解析字节码,执行并输出执行结果。不同的虚拟机中,执行引擎在执行java代码时可能会有解释执行(通过解释器实时执行)和编译执行(通过即时编译器产生本地代码执行)两种之一,也可能两者都兼备
  栈帧是支持虚拟机进行方法调用和方法执行的数据结构。它存储在运行时数据区的虚拟机栈中。每一个方法的从开始到完成的过程,都对应了一个栈帧的入栈和出栈的过程。一个栈帧包含了:局部变量表,操作数栈,动态连接,方法返回地址。局部变量表和操作数栈在编译的时候,已经可以完全确定,并且写入到了Class文件的方法表的Code属性之中。因此一个栈帧需要多大的内存,不会受到程序运行期的变量数据影响。
      在这里插入图片描述

局部变量表中Slot只有被复用,无效局部变量才能垃圾收集成功

  局部变量表用于存放方法参数和方法内部定义的局部变量,在java程序编译为class文件就在方法的Code属性max_locals中确定了该方法需要分配的局部变量表的最大容量。java虚拟机中还有reference和returnAddress数据类型,reference表示一个对象实例的引用,虚拟机可以通过reference直接或间接查找对象在java堆中存放的起始地址索引与在方法区中对象所属的数据类型
  为了尽可能节省栈帧空间,局部变量表中的slot是可以服用的,即方法体中定义的变量作用域不一定覆盖整个方法体,如果字节码PC计数器值超过了某个变量作用域(当执行System.gc()我们想当然以为虚拟机会回收这个局部变量的内存),然而作为GC Roots一部分的局部变量表仍然保持对它的关联,所以无法回收,只有将不再使用的对象置为null,或者其他变量占用了它的slot才能回收。案例分析:运行时再虚拟机参数中加入-verbose:gc

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

运行结果:在执行System.gc()时,变量placeholder还在作用域中,虚拟机自然不会回收placeholder内存。
      在这里插入图片描述

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

改进:在placeholder作用域之外进行回收
  

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

运行结果:仍然没有用,虽然代码离开了placeholder的作用域,但是place原本占用的局部变量表的slot还没有被其他变量复用,所以作为GC Roots一部分的局部变量表仍然保持对它的关联,所以虚拟机以为placeholder还有用,并没有进行回收。除非,人为手动的将placeholder设置为null。在《pratical java》中把“不使用的对象手动赋值为null”作为一条推荐的编码规则,但是没有解释其中具体的原因,一般读者是理解不了的。但是不建议对所有的对象都这个处理,没有必要的地方不需要有这么多的类似代码。
      在这里插入图片描述
人为手动将不使用对象手动赋值为null:

public class TestLocalVariableTable {
	public static void main(String[] args) {
		{
		byte[] placeholder = new byte[64*1024*1024];
		placeholder = null;
		}
		System.gc();
	}
}

运行结果:
      在这里插入图片描述
还可以通过占用placeholder在原有局部变量表中的slot。

public class TestLocalVariableTable {
	public static void main(String[] args) {
		{
		   byte[] placeholder = new byte[64*1024*1024];		
		}
		int a = 0;
		System.gc();
	}
}

      在这里插入图片描述
  局部变量表对开放还有的影响有:局部变量不会被赋予初始值,所以必须初始化才能使用。局部变量不会像类变量存在准备阶段,赋予系统初始值,所以不被初始化是不能使用的。

public class TestLocalVariableUninit {
	public static void main(String[] args) {
		int a;
		System.out.println(a);
	}
}

操作数栈 动态连接 方法返回地址

操作数栈:操作数栈一个后进先出的栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值。一个变量一个solt,64为的占2个solt。java中明确64位的是long & double。

动态连接 :在Class文件的常量池中有大量的符号引用,字节码的方法调用指令以常量池指向方法的符号引用为参数,这些符号引用一部分在类加载阶段或者第一次使用时就转换成符号引用称为静态解析,另外一部分是在每次运行期时转为直接引用,这部分称为动态连接。所以,通常说的动态连接是在运行时将常量池中的符号引用变成直接引用过程,而不是在加载阶段或者第一次使用时转换成直接引用
方法返回地址:方法只有2中退出方式,正常情况下,遇到return指令退出。还有就是异常退出。 正常情况:一般情况下,栈帧会保存 在程序计数器中的调用者的地址。虚拟机通过这个方式,执行方法调用者的地址然后把返回值压入调用者中的操作数栈。 异常情况:方法不会返回任何值,返回地址有异常表来确定,栈帧一般不存储信息。

方法调用

  方法的调用主要是确定被调用方法的版本,方法版本的确认有解析调用方式和分派调用方式。解析调用针对静态方法、私有方法等不可重载重写方法。分派调用与java中多态方法如何实现紧密相关。

解析调用

  解析调用在类加载解析阶段,例如静态方法,私有方法不可重载的方法在编译时方法的版本就被确定了,符号引用转换成了直接引用,而且运行期不会被改变。这些方法都由invokestatic或invokespecial调用,都叫做非虚方法,除此之外还有一种是由final修饰的方法,虽然由invokevirtual调用,但是该方法无法被重写,所以也是非虚方法

分派调用-----静态分派根据静态类型确定重载方法版本

   分派调用:上面说的是只有一种方法版本,接下来说的是有多个方法版本,例如重载的方法,和重写的方法,这时候就要进行方法分派。方法分派有静态分派和动态分派。java是门面向对象的语言,多态是面向对象的其中一种特征。多态跟方法分派是密不可分的,方法分派就是虚拟机确定方法的调用版本,通俗就是确认调用哪个方法。
静态分派:静态分派是在编译期就确定了方法的调用版本,跟静态分派有关的就是方法重载。变量有静态类型,有实际类型。例如以下代码:Human是父类,Man是子类,那么Human是man这个变量的静态类型,Man是实际类型。
Human man = new Man();
   如果一个相同的方法,参数类型不同就可以构成方法重载,那么静态分派是根据静态类型来确认重载方法的版本。例如下面这段伪代码: 类中有两个方法,代码结果是调用第一个方法。

public void add(Human man){}
public void add(Man man){}
Human man = new Man();
方法调用:add(man);

   静态分派是根据静态类型来确认重载方法的版本,sayHello()中具体调用哪个版本是看参数静态类型,而非实际类型。例如:

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 dispatch = new StaticDispatch();
        dispatch.sayHello(man);
        dispatch.sayHello(woman);
        /*
            如果强转了以后,类型也跟着变化了。
            静态分配的典型应用是方法重载。但是方法重载有时候不是唯一的,所以只能选合适的。
            dispatch.sayHello((Man)man);
            dispatch.sayHello((Woman)woman);
        */
    }
}

运行结果:
在这里插入图片描述

分派调用-----动态分派根据实际类型确定重写方法版本

   动态分派: 动态分派跟方法重写有关,动态分派的方法选择是在运行时期确定,它会根据调用对象的实际类型去确认调用方法的版本。动态分派跟invokevirtual指令有关,该指令执行有以下步骤:
①找到栈顶第一个元素指向对象的实际类型,记作类型C;
②如果在类型C中找到与常量描述符和简单名称都相符的方法,并进行权限校验,如果通过则返回这个方法的直接引用,如果不通过则抛出访问非法异常。
③如果没找到,则按照继承关系从下往上依次对C的父类进行搜索。
④如果没找到,则抛出java.lang.AbstractMethodError异常。


public class DynamicDispatch {
    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 Woman extends Human{
        @Override
        protected void sayHello() {
            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();
    }
}

运行结果:
在这里插入图片描述

单分派(动态分派)与多分派(静态分派)

   分派根据方法参数与方法的接收者关系可分为单分派和多分派。书上说,静态分派要根据静态方法进行选择具体的版本是多分派类型,动态分派是执行invokevitual指令时,唯一看实际类型决定方法版本属于单分派。

public class Dispatch {
    static class QQ{}
    static class _360{}
    public static class Father{
        public void hardChoice(QQ qq){
            System.out.println("Father QQ");
        }
        public void hardChoice(_360 aa){
            System.out.println("Father 360");
        }
    }
    public static class Son extends Father{
        public void hardChoice(QQ qq){
            System.out.println("Son QQ");
        }
        public void hardChoice(_360 aa){
            System.out.println("Son 360");
        }
    }
    public static void main(String[] args)
    {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

运行结果:
在这里插入图片描述

基于栈的字节码执行引擎

   java通常都被人定位为解释执行语言,在java初生的JDK1.0时代,这种定位还是比较准确。但是当前主流的虚拟机中都包含了即时编译器(编译执行是通过即时编译器产生本地代码执行),因此严格意义上讲Class文件中的代码是解释执行还是编译执行只有虚拟机自己才能准确判断
   先介绍有基于寄存器的指令集和基于栈的指令集。
   基于栈的指令集:先看一个1+1加法过程:两条iconst_1指令连续把两个常量1压人栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。

       iconst_1
       iconst_1
       iadd
       istore_0

   上诉是基于栈的指令集,基于寄存器的指令集和操作单片机中的寄存器类似。基于栈的指令集 是和硬件无关的,而基于寄存器则依赖于硬件基础。基于寄存器在效率上优势。但是虚拟机的出现,就是为了提供跨平台的支持,所以jvm的执行引擎是基于栈的指令集。

public class Demo {
    public static void foo() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 5;
    }
}

直接使用命令javap查看它的字节码指令如下:

public static void foo();
  Code:
     0: iconst_1//把操作数压入操作数栈
     1: istore_0//将操作数栈顶元素弹出保存至局部变量表中
     2: iconst_2
     3: istore_1
     4: iload_0
     5: iload_1
     6: iadd
     7: iconst_5
     8: imul
     9: istore_2
    10: return

执行过程:
         在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_41262453/article/details/87427566
今日推荐