您的位置:首页 > 其它

JVM系列七:执行引擎

2015-07-19 19:54 295 查看
Java虚拟机和物理机一样,都具有执行代码的能力,其区别在于,物理机执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行定制指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

在不同的Java虚拟机实现里,执行引擎在执行Java代码的时候可能会有解释执行和编译执行,也可能两者兼备。但从外观上来看,Java虚拟机执行引擎都是一致的:输入的是字节码文件,处理过程是字节码文件解析的等效过程,输出的执行结果。

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机栈的栈元素。调用一个方法创建一个栈帧并压入栈顶。在编译程序代码的时候,栈帧中需要多大的局部变量表,多大(深)的操作数栈都已经保存在class文件中的方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受程序运行期变量的数据影响。栈帧由以下四个部分组成

局部变量表

在方法表的Code属性中,max_locals表示局部变量表的最大容量(表的容量可以复用),容量以变量槽(slot)为最小单位。虚拟规范规定,每个slot都应该能存放一个基本数据类型(32位)、reference以及returnAddress。reference是一个对象实例的引用,虚拟机并没有规定说明它的长度,但引用至少做到两点:

1)从此引用能直接或间接查找到对象在Java堆中的数据存放地址
2)从此引用能直接或间接查找到对象在方法区中的类型信息

虚拟机通过索引定位的方式使用变量表,对于两个相邻的共同存放一个64位数据类型的变量,不允许采用任何方式单独访问其中的一个,此操作要在类加载的校验阶段(连接中的验证阶段)进行校验。

局部变量表的slot是可复用的,如果一个变量在在之后的代码中不再被使用,那么其所占的slot就有可能被之后的其它变量所占用。

public class Test2{
public static void main(String []args){
{
byte[] placeholder=new byte[64*1024*1024];
}
//int a=0;
System.gc();
}
}
/*
java -verbose:gc Test2
output:
[GC (Allocation Failure)  942K->578K(15872K), 0.0042658 secs]
[Full GC (Allocation Failure)  578K->577K(15872K), 0.0073536 secs]
[Full GC (System.gc())  66203K->66113K(81476K), 0.0044434 secs]
*/

可以看出,full gc后并没有回收placeholder的内存。在取消注释a变量后在运行结果

[GC (Allocation Failure)  942K->577K(15872K), 0.0041250 secs]
[Full GC (Allocation Failure)  577K->576K(15872K), 0.0101808 secs]
[Full GC (System.gc())  66201K->576K(81476K), 0.0056318 secs]

此时回收了placeholder的内存。因为之前代码虽然已经离开了placeholder的变量作用域,但之后没有对局部变量表的任何读写操作,所以没有及时被回收,而取消注释变量a之后,就有了对局部变量表的读写操作,局部变量表就被复用了。所以在《Practical Java》中把“不使用的对象手动赋值为null”作为一条推荐的编码规则。

还有一点要注意,对于局部变量,如果只是定义了而没有给它赋值是不能使用的。如下代码:

public static void main(String args[]){
int a;
System.out.println(a);//报错
}

操作数栈

同变量表一样,操作数栈的最大深度也在编译的时候写入Code属性的max_stacks数据项中。当一个方法刚开始执行时,这个方法的操作数栈是空的。在方法执行过程中,会有各种字节码指令往操作数栈中写入或读取数据。Java虚拟机的解释执行引擎是基于栈的执行引擎,这个栈就是操作栈。

动态连接

每个栈帧都包含一个指向运行时常量池中
该栈帧所属方法
的引用,持有这个引用是为了支持方法调用过程中的动态连接。方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化成直接引用,这种转化叫静态解析;另外一部分将在每一次运行期间转化为直接引用,这叫动态连接。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给调用者,使用哪种返回指令取决于方法返回值的数据类型(如果有返回值的话),使得调用者的代码能在被调用的方法返回并且返回值被推入调用者栈帧的操作数栈后继续正常地执行。另一种是某些指令导致了 Java 虚拟机抛出异常,并且虚拟机抛出的异常在该方法中没有办法处理,或者在执行过程中遇到了 athrow字节码指令显式地抛出异常,并且在该方法内部没有把异常捕获住。如果方法异常调用完成,那一定不会有方法返回值返回给它的调用者。

方法调用

方法调用不等同于方法执行,其唯一任务就是确定被调用的是哪个方法(调用方法的版本)。Class文件在编译过程中不包含传统编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法的实际运行时内存布局的入口地址(直接引用)。这一特性给Java带来了强大的动态扩展能力,但也使得Java方法调用过程变得复杂,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

Java虚拟机提供了5条方法调用字节码指令:

invokestatic:调用静态方法

invokespecial:调用实例构造器<init>方法、私有方法和父类方法

invokevirual:调用所有的虚方法

invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象

invokedynamic:先在运行时动态解析出
调用点限定符
所引用的方法,然后再执行该方法。前面的4条指令分派逻辑是固化在Java虚拟机内部的,而这个指令的分配逻辑由用户所设定的引导方法决定。

解析

在类加载解析阶段,有一部分符号引用会转化成直接引用,这种解析的前提是:方法在程序真正运行之前就有一个确定的调用版本,并且在运行期间不会改变,也就是在编译期就确定了方法调用的是哪个版本,这类方法调用称为
解析
。在Java中,符合这要求的方法是通过
invokestatic
invokespecial
指令调用的,这种方法称为非虚方法。其它方法,除final外,都是虚方法,final方法虽然是使用invokevirtual指令来调用,但是它无法被覆盖,所以也无需对方法接收者进行多态选择,所以final也是非虚方法。

分派

静态类型实际类型
所谓静态类型就是变量的声明类型,而实际类型则是变量的运行时类型,看下面的代码

public class Test3{
static class Human{}
static class Man extends Human{}
static class Woman extends Human{}
public void sayHello(Human human){
System.out.println("human sayHello");
}
public void sayHello(Man man){
System.out.println("man sayHello");
}
public void sayHello(Woman woman){
System.out.println("woman sayHello");
}
public static void main(String []args){
Test3 test=new Test3();
Human man=new Man();
Human woman=new Woman();
test.sayHello(man);
test.sayHello(woman);
}
}
/*
output:
human sayHello
human sayHello
*/

对于代码声明

Human man=new Man()

Human是变量man的
静态类型
,Man是变量man的
实际类型
。静态类型和实际类型在程序运行时都可能发生变化,区别在于静态类型只有在使用时才会发生变化,变量本身的静态类型不会改变,而且静态类型是编译期可知的。而实际类型变化的结果只有在运行期才能确定。如下所示

//实际类型变化
Human man=new Man();//man的实际类型为Man
man=new Woman();//执行完后man的实际类型为Woman
//静态类型变化
test.sayHello((Man)man);//执行完后man的静态类型仍然是Human

静态分派
在上面Test3类的main方法中,在方法接收者test确定的情况下,使用sayHello()方法调用的是哪个版本就完全取决于传入的参数数量和数据类型。代码中刻意传入了两个静态类型相同而动态类型不同的变量,而从结果可以看出,重载时是通过参数的静态类型而不是实际类型作为判断依据的
所有依赖静态类型来定位方法执行版本的分派动作称之为静态分派,其典型应用是
方法重载
。静态分派发生在编译阶段。

动态分派
动态分配其典型应用就是
方法重写
,看下面的代码:

public class Test4{
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
protected void sayHello(){
System.out.println("man sayHello");
}
}
static class Woman extends Human{
protected void sayHello(){
System.out.println("woman sayHello");
}
}
public static void main(String []args){
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
}
}
/*
output:
man sayHello
woman sayHello
woman sayHello
*/

结果显然不出所料,但是我们使用javap -c Test4来看看生成的字节码

public static void main(java.lang.String[]);
Code:
0: new           #2                  // class Test4$Man
3: dup
4: invokespecial #3                  // Method Test4$Man."<init>":()V
7: astore_1
8: new           #4                  // class Test4$Woman
11: dup
12: invokespecial #5                  // Method Test4$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6                  // Method Test4$Human.sayHello:()V
20: aload_2
21: invokevirtual #6                  // Method Test4$Human.sayHello:()V
24: new           #4                  // class Test4$Woman
27: dup
28: invokespecial #5                  // Method Test4$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6                  // Method Test4$Human.sayHello:()V
36: return
}

0~15行的字节码是准备动作,作用是建立man和woman的内存空间,调用man和woman的实例构造器,并将实例分别存放在第1,2个局部变量表(astore_1,astore_2)。接下来的第16~21行是关键部分,aload_1,aload_2是将刚刚创建的两个对象引用压入操作数栈顶,它们分别是两次调用sayHello的接收者。第17,21是两次invokevirtual调用指令,其指令参数完全相同,但是这两条指令最终执行的目标方法并不相同,其原因就在于invokevirtual指令的多态查找过程。其运行时解析过程大致分为以下几个步骤:

找到操作数中的栈顶元素,这里记作C

如果在类型C中找到与常量中的描述符和简单名称都相同的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,否则,返回java.lang.IllegalAccessError异常

如果上一步没有找到,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。

如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
我们把这种运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

基于栈的字节码解释执行引擎

基于栈和寄存器的指令集

Java编译器输出的指令流基本上是一种基于栈的指令集架构;而我们主流PC机中直接支持的指令集架构是基于寄存器的,那计算
1+1
来说,基于栈的指令集命令为:

iconst_1    //入栈
iconst_1    //入栈
iadd        //相加结果入栈
istore_0    //保存到局部变量表中

而基于寄存器的指令集为:

mov eax,1   //令eax寄存器的值为1
add eax,1   //让exa的值加1

基于栈的指令集有点在于可移植,而寄存器由硬件直接支持,程序直接依赖这些硬件寄存器则不可避免的受硬件约束。而基于栈的缺点在于执行速度慢,完成相同的功能所需的指令多,更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的访问内存,相对于处理器来说,内存始终是执行速度的瓶颈。所有主流物理机的指令集都是基于寄存器架构也从侧面印证了这一点。

基于栈的解释器执行过程

看如下代码

public int calculate(){
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}

使用javap -verbose生成字节码如下

public int calculate();
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush        100
2: istore_1
3: sipush        200
6: istore_2
7: sipush        300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
LineNumberTable:
line 24: 0
line 25: 3
line 26: 7
line 27: 11
}

据字节码可以看出,这段代码需要深度为2的操作数栈(stack=2)和4个Slot的局部变量空间(locals=4)。下面,使用7张图片来描述上面的字节码代码执行过程中的代码、操作数栈和局部变量表的变化情况。



上图展示了执行偏移地址为0的指令的情况,bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,后跟一个参数,指明推送的常量值,这里是100。



上图则是执行偏移地址为1的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量Slot中。后面四条指令(3、6、7、10)都是做同样的事情,也就是在对应代码中把变量a、b、c赋值为100、200、300。后面四条指令的图就不重复画了。



上面展示了执行偏移地址为11的指令,iload_1指令的作用是将局部变量第1个Slot中的整型值复制到操作数栈顶。



上图为执行偏移地址12的指令,iload_2指令的执行过程与iload_1类似,把第2个Slot的整型值入栈。



上图展示了执行偏移地址为13的指令情况,iadd指令的作用是将操作数栈中前两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200出栈,它们相加后的和300重新入栈。



上图为执行偏移地址为14的指令的情况,iload_3指令把存放在第3个局部变量Slot中的300入栈到操作数栈中。这时操作数栈为两个整数300,。
下一条偏移地址为15的指令imul是将操作数栈中前两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,这里和iadd指令执行过程完全类似,所以就不重复画图了。



上图是最后一条指令也就是偏移地址为16的指令的执行过程,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给此方法的调用者。到此为止,该方法执行结束。

注:上面的执行过程只是一种概念模型,虚拟机最终会对执行过程做出一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述。不过从这段程序的执行过程也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都是以操作数栈的出栈和入栈为信息交换途径。

图片来源
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: