jvm 字节码执行 (一)方法调用
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上,而虚拟机的执行引擎是
由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
运行栈帧结构
栈帧,是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区的虚拟机栈的栈元素。栈帧存储包括了局部变量、操作数栈、动态连接、方法返回地址和
一些额外的附件信息。在编译代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法Code属性之中。因此一个栈帧需要分配多少内存,不会受到
程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Slot)为最小单位,虚拟机规范并没有明确指定一个Slot占用的内存空间大小,
虚拟机通过索引定位的方式使用局部变量表,索引值范围从0开始至局部变量表最大数量。局部变量表中第0位索引默认是用于传递方法所属对象实例的应用。方法中可以通过关键字“this”访问到这个
隐含的对象。
局部变量表中的Slot是可以重用的,如果当前字节码PC计数器值已经超过了某个变量的作用域那么这个变量对应的slot可以交给其他变量使用。
public static void main(String[] args) {
byte[] placeholder = new byte[64*1024*1024];
System.gc();
}
在虚拟机运行参数中加上“-verbose:gc”,运行结果如图,并没有收回placeholder的内存
修改代码,
public static void main(String[] args) {
{
byte[] placeholder = new byte[64*1024*1024];
}
System.gc();
}
加入花括号之后,placeholder的作用域被限制在花括号之内,从逻辑上讲,在执行System.gc()的时候,placeholder已经不能再被访问,但执行结果如如图,64MB的内存没有被回收。
这是为什么呢?
再次修改代码:在调用System.gc()之前加入一行“int b = 0;”
public static void main(String[] args) {
{
byte[] placeholder = new byte[64*1024*1024];
}
int b = 0;
System.gc();
}
运行程序,发现这次内存真的被正确回收了。
placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,
没有任何对局部变量表的读写操作,placheholder原本占用的Slot还没有被其他变量所复用,所以作为GC Roots的一部分局部变量表仍然保持着对它的关联。
关于变量表,还有一点可能对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在“准备阶段”。我们已经知道类变量有两次赋初试值的过程,一次在准备阶段,一次在初始化阶段。
因此即使在初始化阶段没有为类变量赋值也没有关系,类变量仍有一个确定的初始值。但是局部变量不一样,如果一个局部变量定义了但没有赋值是不能使用的。
这段代码是不能运行的:
public static void main(String[] args) {
int a;
System.out.pritln(a);
}
操作数栈
它是一个后入先出栈。操作数栈的最大深度在编译的时候写入到Code属性的max_stacks数据项中。Java虚拟机的解释执行伊宁称为“基于栈的执行引擎”。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。持有这个引用时为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码的方法
调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化成为静态解析。另外一部分将在每次运行
期间转化为直接应用,这部分成为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种是正常完成出口。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理。
就会导致方法退出。异常方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,来帮助恢复它的上层方法的执行状态。一般
方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(有的话)压入调用者栈帧的操作数栈中。调整
PC计数器的值以指向方法调用指令后面的一条指令等。
方法调用
Class文件里面存储的都是符号引用,不是方法在实际运行时内存布局中的入口地址。这个特性给Java带来了更强大的动态扩展能力,但也使Java方法调用过程变得相对复杂起来,需要在类加载
期间甚至到运行期间才能确定目标方法的直接运行。
解析
所有方法调用中的目标方法在Class文件里面都是一个常量池的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。这种解析能成立的前提是:方法在程序真正运行
之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器运行编译时就必须确定下来。这类方法的调用称为解析。
在Java语言中符合“编译器可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者直接与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不
可能通过继承或者别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下:
- invokestatic: 调用静态方法
- invokespecial: 调用实例构造器方法<init> 方法、私有方法和父类方法
- invokevirtual: 调用所有的虚方法
- invokeinterface: 调用接口方法,会再运行时再确定一个实现此接口的对象。
- invokedynamic: 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部分,而invokedynamic指令的分派逻辑是由用户
所设定的引导方法决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本。符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号
引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚拟方法。虽然final方法是使用invokevirtual指令来调用,但是由于它无法被覆盖,没有其他版本,所以也无须对方
法接受者进行多态选择,又或者说多态选择的结果肯定是唯一的,在Java语言规范中明确说明了final方法是一种非虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段会把设计的符号引用全部都转换为可确定的直接引用,不会延迟到运行期再去完成。
分派
分派调用则可能是静态的也可能是动态的,根据分派的宗量数可分为单分派和多分派。这两类分配组合成了静态单分派、静态多分派、动态单分派、动态多分派。
1、静态分派
在编译阶段发生,在方法重载时虚拟机如何䦺正确的目标方法。
public class StaticDispatch { static abstract class Human { } static class Man extends Human { } static class Woman extends Human { } public void sayHello(Human guy) { System.out.println("hello, guy!"); } public void sayHello(Man guy) { System.out.println("hello, gentleman!"); } public void sayHello(Woman guy) { System.out.println("hello, lady!"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch staticDispatch = new StaticDispatch(); staticDispatch.sayHello(woman); staticDispatch.sayHello(man); } }
运行结果:
Human man = new Man();
我们把上代码中的“Human”称为静态类型,或者外观类型。后面的“Man”称为实际类型。静态类型和实际类型再程序中都可以发生变化,区别是静态类型的变化仅仅在使用时发生,
变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;而实际类型在运行期才可确定,编译器在编译程序时并不知道一个对象的实际类型是什么。
//实际类型变化 Human man = new Man(); Human woman = new Woman(); //静态类型变化 staticDispatch.sayHello(woman); staticDispatch.sayHello(man);
在上例中,main()里面两次sayHello()方法调用,使用哪个重载版本,就完全取决于传入参数的树龄和数据类型。虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定的依据
并且静态类型是编译器可知的,因此在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写
到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行
的。另外,编译器虽然能够确定出方法的重载版本,但是在很多情况下这种重载版本并不是“唯一的”,往往只能确定一个“更加合适的版本。
静态匹配(确定目标方法):参数类型完全匹配<-参数类型向上扩展(short->char->int->long->float->double)<-参数类型装箱<-封装类型向上匹配(父接口,父类。越向上,优先级越低)<-可变参数
解析与分派两者之间的关系并不是二选一的排他关系,是在不同层次行去筛选确定目标的过程。
2、动态分配
和重写有着很密切的关联。
public class DynamicDispatch { static abstract class Human { protected abstract void sayHello(); } static class Man extends Human { @Override protected void sayHello() { System.out.println("man say hello"); } } static class Woman extends Human { @Override protected void sayHello() { System.out.println("woman say hello"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = new Woman(); man.sayHello(); } }
运行结果:
我们使用javap输出部分代码的字节码:
17和21是方法调用指令,他们完全相同。但是他们最终执行的目标方法不行同。原因要从invokevirtual指令的多态查找开始说起。invokevirtual指令的运行时解析步骤过程大致
可分为以下几个步骤:
1)、找到操作数栈的第一个元素所指向的对象的实际类型,记作C。
2)、如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,否则抛出异常
3)、否则,按照集成关系从下往上一次对C的各个父类进行第2步搜索和验证过程。
4)、如果始终没有找到合适的方法,抛出异常。
由于invokevirtual指令执行的第一步就是在运行时期确定接受者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,
这个过程就是java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3、单分派与多分派
方法的接受者与方法的参数统称为方法宗量,根据分派基于多少宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是
根据多于一个宗量对目标方法进行选择。
public class Dispatch { static class QQ {} static class _360 {} public static class Father { public void hardChoice(QQ arg) { System.out.println("father choose qq"); } public void hardChoice(_360 arg) { System.out.println("father choose 360"); } } public static class Son extends Father { public void hardChoice(QQ arg) { System.out.println("son choose qq"); } public void hardChoice(_360 arg) { System.out.println("son choose 360"); } } public static void main(String[] args) { Father father = new Father(); Father son = new Son(); father.hardChoice(new _360()); son.hardChoice(new QQ()); } }
运行结果:
在编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果
最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择
所以java的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这句代码时,是在执行这句代码对应的invokevirtual指令,由于编译器已经决定
目标方法的签名必须为hardChoice(QQ),虚拟机此时不关心传递过来的参数。因为这是参数的静态类型、实际类型都对方法的选择不会构成任何影响。唯一可以影响虚拟选择的因素
只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以java语言的动态分派属于单分派类型。
根据以上论证的结果,1.7以前版本的java语言是一门静态多分派、动态单分派的语言。
4、虚拟机动态分配的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分
实现都不会真正进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区建立一个虚方发表,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都是指向父类的实现
入口。如果子类中重写了这个方法,子类方法表中地址将会替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚拟方法表中都应当具有一样的索引号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚拟方法表中
按照索引换出所需的入口地址。虚拟方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化值后,虚拟机会把类的方法表也初始化完毕。