在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能有解释执行和编译执行两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。但从外观上,所有的Java虚拟机的执行引起嫩都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
1. 运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,活动线程,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的所有的字节码都只针对当前栈帧进行操作,栈帧的结构如下图:
局部变量表
用于存放方法参数和方法内部定义的局部变量,在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。
在栈帧中,最小的单位为变量槽(Variable Slot),其中每个Slot占用32个字节。在32bit的JVM中32位的数据类型占用1个Slot,64bit数据占用2个Slot;在64bit中使用64bit字节填充来模拟32bit(又称补位),因此我们可以得出结论:64bit的JVM比32bit的更消耗内存,但是又出32bit机器的内存上限限制,有时候牺牲一部分还是值得的。Java的基本数据类型中,除了long、double两种数据类型为64bit以外,boolean、byte、short、char、int、float、reference和returnAddress都是32bit的数据类型。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数列表的传递过程的,如果是实例方法,那么局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可通过this来访问这个隐含的参数,其余参数则按照参数列表的顺序来排列,之后再根据方法体中变量定义的顺序和作用域来分配其余Slot。
局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并一定会覆盖整个方法体,如果当前字节码PC的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以直接交给其它变量使用。这样做不仅仅节省了栈空间,在某些情况下Slot的复用就会直接影响到整个系统的垃圾收集行为:
//case1
public static void main(String[] args) {
//case 1
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
运行结果:
[GC (System.gc())67123K->66208K(126976K), 0.0216788 secs]
[Full GC (System.gc())66208K->66062K(126976K)
//--------------------------------------------------------------
//case2:
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
运行结果:
[GC (System.gc()) 67123K->66144K(126976K), 0.0010326 secs]
[Full GC (System.gc())66144K->66062K(126976K)
//---------------------------------------------------------
//case3
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
运行结果:
[GC (System.gc()) 67123K->66176K(126976K), 0.0011534 secs]
[Full GC (System.gc()) 66176K->526K(126976K)
从上面的运行结果可以看出,case1没有回收掉placeholder所占的内存,因为在执行System.gc()时,变量placeholder还处于作用域之内,虚拟机自然不会回收调用placeholder所占的内存。
case2加了花括号之后,placeholder的作用域被限制在花括号之内,从代码逻辑上讲在执行System.gc()的时候,placeholder已经不可能在被访问了,但是内存仍然没有释放,原因是局部变脸表中的Slot还存在关于数组placeholder的引用。代码虽然离开了placeholder的作用域,但是在此之后,没有任何对局部变量的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。
case3内存被真正的回收了,是因为placeholder所占的Slot被局部变量a所重复利用了。所以placeholder数组所占的内存空间没有与GC Roots的连接,可以被回收。
操作数栈
操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中,元素可以为任意类型,32微数据类型所占的栈容量为1,63微数据类型所占的栈容量为2。
操作数栈最开始为空,由字节码指令往栈中存数据和取数据,方法的返回值也会存到上一个方法的操作数栈中。
大多数虚拟机的实现里,通过下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,在进行方法调用时就可以共用一部分数据,而无须进行额外的参数复制传递。如下图:
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
方法返回地址存放的是调用该方法的pc计数器值,当方法正常返回时,就会把返回值传递到上层方法调用者。当方法中发生没有可被捕获的异常,也会返回,但是不会向上层传递返回值。
附加信息
虚拟机允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。如下:
2. 方法调用
方法调用并不等于方法执行,方法调用阶段唯一的任务就是确定被调用方法。Class文件的编译过程中不包含传统编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局的入口地址。
解析
在Java语言中,符合“编译期可知, 运行期不可变”这个要求的方法主要有静态方法(类的构造方法也是静态方法)和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法都不可能通过继承或别的方式重写出其他版本,因此都适合在类加载阶段进行解析。
与之对应,在java虚拟机里面提供了四条方法调用字节码指令,分别是:
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器方法、私有方法和父类方法。
- invokevirtual:调用所有的虚方法(没有被final修饰的方法)。
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法四类,他们在类加载的时候就会把符号引用解析为该方法的直接引用。
解析调用一定是一个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把设计的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。
分派
分派调用可能是静态的也可能是动态的。根据分派依据的宗量数可分为单分派和多分派。
- 静态分派
先看下面的重载代码:
package org.fenixsoft.polymorphic;
/**
* 方法静态分派演示
* @author zzm
*/
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 sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
运行结果:
hello,guy!
hello,guy!
Human man = new Man();
我们把Human称为变量的静态类型,Man称为变量的实际类型,变量最终的静态类型不会改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
//实际类型变化
Human man = new Man();
Human woman = new Woman();
//静态类型变化
sr.sayHello((Man)man);
sr.sayHello((Woman)woman);
运行结果:
hello,gentleman!
hello,lady!
从上面的运行结果可知,虚拟机在重载的时候是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型在编译期就可知。
所有依赖静态类型来定位执行方法的分派动作,都成为静态分派,静态分派最典型的应用就是重载。静态分派发生在编译阶段,因此确定静态分派的实际动作不是由虚拟机来执行的。
编译期虽然能确定出方法的重载版本,但在很多情况下重载版本并不是唯一的,往往只能确定一个更加合适的版本。如下:
package org.fenixsoft.polymorphic;
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(char... arg) {
System.out.println("hello char ...");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
运行结果:
hello char
//---------------------------------
//注释掉sayHello(char arg)方法
hello int
//原因:发生一次自动类型转换
//---------------------------------
//注释掉sayHello(int arg)方法
hello long
//原因:发生两次自动类型转换
//---------------------------------
//注释掉sayHello(long arg)方法
hello Character
//原因:发生一次自动装箱
//---------------------------------
//注释掉sayHello(Character arg)方法
hello Serializable
//原因:自动装箱后还是找不到装箱类,但是找到了装箱类实现的接口类型,所以紧接着发生了一次自动类型转换, Character是绝对不能转型为Integer的,他只能安全的转为它实现的接口或者父类。Character还实现了接口Comparable。如果同时出现两个参数分别为Serializable和Comparable的重载方法,那么他们此时的优先级一样,编译期无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。
静态方法会在类加载期就进行解析,而静态方法也可以拥有重载版本的,选择重载版本的过程称为静态分派。
- 动态分派
动态分派和重写有着密切的关联。如下例子:
package com.xqq.虚拟机字节码执行引擎;
/**
* 方法动态分派演示
* @author zzm
*/
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();
}
}
运行结果:
man say hello
woman say hello
woman say hello
动态分派的实现:当调用一个对象的方法时,会将该对象的引用压栈到操作数栈,然后字节码指令invokevirtual会去寻找该引用实际类型。如果在实际类型中找对应的方法,且访问权限足够,则直接返回该方法引用,否则会依照继承关系对父类进行查找。实际上,如果子类没有重写父类方法,则子类方法的引用会直接指向父类方法。
- 单分派与多分派
Java语言是一门静态多分派、动态单分派的语言。示例如下:
/**
* 单分派、多分派演示
* @author zzm
*/
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 choose 360
son choose qq
静态分派过程:选择目标的方法看两点,一点是静态类型是Father还是Son,二是方法参数是QQ还是360。
动态分派过程:由于编译器已经决定目标方法的签名必须是QQ,所以只需要判断接收者的实际类型是father还是Son。
- 虚拟机动态分派的实现
为了避免运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现过程中,会为类在方法区中建立一个徐方法表,使用徐方法表索引来代替袁术查找以提高性能。虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表里面的入口地址和父类相同方法的入口地址一致,如果子类重写了这个方法,子类的方法表中的地址将会被替换为指向子类实现版本的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法也初始化完毕。
3. 基于栈的字节码解释执行引擎
解释执行
不管是解释型语言还是编译型语言,机器都无法理解非二进制语言。高级语言转化成机器语言都遵循现代经典编译原理。即执行前对程序源码进行词法和语法分析,构建抽象语法树。C语言等编译型语言会由单独的执行引擎做这些工作,而Java语言等解释型语言语法抽象树由jvm完成。jvm可以选择通过解释器来解释字节码执行还是通过优化器生成机器代码来执行。基于栈指令集和基于寄存器指令集
常用的两套指令集架构分别是基于栈的指令集和基于寄存器的指令集。
例如1+1, 基于栈的指令集如下:
iconst_1 ;将1入栈
iconst_1 ;将1入栈
iadd ;将栈顶两个元素取出相加并将结果入栈
istore_0 ;将栈顶的值放到局部变量表中
基于寄存器的指令集如下:
mov eax,1 ;向eax中存1
add eax,1 ;eax<-eax+1
总体来说,基于栈的指令集会慢一些,但是它与寄存器无关,更容易实现到处运行的目标,即可移植性强。
- 基于栈的解释器执行过程
下面例子给出四则运算加减乘除法:
public class Demo {
public static void foo() {
int a = 1;
int b = 2;
int c = (a + b) * 5;
}
}
直接使用命令javap查看它的字节码指令如下:
public static void foo();
Code:
0: iconst_1//把操作数压入操作数栈
1: istore_0//将操作数栈顶元素弹出保存至局部变量表中
2: iconst_2
3: istore_1
4: iload_0
5: iload_1
6: iadd
7: iconst_5
8: imul
9: istore_2
10: return
执行过程如下图:
注意: 该方法为static方法,所以局部变量表中的第1个元素不是this。