java虚拟机规范只是定义了执行过程的概念模型,具体怎么实现由虚拟机决定。
运行时的栈帧结构:
我们知道虚拟机的运行时数据区有一部分叫做虚拟机栈。虚拟机栈本身就是一个栈结构,栈里面的元素是栈帧。所以虚拟机栈并不是我们直接理解上相对于函数运行时操作数入栈出栈的结构,而是一个更高层次的结构。每一个线程都有一套自己的虚拟机栈,里面是该线程的栈帧。
那么一个栈帧会存什么内容呢?
局部变量表,操作数栈,动态链接,方法返回地址和一些额外信息。下面一一来看。
局部变量表,是一组变量存储空间,存放了函数的参数和局部变量。关于局部变量表,需要搞清楚的是局部变量表的空间在编译完成时就可以知道,然后这个值会被存入一个方法的属性表中,即Code属性表的max_locals属性。为什么这一段空间可以确定大小?因为方法内的变量所占据的大小都是可以确定,基本类型肯定可以,引用类型因为只是存指针或者和地址相关的信息,所以引用所占的大小都可以确定,这样编译完一个方法,就可以知道其局部变量表的大小,然后就存入Code属性表里。当类加载器加载了这个类,并且要运行这个方法的时候,就可以根据Code属性表的max_locals属性来分配局部变量表的空间。
局部变量表的分配是按照Slot来的,一个Slot可以存放除了long和double以外的全部数据,long和double需要两个Slot,但是Slot具体多少位没有规定,可以根据具体的平台来定。
分配完空间以后就需要给局部变量表里Slot填入值,这个过程是执行函数时通过操作数栈来完成的,操作数栈如何使用Slot?通过索引来使用,比如第一个Slot可定是存this,那么在调用方法的时候,就需要把this参数放入到局部变量表,对应的字节码可能会是“store_0”,此时this处于栈顶,这条指令就是把栈顶的元素压入局部变量表第0个Slot。所有的参数和局部变量都是先按索引压入局部变量表,最后再按索引从局部变量表取出。
局部变量表填入的顺序是先填入方法参数,这个发生在栈帧转换时,会生成一些指令把参数放入局部变量表。然后会填入局部变量,法神在进入了被调用方的栈帧后,随着方法内字节码的执行,会将依次遇到的局部变量填入局部变量表,所有变量都会有变量的定义和赋值,此时就会通过索引的方式来填入,比如“store_[index]”,就是把栈顶元素存入index的Slot,最后使用该变量的时候也是通过索引,比如“load_[index]”就是把index的Slot放入栈顶。
本质上,对栈帧里面的局部变量表使用时通过索引,虚拟机不需要知道变量的名称。变量名只是有助于程序员的编程方便。虚拟机执行引擎执行代码时不需要知道变量名,它只需要在编译的时候为每一个变量生成一个索引,同一个变量定义和使用时(对应存入局部变量表和从局部变量表取出)对应一个索引就可以,编译以后的code里面没有变量名只有索引。
还记得class文件结构中的Code属性有一项可选的LocalVariableTable属性吗?该属性存的是变量索引与变量名的映射,既然虚拟机执行时不需要变量名,要这个映射干嘛?答案是调试,如果调试时没有这个映射,那么最后我们的调试窗口内就不会显示变量名而是一个一个的索引或者占位符。
操作数栈,就是对应了函数执行时的栈,操作数出栈入栈进行计算,比如相加操作,就先把栈顶两个操作数出栈,送入处理器,再把结果入栈。
下面看一个函数执行时例子:
假设代码如下:
public int calc(){
int a = 100;
int b = 200;
int c = 300
return (a+b)*c;
}
那么编译后的方法表为:
Code属性中stack最大深度为2,因为做二元操作只有两个操作数,索引最大深度是2。局部变量表是4个Slot,因为有this和a,b,c。参数大小为1,因为有this。
然后再该函数执行之前,局部变量表应该是
只是把参数装入了局部变量表。执行完语句2以后,会把100压入局部变量表,变为了
与此相同,执行到语句10,变为了
至此全部的局部变量表已经填完,之后对变量的引用是通过索引来的。比如11行的“iload_1”和12行的“iload_2”。
栈帧中的另外两块内容是动态链接和返回地址。
动态链接之后看,返回地址也就是上一个栈帧的PC值。
下面看下执行引擎是如何执行方法调用的。
方法调用
方法调用不等同于方法执行,方法执行的过程是与上面的操作数栈和局部变量表相关的内容。方法调用的任务是确定方法的入口地址。有两种方式,一种是解析,另一种是分派,分派也可以进一步分为静态分派和动态分派。
解析方式,就是类加载阶段的解析。这个过程会把符号引用转化为直接引用。这个过程是在实际运行之前,类加载时进行的。要在执行之前就知道方法的入口地址,意味着这个地址不可能在执行时变化,这样的方法有两种,一类是静态方法,另一类是私有方法。静态方法不可能被继承,私有方法已经指明了必须是本类的方法,所以他们都是可以在执行前确定的。不过我觉得final的方法应该也是可以的,同样不可以被继承。
分派方式,先来看静态分派。
静态分派主要解决方法的重载。java的一个特性有继承和多态。假如方法的参数里面有父类也有子类,那么该以哪个方法为入口呢?比如下面的例子:
class Base{
}
class Sub extends Base{
}
public class Main {
public static void main(String[] args) {
Base i = new Sub();
new Main().f(i);
}
public void f(Base b){
System.out.println("Base");
}
public void f(Sub s){
System.out.println("Sub");
}
}
会输出“Base”,因为重载是按照静态类型判断的,也即是静态分派。
我们在new一个实例时,前面的定义部分是静态类型,new后面的类型是动态类型。针对变量i,也就是Base是静态,Sub是动态。可以明确,静态类型是编译可知,或者说运行前可知,动态类型必须是运行时可知。重载版本是按照参数的静态类型来决定的,所以静态分派其实也是编译时决定的,或者说运行前决定的。
有一道京东考题是在调用时传入了一个null,那么该如何决定?居然是子类版本,原因不太清楚,欢迎解答。如果重载版本没有父子关系,传入null就无法确定了,这时编译会报错。
class Base{
}
class Sub extends Base{
}
public static void main(String[] args) {
Base i = new Sub();
new Main().f(null);
}
public void f(Base b){
System.out.println("Base");
}
public void f(Sub s){
System.out.println("Sub");
}
以上代码输出“Sub”,有父子关系。
class Base{
}
class Sub{
}
public static void main(String[] args) {
new Main().f(null);
}
public void f(Base b){
System.out.println("Base");
}
public void f(Sub s){
System.out.println("Sub");
}
以上代码编译无法通过。感觉有继承关系是一种特例吧,会重载子类参数版本。
动态分派,是解决重写的。这个实在执行时确定的,当执行到调用方法处,即invokevirtual指令,会查看调用者的实际类型C,然后先在类型C里面查找,如果没有再找父类。我们知道对象的引用不仅仅会给出实例的地址,也会给出类型的地址,所以这个实际类型是可以找到的。这就是动态分派的原理。
具体实现时,虚拟机不会每一次的动态分派都去执行上述的搜索操作,这是一个递归地向上过程,性能不佳,虚拟机会创建一个虚方法表为每一个类型,里面是这个类型所有方法的实际入口,指向了堆区Class对象的方法入口。若果重写了就是重写的方法入口,否则就是父类的入口,用空间换时间,节省了查找的时间。