深入理解Java虚拟机:虚拟机字节码执行引擎

时间:2022-12-27 19:50:30

概述

    在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能有解释执行和编译执行两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。但从外观上,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。


运行时栈帧结构

    栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧(每一个)存储了方法的局部变量、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机里面从入栈到出栈的过程。

    一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,活动线程,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的所有的字节码都只针对当前栈帧进行操作,栈帧的结构如下图:

深入理解Java虚拟机:虚拟机字节码执行引擎


局部变量表

    局部变量表存放的一组变量的存储空间。存放方法参数和方法内部定义的局部变量表。在java编译成class的时候,已经确定了局部变量表所需分配的最大容量。

    局部变量表以变量槽(Variable Solt, 下称 Solt)为最小单位,虚拟机规范没有明确规定一个Slot占多少大小。只是规定,它可以放下boolean,byte,…reference &return address。

    reference 是指一个对象实例的引用。关于reference的大小,目前没有明确的指定大小。但是我们可以理解为它就是类似C++中的指针。

    局部变量表的读取方式是索引,从0开始。所以局部变量表可以简单理解为就是一个表.

    局部变量表的分配顺序如下:

  • this 引用。可以认为是隐式参数。
  • 方法的参数表。
  • 根据局部变量顺序,分配Solt。
  • 一个变量一个solt,64为的占2个solt。java中明确64位的是long & double
  • 为了尽可能的节约局部变量表,Solt可以重用。

    注意:局部变量只给予分配的内存,没有class对象的准备阶段,所以局部变量在使用前,必须先赋值。


操作数栈

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

    操作数栈在概念上很像寄存器。java虚拟机无法使用寄存器,所以就有操作数栈来存放数据。虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。

    比如,iadd指令就要从操作数栈中弹出两个整数,执行加法运算,其结果又压回到操作数栈中,看看下面的示例,它演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:

         0: bipush 100 //推入操作栈
2: istore_1 //将操作栈栈顶出栈并存放到第1个局部变量Slot中
3: bipush 100
5: istore_2
6: iload_1 //将局部变量表第1个Slot中的值copy到操作栈顶
7: iload_2
8: iadd //将操作栈中头两个栈顶元素出栈,做整型假发,然后把结果重新入栈。
9: istore_3

     操作数栈 的数据读取、写入就是出栈和如栈操作。


动态链接

    每个栈帧都包含一个指向运行时常量池的引用,持有这个引用是为了支持动态连接。

    符号池的引用,有一部分是在第一次使用或者初始化的时候就确定下来,这个称为静态引用。

    还有一部分是在每次执行的时候采取确定,这个就是动态连接。


方法返回地址

    方法只有2中退出方式,正常情况下,遇到return指令退出。还有就是异常退出。

    正常情况:一般情况下,栈帧会保存 在程序计数器中的调用者的地址。虚拟机通过这个方式,执行方法调用者的地址然后把返回值压入调用者中的操作数栈。

    异常情况:方法不会返回任何值,返回地址有异常表来确定,栈帧一般不存储信息。


方法调用

    方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定调用方法的版本。class文件在编译阶段没有连接这一过程,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是直接地址。

解析

    所有方法调用的目标方法都是常量池中的符号引用。在类的加载解析阶段,会将一部分目标方法转化为直接引用。(可以理解为具体方法的直接地址)可以转化的方法,主要为静态方法 & 私有方法。

    Java虚拟机提供5中方法调用命令:

  • invokestatic:调用静态方法
  • invokespecial:调用构造器,私有方法和父类方法
  • invokevirtual:调用虚方法
  • invokeinterface:调用接口方法
  • invokedynamic:现在运行时动态解析出该方法,然后执行。
  • invokestatic & invokespecial 对应的方法,都是在加载解析后,可以直接确定的。所以这些方法为非虚方法。

    方法静态解析演示:

public class StaticResolution {
public static void sayHello(){
System.out.println("hello world!");
}
public static void main(String[] args){
StaticResolution.sayHello();
}
}

    使用 javap 命令查看结果:

rockmbp:Java rocki$ javap -verbose StaticResolution

public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: invokestatic #5 // Method sayHello:()V
3: return
LineNumberTable:
line 7: 0
line 8: 3

分派

    静态分配:

/**
重载为例
*/

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);
*/

}
}

    运行结果:

hello,guy!
hello,guy!

    这里的Human我们理解为静态类型,后面的Man是实际类型。我们在编译器只知道静态类型,后面的实际类型等到动态连接的时候才知道。所以对于sayHello方法,虚拟机在重载时,是通过参数的静态类型,而不是实际类型来判断使用那个方法的。

    动态分配

/**
重写为例
*/

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();
}
}

    运行结果

man say hello
woman say hello
woman say hello

    先来看上面标红的这句:方法要解析man 的sayhello,问题是man是什么东西,我在解析的时候,是不知道的。所以“man.sayHello();”具体执行的那个类的方法,是需要在虚拟机动态连接的时候才知道,这个就是多态。如果使用javap分析就可以知道这句话,在class文件里面是ynamicDispatch$Human: sayHello. 是的class文件不知道这个sayhello到底要去调哪个方法。

    invokevirtual指令解析的过程大概如下:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  2. 如果在类型 C 中找到与常量中的描述和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接饮用,查找介绍;如果不通过,则返回 java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError异常。一般情况下,编译工具会帮我们避免这种情况。

    单分派和多分派
    概念上理解比较麻烦,说白了一点就是重载和重写都存在的情况:

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 action()
{
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}

    运行结果:

Father 360
Son QQ

    结果没有任何悬念,但是过程还是需要明确的。hardChoice的选择是在静态编译的时候就确认的。

    而son.hardchoise 已经确认了函数的类型,只是需要进一步确认实体类型。所以动态连接是单分派。

    动态语言支持:
    jdk 1.7 MehtodHandle:

package cn.kawa;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
/**
Method Handle 基础用法
*/

public class MethodHandleTest {

static class ClassA{
public void println(String s){
System.out.println("Class A :" + s);
}
}

public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();;

// 无论那个 obj 最终是哪个实现类,下面这句都能正确调用到 println 方法
getPrintlnMH(obj).invokeExact("icyfenix");
}


private static MethodHandle getPrintlnMH(Object reveriver) throws Throwable {
/*MethodType:代表 “方法类型”,包含了方法的返回值(methodType() 的第一个参数)
和具体参数(methodType() 第二个参数)
*/

MethodType mt = MethodType.methodType(void.class, String.class);
/*
MethodHandles.lookup这句的作用是在指定类中查找符合给定的方法名、方法类型,并且符合调用权限的句柄
*/

return MethodHandles.lookup().findVirtual(reveriver.getClass(), "println", mt).bindTo(reveriver);
}
}

基于栈的字节码执行引擎

    基于栈的指令集 和基于寄存器的指令集。

    先看一个加法过程:

    iconst_1
iconst_1
iadd
istore_0

    这是基于栈的,也就是上文说的操作数栈。

    先把2个元素要入栈,然后相加,放回栈顶,然后把栈顶的值存在slot 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

    执行过程如下图:
深入理解Java虚拟机:虚拟机字节码执行引擎

    注意: 该方法为static方法,所以局部变量表中的第1个元素不是this。