java虚拟机的执行引擎都是一致的:输入字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
运行时栈帧结构
栈帧(Stack Frame)是用于虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从调研开始至执行的过程,都对应栈帧从虚拟机里面入栈到出栈的过程。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,,不受程序运行期变量数据的影响,而只取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,活动的线程中,只有位于栈顶的栈帧才是有效的,称为当前的栈帧。
局部变量表
局部变量表是一组变量存储空间,用户存放方法参数和方法内部定义的局部变量。在java程序编译为Class文件时,就在方法Code属性区max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
一个 Slot 可以存放一个 32 位以内的数据类型,Java 中占用 32 位以内的数据类型有 boolean、byte、char、short、int、float、reference 和 returnAddress 8 种类型。前面 6 种不需要多加解释,读者可以按照 Java 语言中对应数据类型的概念去理解它们(仅是这样理解而已,Java 语言与 Java 虚拟机中的基本数据类型是存在本质差别的),而第 7 种 reference 类型表示对一个对象实例的引用,虚拟机规范既没有说明他的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在 Java 堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现 Java 语言规范中定义的语法约束约束。第 8 种即 returnAddress 类型目前已经很少见了,它是为字节码指令 jsr、jsr_w 和 ret 服务的,指向了一条字节码指令的地址,很古老的 Java 虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。
对于 64 位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间。Java 语言中明确的(reference 类型则可能是 32 位也可能是 64 位)64 位的数据类型只有 long 和 double 两种。
在方法执行中,虚拟机是使用局部变量表来完成参数值到参数列表的传递过程的。果执行的是实例方法(非 static 的方法),那局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 “this” 来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。
局部变量表中的slot是可以重用的,方法中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前PC计数器的值已经超出了某个量的作用域,那么这个变量的slot可以交给其他slot使用,这样可以节省栈的空间。但是有的时候会对垃圾收集器GC有一定的影响。
public class SlotTest {
public static void main(String[] args) {
byte[] bytes = new byte[64 * 1024 * 1024];
System.gc();
}
}
jvm options:-verbose:gc
,可以看到有64M的内存没有回收
[GC (System.gc()) 68075K->65984K(241664K), 0.0028395 secs]
[Full GC (System.gc()) 65984K->65945K(241664K), 0.0062437 secs]
public class SlotTest1 {
public static void main(String[] args) {
{
byte[] placeHolder = new byte[64 * 1024 * 1024];
}
System.gc();
}
}
jvm options:-verbose:gc
,可以placeHolder不再{}作用域外,看到有64M的内存没有回收。
[GC (System.gc()) 69345K->66008K(241664K), 0.0029754 secs]
[Full GC (System.gc()) 66008K->65945K(241664K), 0.0055845 secs]
public class SlotTest1 {
public static void main(String[] args) {
{
byte[] placeHolder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
}
jvm options:-verbose:gc
,可以看到有64M的内存被回收了
[GC (System.gc()) 68075K->65992K(241664K), 0.0044556 secs]
[Full GC (System.gc()) 65992K->409K(241664K), 0.0056501 secs]
在上面三段代码中,placeholder能否被回收的根本原因就是:局部变量表中的slot是否还存有placeholder数组对象的引用,第一次修改后,代码虽然离开了placeholder的作用域,但是在此之后,没有任何对局部变量的读写操作,placeholder原本占用的slot还没有被其它变量服用,所以gc Roots依然能找到该变量的指向堆内存对象的引用链,不会进行回收。在第二次修改后,由于int a=0 会将placeholder的slot的复用,所以该数组数据会被回收。我们可以使用手动设置该变量为null值来达到同样的效果,但是也不能对手动设置null这种方法过多的依赖。
一个局部变量没有赋值是不能够使用的:
public class Test1 {
public static void main(String[] args) {
int a;
System.out.println(a);
}
}
操作数栈
操作数栈也称为操作栈,是一个后入先出的栈。栈的深度,同局部变量一样,编译的时候写入Code属性的max_stacks数据项中。
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但大多数虚拟机会会做一些优化处理,令两个栈帧出现一部分重叠。
java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中栈是指操作数栈。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。class文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化为静态解析。另外一部分将在每一次运行期间直接转化为直接引用,这部分称为动态链接。
方法返回地址
当每一个方法开始执行后,只有两种方式可以退出这个方法:
- 执行引擎遇到任意一个方法返回字节码指令,可能会返回值传递给上层的方法调用者,是否有返回值的类型将根据遇到何种方法返回指令来决定,这种退出方式是正常完成出口
- 在执行过程中遇到异常,这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致退出,这种退出方法称为异常完成出口
无论那种方式退出,在退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要栈帧中保存一些信息,用来邦组恢复它的上层方法执行的状态。正常退出,调用者的PC计数器的值可以作为返回地址,栈帧中很可能保存这个计数器的值,而异常退出时候,返回地址要通过异常处理表来确定,栈帧一般不会保存这部分信息。
附加信息
虚拟机规范中允许具体的虚拟机实现增加一些规范没有描叙信息到栈帧中,例如调试信息相关。
方法调用
一切的方法调用在Class文件里边存储都只是符号引用,而不是方法在实际运行的布局中的入口地址。这就需要在类加载期间,甚至运行期间才能确定目标方法的直接引用。
解析
在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析的前提是:方法在程序真正运行之前就有一个可确定调用版本,并且这个版本在运行期间不可改变。
“编译期间可知,运行期间不可改变”,主要包含静态方法和私有方法,前者和类型相关,后者与外部不可以被访问,这2种他们不可能通过继承或者别的方式重写其他版本,适合在类的加载阶段解析。
与之对应,在java虚拟机里面提供5种方法调用字节码指令:
- invokestatic:调用静态方法
- invokespecial:调用实例构造器
<init>
方法、私有方法和父类方法。 - invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象
- invokedynamic:先运行时动态解析出调用点限定符所引用的方法,然后在执行该方法,分派逻辑需要用户设定的引导方法决定。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个阶段的条件有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载时候就会把符号引用解析为该方法的直接引用。这类方法称为非虚方法,其他方法称为虚方法(除final方法)。
public class StaticResolution {
public static void sayHello() {
System.out.println("hello world!");
}
public static void main(String[] args) {
StaticResolution.sayHello();
}
}
javap字节码:
public class StaticResolution {
public static void sayHello() {
System.out.println("hello world!");
}
public static void main(String[] args) {
StaticResolution.sayHello();
}
}
发现invokestatic命令来调用sayHello方法
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: invokestatic #5 // Method sayHello:()V
3: return
LineNumberTable:方法
line 10: 0
line 11: 3
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 args [Ljava/lang/String;
java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法使用invokevirtual指令调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,或者多态选择中接口肯定是唯一的。所以,final方法也是一种非虚方法。
分派
根据分配宗数量可分为单分派和多分派,这2种分派方式俩俩组合就构成了静态分派、静态多分派、动态单分派、动态多分分 26: invokevirtual #13 // Method sayHello:(Lcom/own/learn/jdk/cls1/classLoading/StaticDispatch$Human;)V
派派。
java具备面向对象的3个基本特征:继承、封装和多态。从分派调用过程将会揭示多态特征,如重写和重载的实现。
静态分派
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human gay) {
System.out.println("Hello, gay");
}
public void sayHello(Man gay) {
System.out.println("Hello, man");
}
public void sayHello(Woman gay) {
System.out.println("Hello, Woman");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch staticDispatch = new StaticDispatch();
staticDispatch.sayHello(man);
staticDispatch.sayHello(woman);
}
}
javap查看main方法字节码:
25: aload_1
26: invokevirtual #13 // Method sayHello:(Lcom/own/learn/jdk/cls1/classLoading/StaticDispatch$Human;)V
29: aload_3
30: aload_2
31: invokevirtual #13 // Method sayHello:(Lcom/own/learn/jdk/cls1/classLoading/StaticDispatch$Human;)V
34: return
Human man = new Man();
代码中Human是变量的静态类型,或者叫外观类型;后面的Man则称为变量的实际类型,静态类型和实际类型都有一些变法,区别是静态类型的变法只是在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变法的结果在运行期间才确定,编译器在编译程序时候并不知道实际的类型是什么。
所以依赖静态类型来定位方法执行的把本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。编译器虽然能够确定方法的版本号,但是很多情况下这个版本号不是唯一的,只能呢个确定一个“更加合适的”版本。
public class OverloadTest {
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(char arg) {
System.out.println("hello char");
}
public static void main(String[] args) {
sayHello('a');
}
}
输出:
hello char
动态分派
看下动态分派一个重要体现——重写。
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();
}
}
查看下main方法的调用字节码:
17: invokevirtual #6 // Method com/own/learn/jdk/cls1/classLoading/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method com/own/learn/jdk/cls1/classLoading/DynamicDispatch$Human.sayHello:()V
24: new #4 // class com/own/learn/jdk/cls1/classLoading/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method com/own/learn/jdk/cls1/classLoading/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method com/own/learn/jdk/cls1/classLoading/DynamicDispatch$Human.sayHello:()V
可以21和33行都是,Human.sayHello,最终执行的目标方法不同。原因需要从invokevirtual指令多态查找所起,invokevirtual指令运行时解析过程大致可以分为几个过程:
- 找到操作数栈顶的第一个元素所指向的实际类型,记C
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,组进行访问权限校验,如果通过返回这个方法的直接引用,查找过程结束。如果不通过,返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下向上一次对C的各个弗雷进行第二步搜索和验证操作
- 如果没有找到合适的方异常法,则抛出java.lang.AbstractMethodError异常。
单分派与多分派
方法的接收者和方法的参数统称为方法的宗量,根据宗量多少来划分单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派根据多于一个宗量对目标方法进行选择。
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());
}
}
我们来看看编译阶段编译器选择过程,就是静态分派的过程。选择目标方法的依据有2点:一是、静态类型是son还是father,二是方法类型是QQ还是360.这个选择产生2条invokevirtual指令,分别指向 Father.hardChoice(_360);和Father.hardChoice(QQ);因为是更具2个宗量进行选择,所以java语言的静态分派属于多分分派类型。运行时候,动态分派。
虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最常用的”稳定优化“手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable),使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都是指向父类的实际入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实际版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中具有一样的索引序号,这样当类型变换时,仅仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需要的入口地址。
方法表一般在类加载阶段的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。