搜文章
推荐 原创 视频 Java开发 iOS开发 前端开发 JavaScript开发 Android开发 PHP开发 数据库 开发工具 Python开发 Kotlin开发 Ruby开发 .NET开发 服务器运维 开放平台 架构师 大数据 云计算 人工智能 开发语言 其它开发
Lambda在线 > nullPoint水 > 深入理解JVM搬运工(5) 虚拟机字节码执行引擎

深入理解JVM搬运工(5) 虚拟机字节码执行引擎

nullPoint水 2018-06-30

声明:本文基于《深入理解Java虚拟机》一书,可以看作是该书的读书笔记


         虚拟机是相对于物理机的一个概念,两种机器都有执行代码的能力,区别在于物理机的执行引擎是建立在处理器、硬件、指令集和操作系统层面的;而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系。

         在不同的虚拟机实现里,执行引擎在执行代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。

运行时栈帧结构

在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定并已写到方法表的code属性中,因此一个栈帧需要分配多少内存,仅仅取决于虚拟机实现。

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

局部变量表

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

         局部变量表的容量以变量槽(VariableSlot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是导向性的说到每个Slot都应该能存放一个booleanbytecharshortintfloatreferencereturnAddress类型的数据。

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

         为了尽可能的节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用于并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。

         局部变量不像类变量那样存在准备阶段。类变量有两次赋初始值的过程,一次在准备阶段,另一次在初始化阶段,因此,即使在初始化阶段程序员没有为类变量赋值,类变量仍然具有一个确定的初始值。但局部变量不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,编译器在编译期就能检查并提示这一点。

操作数栈

         操作数栈(OperandStack)也常称为操作栈。操作数栈的最大深度在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的java数据类型。

         当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法执行过程中,会有各个字节码指令往操作数栈中写入和提取内容,也就是出栈、入栈操作。

         操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类的校验阶段的数据流分析中还要再次验证这一点。

         在概念模型中,两个栈帧作为虚拟机栈的元素,是完全独立的,但是在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递。

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

深入理解JVM搬运工(5) 虚拟机字节码执行引擎

动态连接

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

方法返回地址

当一个方法开始执行后,有两种方式可以退出这个方法。一种是正常完成出口:执行引擎遇到任意一个方法返回的字节码指令,这个时候可能会有返回值传递给上层的方法调用者。另一种是异常完成出口:在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理。一个方法使用异常完成出口的方式退出,是不给上层调用者产生返回值的。

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

附加信息

方法调用

         方法调用并不等于方法执行,方法调用阶段唯一的任务就是确定调用哪一个方法,还不涉及方法的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都是符号引用,这个特性给java带来了动态扩展能力,同时也使方法调用过程变的复杂,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

         所有方法调用中的目标方法在Class文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这类解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可改变的,也就是说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。

         Java中符合编译期可知,运行期不可变的方法包括静态方法和私有方法两大类。

         只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以成为非虚方法。

         Java中的非虚方法有invokestaticinvokespecial调用的方法和被final修饰的方法。

         解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派一句的宗量数可分为单分派和多分派。

分派

         分派调用过程会揭示一些java多态性特征。

静态分派

         变量被声明时的类型是静态类型,或者外观类型。

         变量所引用的对象的真是类型是动态类型。

         静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期克制的;而实际类型变化的结果在运行期才可确定。

         所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

         解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上筛选、确定目标方法的过程。

动态分派

         动态分派和多态性的另外一个重要体现----重写有着很密切的关联,动态分派发生在运行期。

单分派和多分派

         方法的接收者与方法的参数统称为方法的宗量。

         单分派:根据一个宗量的类型进行方法的选择

         多分派:根据多余一个宗量的类型对方法选择,多分派其实是一系列的单分派组成的,区别在于这些单分派不能分割。

         目前java语言是一门静态多分派、动态单分派的语言。

虚拟机动态分派的实现

         由于动态分派是非常频繁的动作,而动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,基于性能的考虑,大部分的实现不会进行频繁的搜索,而是建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

动态语言支持

动态类型语言

         动态类型语言的特征是它的类型检查的主体过程是在运行期而不是编译期,常见的有JavaScriptPHPPython等。相对的,在编译期进行类型检查过程的语言(JavaC++等)就是静态类型语言。

         静态类型语言在编译期确定类型,优点是编译期可以提供严禁的类型检查,利于稳定性以及达到更大的代码规模。动态类型语言在运行期确定类型,可以提供更大的灵活性,某些静态类型语言中需要大量代码来实现的功能,由动态类型语言实现会更清晰简洁。

Java.lang.invoke

         JDK1.7新加入了java.lang.invoke包。这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle

         java语言的角度来看,MethodHandle的使用方法和效果与Reflection很相似。但是他们有以下区别

  1. 本质上讲,ReflectionMethodHandle机制都是在模拟方法调用,但Reflection是在模拟java代码层次的调用,而MethodHandle是模拟字节码层次的方法调用。

  2. Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。通俗来讲,Reflection是重量级的,而MethodHandle是轻量级的。

  3. 由于MethodHandle是对字节码的方法指令调用模拟,所以理论上虚拟机在这方面做的优化,在MethodHandle上也可以采用类似的思路支持。

从虚拟机的角度看,ReflectionAPI的设计目标只是为了java语言,而MethodHandle设计的目的是服务于java虚拟机上的所有语言的。

Invokedynamic指令

JDK1.7为了更好的支持动态类型语言,引入了第5条方法调用的字节码指令invokedynamicInvokedynamic指令与MethodHandle一样,都是为了解决原有4“Invoke*”指令方法分派规则固话在虚拟机之中的问题,吧如何查找目标方法的决定权从虚拟机转嫁给用户代码中。

每一处含有invokedynamic指令的位置都称为动态调用点,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变成JDK1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个心常量中可以得到3个信息:引导方法、方法类型、名称。

方法分派规则

         Invokedynamic指令与前面4“invoke*”指令最大的差别就是他的分派逻辑不是由虚拟机决定的,而是有程序员决定。

基于栈的字节码解释执行引擎

解释执行

         Java语言经常被定位解释执行的语言,在以前这个定义还算准确,但当前主流的虚拟机都包含了即时编译器,Class文件中的代码到底是解释执行还是编译执行,这就只有虚拟机知道。

         现在的大多数的基于高级语言虚拟机的语言。大多会遵循以下思路:在执行前先对程序源码进行此法分析和语法分析处理,把源码转化为抽象语法树。

         Java语言中,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,在遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在java虚拟机之外进行的,而解释器在虚拟机内部,所以java程序的编译就是半独立实现。

         对于一门语言的实现来说有以下3中实现方式:

  1. 独立实现:词法分析、语法分析以致后面的优化器和目标生产器都选择独立于执行引擎。形成完整意义的编译器去实现。如CC++

  2. 半独立实现:把其中一部分(如生成语法树之前的步骤)实现为一个半独立的编译器。如:java

  3. 把所有步骤和执行引擎全部集中封装在一个封闭的黑匣子中。如JavaScript

基于栈的指令集与基于寄存器的指令集

         基于栈的指令集主要优点是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要收到硬件的约束。代码相对更加紧凑、编译器实现更加简单等。缺点是:执行速度相对较慢,虽然栈架构指令集的代码非常紧凑,但完成相同功能所需的指令数量一般比寄存器架构多。

 



版权声明:本站内容全部来自于腾讯微信公众号,属第三方自助推荐收录。《深入理解JVM搬运工(5) 虚拟机字节码执行引擎》的版权归原作者「nullPoint水」所有,文章言论观点不代表Lambda在线的观点, Lambda在线不承担任何法律责任。如需删除可联系QQ:516101458

文章来源: 阅读原文

相关阅读

关注nullPoint水微信公众号

nullPoint水微信公众号:nullPointWater

nullPoint水

手机扫描上方二维码即可关注nullPoint水微信公众号

nullPoint水最新文章

精品公众号随机推荐