• 深入理解java虚拟机:虚拟机字节码执行引擎(1)


    1. 概述

    代码编译的结果是从 本地机器码 转变为 字节码 ,是存储格式发展的一小步,却是编程语言发展的一大步。

    执行引擎Java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

    在Java虚拟机规范中制定了 虚拟机字节码执行引擎 的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能有 解释执行(通过解释器执行)编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果

    2. 运行时栈帧结构

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

    编译 程序代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的 Code属性 之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

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

    在这里插入图片描述

    2.1 局部变量表

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

    局部变量表的容量以 **变量槽(Variable Slot,下称Slot)**为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有“导向性”地说明每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这种描述与明确指出“每个Slot占用32位长度的内存空间”有一些差别,它允许Slot的长度随着处理器、操作系统或虚拟机的不同而发生变化。不过无论如何,即使在64位虚拟机中使用了64位长度的内存空间来实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致。reference(可能是32位也可能是64位)和returnAddress的做法法与“long和double的非原子性协定”中把一次long和double数据类型读写分割为两次32位读写的做法类似。不过,由于 局部变量表建立在线程的堆栈 上,是 线程私有 的数据,无论读写两个连续的Slot是否是原子操作,都不会引起数据安全问题”。

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

    局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省栈空间,在某些情况下Slot的复用会直接影响到系统的垃圾收集行为,如下所示

    public class Test {
        public static void main(String[] args)  {
    		byte[] placeholder = new byte[64 * 1024 * 1024];
            System.gc();
        }
    }
    /*
    java -verbose:gc Test
    
    [0.010s][info][gc] Using G1
    [0.087s][info][gc] GC(0) Pause Full (System.gc()) 67M->65M(224M) 2.281ms
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    这里没回收,说的过去,因为执行Full GC的时候,变量 placeholder 还处于作用域中,虚拟机自然不敢回收这部分内存,我们改一下再看看

    public class Test {
        public static void main(String[] args)  {
            {
              byte[] placeholder = new byte[64 * 1024 * 1024];
            }
            System.gc();
        }
    }
    /*
    java -verbose:gc Test
    
    [0.011s][info][gc] Using G1
    [0.088s][info][gc] GC(0) Pause Full (System.gc()) 67M->65M(224M)  1.809ms
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    加入了花括号之后,placeholder的作用域被限制在花括号之内,从代码逻辑上讲,在执行Full GC的时候,placeholder 已经不可能再被访问了,但执行一下这段程序,还是有65MB的内存没有被回收掉,这又是为什么呢?我们再修改一下看看:

    public class Test {
        public static void main(String[] args)  {
            {
              byte[] placeholder = new byte[64 * 1024 * 1024];
            }
            int a=0;
            System.gc();
        }
    }
    /*
    java -verbose:gc Test
    
    [0.011s][info][gc] Using G1
    [0.093s][info][gc] GC(0) Pause Full (System.gc()) 67M->0M(8M) 6.907ms
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    这里的修改看起来很奇怪,但是运行后发现placeholder的内存被回收了, 能否被回收的根本原因就是:局部变量表中的 Slot 是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的SIot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再被使用的变量,手动将其设置为null值(用来代替“int a=0;”,把变量对应的局部变量表Slot清空) 就不是一个毫无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件)下的“奇技”来使用。但不应当对赋null值操作有过多的依赖,也没有必要把它当做一个普遍的编码方法来推广。以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法,上述代码不常见。

    2.2 操作数栈

    也被称为 操作栈,后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作栈的最大深度也在编译时期被写入到 Code属性max_stacks数据项之中。

    操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

    2.3 动态连接

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

    2.4 方法返回地址

    当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个 方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为 正常完成出口(Normal Method InvocationCompletion)

    另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用**athrow字节码指令**产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为 异常完成出口(Abrupt Method Invocation Completion)

    无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈顿中一般不会保存这部分信息。

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

    2.5 附加信息

    虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与 调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把 动态连接方法返回地址 与其他 附加信息 全部归为一类,称为 栈帧信息

  • 相关阅读:
    [附源码]Python计算机毕业设计Django疫情防控平台
    前端里那些你不知道的事儿之 【window.onload】
    企业电子招标采购系统源码Spring Cloud + 前后端分离 + 二次开发
    《Clean Code》
    03 变量
    硬件扫盲系列-接口
    SpringBoot 整合 Quartz 定时任务框架
    使用Fiddler进行移动端抓包和模拟弱网络测试
    LeetCode每日一题(963. Minimum Area Rectangle II)
    面试题-3
  • 原文地址:https://blog.csdn.net/qq_37776700/article/details/128038696