一、运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈(Virtual Machine Stack)的栈元素。
对于执行引擎来说,活动线程中,只有栈顶的栈帧是有效地,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧概念结构如下图所示:
1、局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。最大局部变量表容量在java程序被编译时就一定确定。
局部变量表的容量以变量槽(Variable Slot)为最小单位。一个变量占用一个或两个槽。
虚拟机通过索引定位的方式使用局部变量表;
实例方法的局部变量表中第0位索引的Slot默认是用于传递方法所属实例的引用(this引用)。接着是参数按照参数表顺序占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用分配其余的Slot。
局部变量Slot可以重用,如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。
注:因为类变量在使用前会有两次赋值机会(“准备阶段”和“初始化阶段”),因此即使程序员不赋值,也会存在默认值。但是局部变量就不同,如果一个局部变量没有赋初始值,就不能使用。
2、操作数栈
操作数找也称为操作栈,同局部变量表一样,操作数栈的最大深度也在编译时就确定,栈的每一个元素都是任意的Java数据类型,单位还是Slot。
当一个方法刚开始执行时,这个方法的操作数栈为空。在执行过程中会有入栈和出栈操作。
注:在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。
重叠过程如下:
3、动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接;
注:字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的,有一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析,另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接。
4、方法返回地址
当一个方法被执行后,有两种方式退出这个方法:
第一种是执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。注意:这种退出方式不会给上层调用者产生任何返回值。
‘ 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
方法返回地址用来帮助恢复它的上层调用者的执行状态(异常完成出口没有此信息)’。
5、附加信息
虚拟机规范允许虚拟机实现向栈帧中添加一些自定义的附加信息,例如与调试相关的信息等。
二、方法调用
方法调用阶段的目的:确定被调用方法的版本(哪一个方法),不涉及方法内部的具体运行过程。
我们已知一切方法调用在Class文件里存储的都只是符号引用,这是需要在类加载期间或者是运行期间,才能确定为方法在实际 运行时内存布局中的入口地址。
1、解析
“编译期可知,运行期不可变”的方法,在类加载的解析阶段,会将其符号引用转化为直接引用(入口地址)。这类方法的调用称为“解析(Resolution)”。
符合“解析”条件的方法称为“非虚方法”,包括:静态方法、私有方法、实例构造器、父类方法 和一种特殊类型:被final修饰的方法。
注:Java虚拟机提供的四条调用字节码指令是:
** invokestatic : 调用静态方法
** invokespecial:调用实例构造器<init>方法、私有方法、父类方法
** invokevirtual:调用所有的虚方法和final修饰的方法。
** invokeinterface:调用接口方法。
2、分派
<1>、静态分派
如果Human类是Man类的基类,那么对于Human man = new Man();这句代码,“Human”称为变量man的静态类型(Static Type)或外观类型(Apparent Type)。后面的“Man”则称为变量的实际类型(Actual Type)。
所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段。
静态分派最典型的应用就是方法重载。
关于重载的优先级:类型自动转换的优先级、自动装箱、实现接口、父类、可变长参数。
注:解析和分派不是二选一的排他关系,是在不同层次上去筛选和确定目标方法的过程。例如:选择重载版本的过程就是通过静态分派来完成的。
<2>、动态分派
对于虚方法(比如重写的方法)的调用都是执行invokevirtual指令的。
invokevirtual指令的运行时解析过程大致如下:
@1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
@2、如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
@3、否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
@4、如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
<3>、单分派和多分派
方法的接收者、方法的参数都可以称为方法的宗量。根据分批基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择的,多分派是根据多于一个的宗量对目标方法进行选择的。
Java在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
注:到JDK1.7时,Java语言还是静态多分派、动态单分派的语言,未来有可能支持动态多分派。
<4>、虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。
其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与C++的虚函数表类似。
虚方法表中存放的是各个方法的实际入口地址。虚方法表一般在类加载的连接阶段进行初始化。
此外,还存在两种“激进的非稳定优化”手段:内联缓存(Inline Cache)和基于“类型继承关系分析(Class Hierarchy Analysis, CHA)”技术的守护内联(Guarded Inlining)。
三、基于栈的字节码解释执行引擎
虚拟机字节码的执行引擎,在执行java代码时有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。
1、解释执行
所有程序代码编译的过程如下:
其中,最下面的那条分支是程序代码到目标机器代码的生成过程,而中间的那条分支是解释执行的过程。
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。位于虚拟机之外。
解释执行的引擎解释器位于虚拟机内部。
2、基于栈的指令集 与 基于寄存器的指令集
有两种指令集架构:基于栈的指令集架构 和基于寄存器的指令集架构。
基于栈的指令集架构,指令流依赖操作数栈进行工作; 基于寄存器的指令集架构,其指令时依赖寄存器进行工作的。
以下计算“1+1”的例子,显示了两种指令集的区别:
<1>、 对于基于栈的指令集架构,对于该例,其对应的指令集将会是:
iconst_1
iconst_1
iadd
istore_0
执行过程是:两条iconst_1指令连续地把两个常量1压入栈后,iadd指令把栈顶的两个值出栈并相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。
<2>、基于寄存器的指令集架构,对于该例,其对应的指令集将会是:
mov eax, 1
add eax, 1
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。
两种指令集架构的优缺点:
基于栈的指令集,优点是:可移植性,缺点是执行速度较慢
基于寄存器的指令集:优点是:执行速度快,缺点是:可移植性差
3、基于栈的解释器执行过程
基于栈的解释器执行过程,都是以操作数栈的出栈和入栈为信息交换途径的。
四、类加载及执行子系统的案例与实战
1、Tomcat的类加载架构
Tomcat中被放置在不同路径的类库,具备不同的访问范围和服务对象,通常每个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。
/lib目录放置的类库可以被Tomcat和所有的Web应用程序共同使用。
WebApp/WEB-INF目录下的类库仅仅可以被此Web应用程序使用。
Tomcat自定义的类加载器有:CommonClassLoader、CatalinaClassLoader、SharedClassLoader、WebappClassLoader。
Tomcat服务器的类加载架构如下:
2、OSGi的类加载架构
OSGi(Open Service Gateway Initiative)是OSGI联盟(OSGi Alliance)制定的一个基于Java语言的动态模块化规范。已成为Java世界的“事实上”的模块化标准。
OSGi在提供强大功能的同时,也引入了额外的复杂度,带来了线程死锁和内存泄漏的风险。
3、字节码生成技术与动态代理的实现
4、Retrotransator:跨越JDK版本
Retrotransator是一种“Java逆向移植”工具(Java Backporting Tools)。
每次JDK升级,新增的功能大致分为以下四类:
<1>、在编译器层面所做的改进。如自动装箱拆箱、变长参数等。
<2>、对Java API的代码增强,例如JDK1.2引入的java.util.Collections,JDK1.5中引入的java.util.concurrent并发包等。
<3>、需要在字节码中进行支持的改动。如JDK1.7引入的动态语言支持
<4>、虚拟机内部的改进,例如JDK1.5中重新定义了Java内存模型、CMS收集器之类的改动。
对于这四类新功能,Retrotranslator之类的“逆向移植”工具只能模拟前两类,对于后面两类直接在虚拟机内部实现的改进,一般所有的逆向移植工具都无能为力。