每当启动一个线程时,JVM就为它分配一个Java栈,栈是以帧为单位保存当前线程的运行状态的。某个线程正在执行的方法称为当前方法,当前方法使用的帧称为当前帧,当前方法所属的类称为当前类,当前类的常量池称为当前常量池。当线程执行一个方法时,它会追踪当前常量池。
栈帧是虚拟机栈的栈元素,每当在调用一个方法时,才为当前方法分配一个帧,然后将该帧压入栈顶,这个帧就成了当前帧,当执行这个方法时,它使用这个帧来存储参数、局部变量,中间计算结果等。
栈是保存线程运行状态的,帧是保存方法执行的运行状态的,帧是栈的元素。线程的切换对应着栈的出栈入栈,线程中的不同方法依次运行对应着帧的出栈和入栈。
帧的组成部分
1. 局部变量表
局部变量表的大小是编译期间可知的,因为在java程序编译成class文件的时候,就在方法表对应方法的code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽slot为最小单位,一个slot的大小为32bit,在64位虚拟机中需要使用对齐和补白的手段让slot在外观上看起来与32位虚拟机中的一致。对于64位的数据(long,doble)需要占用两个slot,对于这种占两个slot的数据类型存储,不允许采用任何方式单独访问其中某一个。
局部变量表顺序:变量表从索引0开始,依次存放方法所属对象的引用(如果为静态方法则没有)、方法参数变量(按照声明顺序)、方法内局部变量(按照声明顺序)。注意,对于short、byte、char这三种数据类型需要转换成int类型存储在局部变量表中。
类变量与局部变量:class文件在被JVM加载时,创建Class对象,分配内存空间时会为类变量指定初始值。但局部变量定义了没有赋初始值是不能使用的,会出现编译错误。
slot是可重用的:对于局部变量中没有覆盖整个方法的作用域的变量是可重用的。对于可重用的slot,如果后面没有在定义变量对这个slot进行覆盖,即使这个变量已经离开了其作用域(无效),那么这个变量在方法体内也不会被回收,除非显示的赋值为null(解释执行的时候),但是在JIT编译器优化后赋值为null的操作就会被消除掉,这时候将变量设置为null就是没有意义的。
2. 操作数栈
操作数栈和局部变量表一样也是编译期间可知的,操作数栈的最大深度在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意java数据类型,32位数据容量为1,64位数据容量为2,在方法执行的时候,操作数栈的深度不会超过max_stacks数据项中设定的最大值。
在概念模型中,两个栈帧是相互独立的,但是在多数虚拟机实现里都会做一些优化处理,令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面的栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就会公用一部分数据,无需进行额外的参数复制。java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中栈指的就是操作数栈。
3. 动态连接
每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用
持有这个引用是为了支持方法调用过程中的动态连接,class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。
这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。
另外一部分将在每一次运行期间转化为直接引用,这部分称为动态解析。
4. 方法返回地址
退出方法有两种方法:
正常完成出口:执行引擎遇到任意一个方法返回的字节码指令。
异常完成出口:在方法的执行过程中遇到了异常,并且这个异常没有在方法体内得到处理。
无论以哪种方式退出,都需要返回到方法被调用的位置,程序才能继续执行,一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值;方法异常退出时,返回的地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出相当于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话) 压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
方法调用
确定方法调用的版本,这个阶段并没有执行方法体的内容。class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用),需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析调用
在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析的前提是:方法在程序真正执行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可变的。换句话说,调用目标在程序代码写好,编译器进行编译时就必须确定下来。这类方法的调用称为解析调用。
在java语言中符合“编译期间可知,运行期间不变”要求的方法主要有:静态方法(invokestatic指令调用)、私有方法(invokespecial指令调用)、实例构造器方法(invokespecial指令调用)、父类方法(invokespecial指令调用)、final方法(invokevirtual指令调用)。凡是能在解析阶段中确定唯一的调用版本的这些方法称为非虚方法,与之相反,其他的方法则成为虚方法。分派
2.1. 静态分派与多态之重载
重载 : 方法名相同,方法签名不同,调用时根据方法的签名选择最佳的方法。
虚拟机(编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的, 并且静态类型是编译期间可知的,因此,在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本。
静态类型不可变,编译期间可知,实际类型可变,编译期间不可知,运行期间确定。
//实际类型变化
Human man = new Man();
man = new Women();
//静态类型变化
sr.sayHello((Man) man);
sr.sayHello((Woman) man);
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是有虚拟机来执行的。很多情况下这个重载版本并不是“唯一的”,往往只能确定一个更加合适的“版本”。
2.2 动态分派与多态之重写
重写:子类重写父类的方法,方法名和方法签名都相同,注意静态方法可以重载,但是对静态方法进行重写是无效的(通过子类的实例对象调用,则对应的是父类定义的静态方法,通过子类的类对象调用则调用子类定义的静态方法)
public class DynamicDispatchTest {
private static void print(String str){
System.out.println(str);
}
static class Human{
protected void sayHello(){
print("human");
}
protected static void printStatic(){
print("human static");
}
}
static class Man extends Human{
@Override
protected void sayHello(){
print("man");
}
protected static void printStatic(){
print("man static");
}
}
static class Woman extends Human{
@Override
protected void sayHello(){
print("woman");
}
protected static void printStatic(){
print("woman static");
}
}
public static void main(String[] args){
//动态分派测试
print("动态分派测试");
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
//静态方法重写测试
print("静态方法重写测试");
Human.printStatic();
Man.printStatic();
man.printStatic();
woman.printStatic();
}
}
/*
动态分派测试
man
woman
woman
静态方法重写测试
human static
man static
human static
human static
*/
解析和分派这两者之间不是二选一的排他关系,而是不同阶段的不同层次上去筛选、确定目标方法的过程。比如,静态方法会在类加载期间就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。
动态分派和多态重写的本质要从字节码指令invokevirtual的多态查找过程开始说起: 1) 找到栈顶元素所指向的对象的实际类型,记为C;2) 在类型C中找到与常量池中的描述符与简单名称都相符的方法,然后进行访问权限检查,如果通过则返回这个方法的直接引用,查找结束;如果不通过,则返回java.lang.IllegalAccessError异常。3) 否则,按照继承关系,继续重复2中搜索和验证过程。 4) 如果始终没有找到,则抛出java.lang.AbstractMethodError异常。
调用方法时,invokevirtaul指令把常量池中的类方法符号引用解析到了不同的实际类型的直接引用上,这个就是java方法中重写的本质。
动态分配的实现:动态分派时,在类的方法的元数据中搜索合适的目标方法,基于性能的考虑,避免频繁的搜索,会为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表:虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口时一致的,都指向父类的实现入口,如果子类中重写了这个方法,子类方发表的地址将会替代为指向子类实现版本的入口地址。具有相同方法签名的父类、子类的方法在父类和子类的虚方法表中具有相同的索引序号,这样当类型变换时,仅需要变更查找的方法表。
解释执行和直接执行
- 解释器
javac将java文件编译成class文件,将源代码编译成字节码(中间代码),这个字节码是与平台无关的,而解释器就是将字节码翻译成对应平台的机器码,解释执行。
- JIT即时编译器
分析Java应用程序的函数调用,将热点代码将字节码编译为本地更高效的机器码,JVM对这个函数就不在进行解释执行了,而是直接执行。
- 解释执行还是直接执行
- 在client模式下,是解释执行的。
- 在server模式下:先解释执行,然后JVM统计函数执行热点,将这些热点代码仔细优化编译成本地机器码(默认为调用10000次以上),然后执行本地机器码,当这个热点不再是热点的时候,释放编译的代码,重新解释执行。这也就是Sun JDK被称为 HotSpot(热点) VM的原因