深入理解JVM - 虚拟机字节码执行引擎

执行引擎是Java虚拟机最核心的组成部分之一,输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

1、栈帧


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

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性中,因此一个栈帧需要分配多少内存,不会收到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎的所有字节码指令都只针对当前栈帧进行操作。

1.1、局部变量表

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

局部变量表的容量以变量槽(slot)为最小单位。一个Slot可以存放一个32位以内的数据类型。对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间,在Java语言中明确的64位数据类型只有long和double两种。值得一提的是,这里把long和double数据类型分割存储的做法与“long和double的非原子性协定”中把一次long和double数据类型读写分割成两次32位读写的做法有些类似。不过,由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐藏的参数。其余的参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数分配完毕之后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

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

public static void main(String[] args) {
        byte[] placeHolder = new byte[64*1024 * 1024];
        System.gc();
    }
    [GC 66846->65824K(125632K),0.0032678 secs]
    [Full GC 65824->65746K(125632K),0.0064131 secs]
可以看到运行System.gc()之后并没有回收64M的内存。没有回收placeHolder所占的内存能说的过去,因为在执行System.gc()的时候,变量placeHolder还处于作用域之内,虚拟机自然不敢回收placeHolder的内存。

  public static void main(String[] args) {
        {
            byte[] placeHolder = new byte[64*1024 * 1024];
        }
        System.gc();
    }
    [GC 66846->65824K(125632K),0.0032678 secs]
    [Full GC 65824->65746K(125632K),0.0064131 secs]
加上花括号之后,placeHolder的作用域被限制在了花括号之内,从代码逻辑上讲,在执行System.gc()的时候,placeHolder已经不能再访问了,但是运行之后会发现64M内存没有被回收。

public static void main(String[] args) {
        {
            byte[] placeHolder = new byte[1024 * 1024];
        }
        int a = 0;
        System.gc();
    }
    [GC 66401->65778K(125632K),0.0035471 secs]
    [Full GC 65824->218K(125632K),0.00140596secs]
运行之后,可以发现64M的内存被回收了。placeHolder被回收的根本原因是:局部变量表中的Slot是否还存在有关于placeHolder数组对象的引用。第一次修改中,代码虽然已经离开了placeHolder的作用域。但在此之后,没有任何对局部变量表的读写操作,placeHolder原本所占用的Slot还没有被其他变量复用,所以作为GC Root一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时的打断,在绝大多数情况下影响很轻微。但如果遇到了方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不再使用的变量,手动将其置成null值便不见得是一个绝对无意义的操作。
注意:虽然在某些特殊的情况下,赋null值确实是有用的,但是不应当对赋null值的操作有过多的依赖,更没有必要把它当作一个普遍的编码规则来推广。

关于局部变量表,还有一点需要知道,就是局部变量不像前面介绍的类变量那样存在“准备阶段”。我们已经知道类变量有两次赋初始化值的过程,一次是在准备阶段,赋予系统初始值;另一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,但局部变量就不一样了,如果一个局部变量没有赋初始值是不能使用的,不要认为Java中任何情况下都存在诸如整型变量默认值为0,布尔值变量默认为false等这样的默认值。

1.2、操作数栈

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

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈的操作。举个栗子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的整数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

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

1.3、动态连接

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

1.4、方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:1、执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。这种退出方法的方式称为正常完成出口。2、在方法的执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是虚拟机内部产生的异常还是代码中athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。这种退出方法的方式被称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给上层调用者产生任何返回值的。

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

--------------------------------方法调用、基于栈的解释器执行过程----------------------------------------

猜你喜欢

转载自blog.csdn.net/json_it/article/details/79107330