栈帧(stack Frame)是用于支持虚拟机进行方法调用和方法执行的结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。
栈帧中存储了方法的局部变量表,操作数栈,动态连接和方法返回地址以及额外的一些附加信息。
每个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译程序代码中,局部变量表的大小已经确定,操作数栈深度也已经确定,因此与栈帧在运行的过程中需要分配多大的内存是固定的,不受运行时影响,对于没有逃逸的对象也会在栈上分配内存,对象的大小其实在运行时也是确定的,因此即使出现了栈上内存分配,也不会导致栈帧改变大小。
一个线程中的方法调用链可能很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法成为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图:
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Slot)为最小单位。
一个Slot可以存放一个32位以内的数据类型,每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量,索引值的范围是从0到局部变量变最大的Slot数量。如果访问的是32位的数据类型的变量,索引n就代表了使用第n个Slot。如果访问的是64位的数据类型的变量,会同时使用n和n+1两个Slot,不允许单独访问其中的某个Slot。
Slot复用
当一个变量的pc寄存器的值大于Slot的作用域的时候,Slot是可以复用的。
使用System.gc()运行后并没有回收这64MB的内存,因为在执行System.gc()时,变量placeholder还处于作用域之内,虚拟机自然不敢回收placeholder的内存。
加入了花括号之后,placeholder的作用域被限制在花括号之内,从代码逻辑上来讲,运行System.gc()的时候,placeholder已经不可能再被访问了,但内存还是没有被回收,再测试一个案例,再对这个问题进行讲解。
这次被垃圾收集器回收了。
placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。
案例二中:代码虽然离开了placeholder的作用域,但之后没有任何对局部变量表的读写操作,placeholder原本占用的Slot还没有被其它变量所复用,所以作为GC Roos一部分的局部变量表仍然保持对它的关联。
当遇到一个方法,其后面的代码有一些耗时较长的操作,将前面定义了占用大量内存、实际上已经不再使用的变量手动设置为null(如同案例三的int i = 0,把变量对应的局部变量表的Slot清空)。这种操作常用于对象占用内存大,此方法的栈帧长时间不能被回收、方法调用次数达不到JIT编译条件下使用。
操作数栈也常称为操作栈,它是一个后入先出的栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的元素可以是任意的java数据类型。
32位的数据类型占用的栈容量为1,64位是2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚开始执行时,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
举例:整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行该指令时,会将两个int值出栈并相加,然后将相加的结果入栈。
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的,但大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现部分重叠。让下面栈帧的部分数栈与上面栈帧的部分局部变量表重叠在一起,这样进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。重叠的过程如下图:
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用为了支持方法调用过程中的动态连接。
Class文件中存在大量符号引用,引用又分为静态引用和动态引用。
a.静态引用:符号引用在类初始化阶段或者第一次使用的时候就转换成直接引用。 b.动态引用:符号在每一次运行期间转换成直接引用。方法调用时通过一个指针指向方法的地址,方法返回时将回归到调用处,那个地方就是返回地址。
当一个方法开始执行后,只有两种方式可以退出这个方法。分为正常完成出口和异常完成出口。
1.正常完成出口:执行引擎遇到任意一个方法返回的字节码指令,是否有返回值和返回值类型根据遇到的方法指令来决定。 2.异常完成出口:在方法执行过程中遇到了异常,并且这个异常并没有被处理,无论是虚拟机内部产生的异常还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜素到匹配的异常处理器,就会导致异常退出。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与 调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发 中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本
(即调用哪一个方法)。
类加载的解析阶段会将一部分的符号引用转化成直接引用,该解析的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。
调用目标在程序编译时就必须确定下来,这类方法的调用称为解析(Resolution)。
比如:静态方法,构造器方法,私有方法,final修饰的方法。
Java虚拟机中提供了5条方法调用字节码的指令:
1.invokestatic:调用静态方法。 2.invokespecial:调用实例构造器<init>方法、私有方法和父类方法。 3.invokevirtual:调用所有的虚方法。 4.invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。 5.invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令 的分派逻辑是由用户所设定的引导方法决定的。只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合该条件的有静态方法、私有方法、实例构造器、父类方法这4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。称为虚方法,反之其它的方法则称为虚方法(除去final方法)。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。
分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型案例就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
a. 案例
package com.kevin.jvm.dispatch; /** * @author caonanqing * @version 1.0 * @description 测试静态分派 * @createDate 2019/8/3 */ public class Demo { static class Parent{} static class Child1 extends Parent{} static class Child2 extends Parent{} public void sayHello(Child1 c){ System.out.println("c1 is call"); } public void sayHello(Child2 c){ System.out.println("c2 is call"); } public void sayHello(Parent p){ System.out.println("p is call"); } public static void main(String[] args) { // 父类称为静态类型,子类称为实际类型 Parent p1 = new Child1(); Parent p2 = new Child2(); Demo d = new Demo(); // 选择的时候根据静态来选择。称为静态类型 d.sayHello(p1); d.sayHello(p2); } }b.案例:重载方法匹配优先级
package com.kevin.jvm.dispatch; import java.io.Serializable; /** * @author caonanqing * @version 1.0 * @description 测试静态分派 * @createDate 2019/8/3 */ public class Demo2 { public void sayHello(int a){ System.out.println("int !"); } public void sayHello(char a){ System.out.println("char !"); } public void sayHello(Object a){ System.out.println("Object !"); } public void sayHello(Character a){ System.out.println("Character !"); } public void sayHello(Serializable a){ System.out.println("Serializable !"); } public void sayHello(char... a){ System.out.println("char... !"); } public static void main(String[] args) { Demo2 d = new Demo2(); d.sayHello('a'); } }'a'是char类型的数据,自然会寻找参数类型,为char的重载方法,如果注释掉sayHello(char a),那么输出会变成
按照char->int->long->float->double的顺序转型进行匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。
编译期间选择静态分派目标的过程,这个过程也是Java语言实现方法重载的本质
动态分派和多态性的另一个重要体现---重写有着很密切的关联。
案例
public class Demo { static class Parent{ public void sayHello(){ System.out.println("parent"); } } static class Child1 extends Parent { @Override public void sayHello() { System.out.println("child1"); } } static class Child11 extends Child1 { @Override public void sayHello() { System.out.println("child11"); } } static class Child2 extends Parent { @Override public void sayHello() { System.out.println("child2"); } } public static void main(String[] args) { Parent p1 = new Child11(); Parent p2 = new Child2(); p1.sayHello(); p2.sayHello(); } }invokevirtual指令的运行时解析过程大致分为以下几个步骤:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。 2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。 3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。 4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
静态类型的语言在非运行阶段,变量的类型是可以确定的,也就是说变量是有类型的。
动态类型语言在非运行阶段,变量的类型是无法确定的,也就是变量是没有类型的,但是值是有类型的,也就是运行期间可以确定变量的类型。
大部分程序代码到物理机的目标代码或虚拟机能执行的指令集之前都需要经过下图中的各个步骤,下面那条分支就是传统编译原理中程序代码到目标机器代码的生成过程,二中间那条分支,自然就是解释执行的过程。
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法生成线性的字节码指令流的过程。因为这一部分动作实在Java虚拟机之外,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction SetArchitecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集。
举个例子:分别使用这两种指令集计算“1+1”的结果。
基于栈的指令集:
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。
基于寄存器的指令集:
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。
案例:简单的算术案例
public class Hello{ public int calc(){ int a = 100; int b = 200; int c = 300; return (a+b)*c; } }上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述……更准确地说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化。
如,在HotSpot虚拟机中,有很多以“fast_”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。
不过,我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。