运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
栈帧的概念结构
局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
-
存放方法参数和方法内部定义的局部变量;
- Java 程序编译为 class 文件时,就确定了每个方法需要分配的局部变量表的最大容量。
-
最小单位:变量槽——Slot;
-
一个 Slot 中可以存放:boolean,byte,char,short,int,float,reference,returnAddress (少见);
- reference类型表示对一个对象实例的引用
-
虚拟机可通过局部变量表中的 reference 做到:
- 查找 Java 堆中的实例对象的起始地址;
- 查找方法区中的 Class 对象。
-
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间
- 把long和double数据类型分割存储的做法与“long和double的非原子性协定”中把一次long和double数据类型读写分割为两次32位读写的做法有些类似。不过,由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
- 如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot
-
如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot
- 对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,在类加载的校验阶段应抛出异常。
-
一个 Slot 中可以存放:boolean,byte,char,short,int,float,reference,returnAddress (少见);
局部变量表的空间分配
局部变量表中的Slot是可以重用的
定义: 如果当前位置已经超过某个变量的作用域时,例如出了定义这个变量的代码块,这个变量对应的 Slot 就可以给其他变量使用了。但同时也说明,只要其他变量没有使用这部分 Slot 区域,这个变量就还保存在那里,这会对 GC 操作产生影响。
1.
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
-verbose:gc
输出(在虚拟机运行参数中加上-verbose:gc
来看看垃圾手机的过程):
[GC (System.gc()) 68813K->66304K(123904K), 0.0034797 secs]
[Full GC (System.gc()) 66304K->66204K(123904K), 0.0086225 secs] // 没有被回收
原因: 在执行System.gc()时,变量placeholder还处于作用域内,故而不会被虚拟机回收内存
2.
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
-verbose:gc
输出:
[GC (System.gc()) 68813K->66304K(123904K), 0.0034797 secs]
[Full GC (System.gc()) 66304K->66204K(123904K), 0.0086225 secs] // 没有被回收
3.
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 1; // 新加一个赋值操作
System.gc();
}
-verbose:gc
输出:
[GC (System.gc()) 68813K->66320K(123904K), 0.0017394 secs]
[Full GC (System.gc()) 66320K->668K(123904K), 0.0084337 secs] // 被回收了
第二次修改后,placeholder 能被回收的原因?
- placeholder 能否被回收的关键:局部变量表中的 Slot 是否还存在关于 placeholder 的引用;
- 出了 placeholder 所在的代码块后,还没有进行其他操作,所以 placeholder 所在的 Slot 还没有被其他变量复用,也就是说,局部变量表的 Slot 中依然存在着 placeholder 的引用;
- 第三次修改后,int a 占用了原来 placeholder 所在的 Slot,所以可以被 GC 掉了。
操作数栈
- 元素可以是任意 Java 类型,32 位数据占 1 个栈容量,64 位数据占 2 个栈容量;
- Java 虚拟机的解释执行称为:基于栈的执行引擎,其中 “栈” 指的就是操作数栈;
动态连接
- 指向运行时常量池中该栈帧所属方法的引用;
- 为了支持方法调用过程中的动态连接
方法返回地址
-
两种退出方法的方式:
- 遇到 return;
- 遇到异常。
-
退出方法时可能执行的操作:
- 恢复上层方法的局部变量表和操作数栈;
- 把返回值压入调用者栈帧的操作数栈;
- 调整 PC 计数器指向方法调用后面的指令。
方法调用
Java 虚拟机提供了 5 个职责不同的方法调用字节码指令:
invokestatic
:调用静态方法;invokespecial
:调用构造器方法、私有方法、父类方法;invokevirtual
:调用所有虚方法,除了静态方法、构造器方法、私有方法、父类方法、final 方法的其他方法叫虚方法;invokeinterface
:调用接口方法,会在运行时确定一个该接口的实现对象;invokedynamic
:在运行时动态解析出调用点限定符引用的方法,再执行该方法。
除了invokedynamic
,其他 4 种方法的第一个参数都是被调用的方法的符号引用,是在编译时确定的,所以它们缺乏动态类型语言支持,因为动态类型语言只有在运行期才能确定接收者的类型,即变量的类型检查的主体过程在运行期,而非编译期。
final 方法虽然是通过 invokevirtual 调用的,但是其无法被覆盖,没有其他版本,无需对接收者进行多态选择,或者说多态选择的结果是唯一的,所以属于非虚方法。
解析
解析调用,正如其名,就是 在类加载的解析阶段,就确定了方法的调用版本 。我们知道类加载的解析阶段会将一部分符号引用转化为直接引用,这一过程就叫做解析调用。因为是在程序真正运行前就确定了要调用哪一个方法,所以 解析调用能成立的前提就是:方法在程序真正运行前就有一个明确的调用版本了,并且这个调用版本不会在运行期发生改变。
符合这两个要求的只有以下两类方法:
- 通过 invokestatic 调用的方法:静态方法;
- 通过 invokespecial 调用的方法:私有方法、构造器方法、父类方法;
这两类方法根本不可能通过继承或者别的方式重写出来其他版本,也就是说,在运行前就可以确定调用版本了,十分适合在类加载阶段就解析好。它们会在类加载的解析阶被解析为直接引用,即确定调用版本。
分派
在介绍分派调用前,我们先来介绍一下 Java 所具备的面向对象的 3 个基本特征:封装,继承,多态。
其中多态最基本的体现就是重载和重写了,重载和重写的一个重要特征就是方法名相同,其他各种不同:
- 重载:发生在同一个类中,入参必须不同,返回类型、访问修饰符、抛出的异常都可以不同;
- 重写:发生在子父类中,入参和返回类型必须相同,访问修饰符大于等于被重写的方法,不能抛出新的异常。
相同的方法名实际上给虚拟机的调用带来了困惑,因为虚拟机需要判断,它到底应该调用哪个方法,而这个过程会在分派调用中体现出来。其中:
- 方法重载 —— 静态分派
- 方法重写 —— 动态分派
静态分派(方法重载)
在介绍静态分派前,我们先来介绍一下什么是变量的静态类型和实际类型。
变量的静态类型和实际类型
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 man) { System.out.println("Hello man!"); } public void sayHello(Woman woman) { System.out.println("Hello woman!"); } 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 来判断调用哪个方法, 所以调用的都是 public void sayHello(Human guy) */ }
}
/* 简单讲解 */
// 使用
Human man = new Man();// 实际类型发生变化
Human man = new Man();
man = new Woman();
// 静态类型发生变化
sr.sayHello((Man) man); // 输出:Hello man!
sr.sayHello((Woman) man); // 输出:Hello woman!
其中 Human 称为变量的静态类型,Man 称为变量的实际类型。
在重载时,编译器是通过方法参数的静态类型,而不是实际类型,来判断应该调用哪个方法的。
通俗的讲,静态分派就是通过方法的参数(类型 & 个数 & 顺序)这种静态的东西来判断到底调用哪个方法的过程。
- 重载方法匹配优先级,例如一个字符 'a' 作为入参
-
基本类型
- char
- int
- long
- float
- double
- Character
-
Serializable(Character 实现的接口)
- 同时出现两个优先级相同的接口,如 Serializable 和 Comparable,会提示类型模糊,拒绝编译。
- Object
- char...(变长参数优先级最低)
动态分派(方法重写)
动态分派就是在运行时,根据实际类型确定方法执行版本的分派过程。
动态分派的过程
public class DynamicDispatch { static abstract class Human { protected abstract void sayHello(); }
static class Man extends Human { protected void sayHello() { System.out.println("Hello man"); } } static class Woman extends Human { protected void sayHello() { System.out.println("Hello woman"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = woman; man.sayHello(); /* 输出 Hello man Hello woman Hello woman */ }
}
字节码分析(javap命令输出字节码):
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class com/jvm/ch8/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method com/jvm/ch8/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class com/jvm/ch8/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method com/jvm/ch8/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1 // 把刚创建的对象的引用压到操作数栈顶,
// 供之后执行sayHello时确定是执行哪个对象的sayHello
17: invokevirtual #6 // 方法调用
20: aload_2 // 把刚创建的对象的引用压到操作数栈顶,
// 供之后执行sayHello时确定是执行哪个对象的sayHello
21: invokevirtual #6 // 方法调用
24: aload_2
25: astore_1
26: aload_1
27: invokevirtual #6 // Method com/jvm/ch8/DynamicDispatch$Human.sayHello:()V
30: return
通过字节码分析可以看出,invokevirtual 指令的运行过程大致为:
- 去操作数栈顶取出将要执行的方法的所有者,记作 C;
-
查找此方法:
- 在 C 中查找此方法;
- 在 C 的各个父类中查找;
-
查找过程:
- 查找与常量的描述符和简单名称都相同的方法;
- 进行访问权限验证,不通过抛出:IllegalAccessError 异常;
- 通过访问权限验证则返回直接引用;
- 没找到则抛出:AbstractMethodError 异常,即该方法没被实现。
动态分派的实现
动态分派在虚拟机种执行的非常频繁,而且方法查找的过程要在类的方法元数据中搜索合适的目标,从性能上考虑,不太可能进行如此频繁的搜索,需要进行性能上的优化。
常用优化手段: 在类的方法区中建立一个虚方法表。
- 虚方法表中存放着各个方法的实际入口地址,如果某个方法没有被子类方法重写,那子类方法表中该方法的入口地址 = 父类方法表中该方法的入口地址;
- 使用这个方法表索引代替在元数据中查找;
- 该方法表会在类加载的连接阶段初始化好。
通俗的讲,动态分派就是通过方法的接收者这种动态的东西来判断到底调用哪个方法的过程。
总结一下:静态分派看左面,动态分派看右面。
单分派与多分派
除了静态分派和动态分派这种分派分类方式,还有一种根据宗量分类的方式,可以将方法分派分为单分派和多分派。
宗量:方法的接收者 & 方法的参数。
Java 语言的静态分派属于多分派,根据 方法接收者的静态类型 和 方法参数类型 两个宗量进行选择。
Java 语言的动态分派属于单分派,只根据 方法接收者的实际类型 一个宗量进行选择。
动态类型语言支持
什么是动态类型语言?
就是类型检查的主体过程在运行期,而非编译期的编程语言。
动/静态类型语言各自的优点?
- 动态类型语言:灵活性高,开发效率高。
- 静态类型语言:编译器提供了严谨的类型检查,类型相关的问题能在编码的时候就发现。
Java虚拟机层面提供的动态类型支持:
- invokedynamic 指令
- java.lang.invoke 包
java.lang.invoke 包
目的: 在之前的依靠符号引用确定调用的目标方法的方式之外,提供了 MethodHandle 这种动态确定目标方法的调用机制。
MethodHandle 的使用
获得方法的参数描述,第一个参数是方法返回值的类型,之后的参数是方法的入参:
MethodType mt = MethodType.methodType(void.class, String.class);
获取一个普通方法的调用:
/**
* 需要的参数:
* 1. 被调用方法所属类的类对象
* 2. 方法名
* 3. MethodType 对象 mt
* 4. 调用该方法的对象
*/
MethodHandle.lookup().findVirtual(receiver.getClass(), "方法名", mt).bindTo(receiver);
获取一个父类方法的调用:
/**
* 需要的参数:
* 1. 被调用方法所属类的类对象
* 2. 方法名
* 3. MethodType 对象 mt
* 4. 调用这个方法的类的类对象
*/
MethodHandle.lookup().findSpecial(GrandFather.class, "方法名", mt, getClass());
通过 MethodHandle mh 执行方法:
/*
invoke() 和 invokeExact() 的区别:
- invokeExact() 要求更严格,要求严格的类型匹配,方法的返回值类型也在考虑范围之内
- invoke() 允许更加松散的调用方式
*/
mh.invoke("Hello world");
mh.invokeExact("Hello world");
使用示例:
public class MethodHandleTest { static class ClassA { public void println(String s) { System.out.println(s); } }
public static void main(String[] args) throws Throwable { /* obj的静态类型是Object,是没有println方法的,所以尽管obj的实际类型都包含println方法, 它还是不能调用println方法 */ Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA(); /* invoke()和invokeExact()的区别: - invokeExact()要求更严格,要求严格的类型匹配,方法的返回值类型也在考虑范围之内 - invoke()允许更加松散的调用方式 */ getPrintlnMH(obj).invoke("Hello world"); getPrintlnMH(obj).invokeExact("Hello world"); } private static MethodHandle getPrintlnMH(Object receiver) throws NoSuchMethodException, IllegalAccessException { /* MethodType代表方法类型,第一个参数是方法返回值的类型,之后的参数是方法的入参 */ MethodType mt = MethodType.methodType(void.class, String.class); /* lookup()方法来自于MethodHandles.lookup, 这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄 */ /* 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者, 也即是this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情 */ return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver); }
}
MethodHandles.lookup 中 3 个方法对应的字节码指令:
- findStatic():对应 invokestatic
- findVirtual():对应 invokevirtual & invokeinterface
- findSpecial():对应 invokespecial
MethodHandle 和 Reflection 的区别
-
本质区别: 它们都在模拟方法调用,但是
- Reflection 模拟的是 Java 代码层次的调用;
- MethodHandle 模拟的是字节码层次的调用。
-
包含信息的区别:
- Reflection 的 Method 对象包含的信息多,包括:方法签名、方法描述符、方法的各种属性的Java端表达方式、方法执行权限等;
- MethodHandle 对象包含的信息比较少,既包含与执行该方法相关的信息。
invokedynamic 指令
Lambda 表达式就是通过 invokedynamic 指令实现的。