纸上得来终觉浅 绝知此事要躬行
前言:本文参考自 周志明先生的《深入理解Java虚拟机》作学习记录作用,想详细学习java虚拟机的朋友建议买一本书仔细研读。
执行引擎是java虚拟机中最核心的部分之一。
首先,我们要清楚什么是虚拟机,虚拟机是相对于物理机而言的,只不过物理机的执行能力是建立在处理器、硬件、指令集等等层面上的。而虚拟机都是由自己实现的。
执行引擎:输入的字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
栈帧
是什么:是虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区的虚拟机栈的栈元素
什么用:栈帧中存放了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加的信息。
怎么理解栈帧:一个方法的执行从开始到执行结束这个过程,对应的也是一个栈帧从入栈到出栈的一个过程。
栈帧所包含的各个属性信息都是确定的,在编译的过程中它的局部变量表需要多少内存都已经确定了,并且写到了方法表的Code属性中。所以一个栈帧需要多少内存不会受到运行期间变量数据的影响。
接下来要详细介绍一波栈帧中的各个属性信息。
局部变量表:用来存放方法参数和方法内部定义的局部变量,在编译class文件的时候,就会给定Code中的max_locals数据,确定了这个方法的局部变量所需的最大容量。局部变量表的存储单位是槽(Slot)
操作数栈:同局部变量表一样,是LIFO的栈结构。在编译的时候就确定了操作数栈的深度,这个栈中存放的是JAVA的基本类型。
动态链接:每个栈帧中都有一个指向运行时常量池中该栈帧所属方法的引用。这个引用主要是为了支持方法调用过程中的动态链接。
我们知道Class文件中存在大量符号引用,引用又分成静态引用和动态引用。
静态引用:符号引用在类初始化阶段或者第一次使用的时候就转换成直接饮用。
动态引用:符号在每一次运行期间转换成直接引用。
方法返回地址:一个方法的返回分成正常情况和异常情况,正常情况是执行引擎执行到方法返回的字节码指令。
正常情况可能会产生返回值,异常情况不会产生返回值。无论何种情况导致退出之后,都需要返回到方法被调用的位置。程序才能继续。正常情况退出的方法,PC计数器可以作为返回地址、但是异常情况返回地址要通过异常处理器来确定。
方法调用
方法调用并不是方法执行,方法调用其实是确定接下来要执行的方法是哪一个方法,尚未涉及方法内部。
解析:符合“编译时可知,运行时不可变”的要求方法调用称为解析;
所有方法调用的目标方法在Class文件中都只是常量池中的一个符号引用。在解析的过程中,这些符号引用会有一部分转换成直接引用。
解析的前提就是方法在运行之前(也就是编译期间)就有一个确定的调用版本,并且这个方法在调用的时候是不可以改变的。满足这些前提要求的方法是静态方法和私有方法。(因为前者在编译的时候类型就确定了,后者在外部不会被访问。这两种情况说明他们不可能通过任何重写其他版本)
invokeSiatic | 调用静态方法 |
invokespecial |
调用实例构造函数<init>方法、私有方法和父类方法 |
invokevirtual | 调用所有虚方法 |
invokeinterface | 调用接口,会在运行的时候确定一个方法实现此接口 |
invokedynamic |
只要能被invokestatic和invokespecial方法调用的方法都可以在解析的时候确定其唯一的调用版本。符合这个条件的有静态方法、私有方法、父类方法、构造器方法。
分派:分派调用可能是静态的也可能是动态的。可以分成静态单分派 动态单分派、静态多分派以及动态多分派。
静态分派
public class fenpaiTest{
static abstract class Animal{
}
static class Lion extends Animal{
}
static class Tiger extends Animal{
}
public static void testMethod(Animal animal){
System.out.println("this is a animal!");
}
public static void testMethod(Lion lion){
System.out.println("this is a lion!");
}
public static void testMethod(Tiger tiger){
System.out.println("this is a tiger!");
}
public static void main(String[] args) {
Animal animal = new Animal();
Animal lion= new Lion();
Animal tiger= new Tiger();
testMethod(lion);
testMethod(tiger);
}
}
首先需要清楚的两个概念,静态类型以及实际类型。
Animal lion = new Lion() 中的Animal是静态类型而Lion()是实际类型。
在程序编译的过程中,编译器是根据静类型作为判断的类型而不是实际类型,因为静态类型是编译的阶段就可知的类型,而实际类型是在运行期可知,所以javac编译器会根据静态类型决定使用哪个重载方法的版本。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用就是方法重载。
以上就是静态分派的内容,静态分派发生在编译阶段,所以实现静态分派并不是由虚拟机实现的,而是编译器。
另外还有一个特殊点,字面量是没有显示静态类型的。以下代码举例
public static void main(String[] args) {
jump('a');
}
public static void jump(int a){
System.out.println("int type!");
}
public static void jump(char a){
System.out.println("long type!");
}
public static void jump(Object a){
System.out.println("Object type!");
}
public static void jump(Character a){
System.out.println("Character type!");
}
public static void jump(Serializable a){
System.out.println("Serializable type!");
}
public static void jump(char... a){
System.out.println("char... type!");
}
没有具体静态类型的时候,他会自动匹配一个最合适的方法来实现。这里涉及到优先级和转换安全的知识
以这个部分为例。char >> int >>long >>Character>> Serializable>>Object>>char...
如果出现了两个优先级一致的方法,那么编译器会拒绝编码,除非你在代码中给他制定类型。
动态分派
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) {
// jump('a');
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
}
运行结果:
从代码中分析可以很明显的确定这里已经不再是根据静态类型来选取的了,原因是他们拥有同一个静态类型,却得到了两个不同的输出结果。导致这个现象的原因很明显实际变量不同。
那么问题来了,JVM是怎么根据实际变量分派方法版本的呢?
上面的解析我们讲到,确定调用那个方法版本会用到invokevirtual指令,下面我们分析一下该指令的解析步骤。
1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2、如果在类型C中找到与常量中的描述符和简单名称相符合的方法,然后进行访问权限验证,如果验证通过则返回这个方法的直接引用,查找过程结束;如果验证不通过,则抛出java.lang.IllegalAccessError异常。
3、否则未找到,就按照继承关系从下往上依次对类型C的各个父类进行第2步的搜索和验证过程。
4、如果始终没有找到合适的方法,则跑出java.lang.AbstractMethodError异常。
我们可以看到第一步就是找到对象的额实际类型。所以动态分派可以实现根据接受者的实际类型不同寻找到对应直接引用上,这个过程就是重写的本质了。