java虚拟机执行模型都是差不多的,输入字节码二进制流,处理过程是解析这些字节码指令,输出执行结果
虚拟机栈与栈帧
虚拟机栈的结构
java虚拟机的最基本执行单元是方法,虚拟机栈中的栈帧是虚拟机进行方法调用和执行的最基本数据结构。
栈帧中有局部变量表、操作数栈、动态连接、方法返回地址等信息。
一个线程中,同一时刻,位于栈顶的方法才是运行的,即当前栈帧,与其关联的方法叫当前方法。
局部变量表
局部变量表用于存储方法参数和方法内部定义的局部变量,编译为class时,方法的code属性的max_locals数据项就已经确定了局部变量表要分配的最大容量。
局部变量表容量单位是变量槽slot,每个变量槽能存放一个32位的基本类型,boolean、byte、char、short、int、float、reference、returnAddress。而64位的数据long、double要分割成2个32位存。
当方法为实例方法,局部变量表第0位索引默认是用于传递方法所属对象实例的引用(因此java可以用this访问自己),其余参数则按照参数表顺序排列,占用从1开始的变量槽。
局部变量表中的变量槽可以重用,但这一点可能会影响垃圾收集。
操作数栈
是一个后入先出栈。编译时指定其最大深度,写入Code属性的max_stacks数据项。操作数栈每个元素都是可以存放一个包括long、double的任意java数据类型。方法开始执行时,操作数栈是空的,执行过程中会有字节码指令进行操作数栈的写入和提前操作。例如iadd字节码要求栈顶两个元素都是int。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
符合引用在类加载或第一次使用被转化为直接引用的这一部分是静态连接,每次运行期间都转化为直接引用的是动态连接。
方法返回地址
方法执行,要么成功遇到方法返回的字节码指令,向上层方法调用者传递返回值或不传递返回值,即正常调用完成;要么遇到athrow字节码指令产生异常,且本方法的异常表没用搜索到匹配的异常处理器,会导致异常调用完成,且一定不提供返回值。
无论上上面哪种退出,都必须返回最初被调用的位置继续执行,因此方法返回时可能需要在栈帧中保存一些信息,用于恢复上层方法的调用状态,即方法返回地址。
方法调用
方法调用≠执行代码,这个阶段的任务是要确定被调用的方法的版本(即调用哪个方法)。到此为止,还没涉及到方法内部的具体运行过程。
解析
所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,还记得在类加载的解析阶段,会将一部分符号引用转化为直接引用
而方法调用能在类加载的解析阶段被转化的前提是:方法在程序真正运行之前就能确定其可调用版本,并且运行期间也不可改变。符合上述要求的有两大类;静态方法、私有方法。因为前者和类直接挂钩,后者则是在外部不能访问。
java虚拟机有5种方法调用的字节码指令:
invokestatic:用于调用静态方法
invokespecial:用于调用实例构造器
<init>()
方法、私有方法和父类中的方法invokevirtual:用于调用所有的虚方法
invokeinterface:用于调用接口方法
invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后执行该方法。
能被invokestatic、invokespecial调用的方法都可以在解析阶段唯一确定调用版本,符合这个条件的就是静态方法、私有方法、实例构造器、父类方法四种,再加一个被final修饰的方法(特殊,这个由invokevirtual调用)。这五类被称为非虚方法,而其他的方法就是虚方法。
举个例子:
public class MyTest {
public static void sayHello() {
System.out.println("Hello World");
}
public static void main(String[] args) throws ExecutionException, SQLException, ClassNotFoundException, IOException {
sayHello();
}
}
sayHello方法是静态方法,不可能被覆盖或者隐藏。同理把static换成final,也是一样效果
因此查看该类的字节码,先使用javac
编译,再使用javap -verbose
反编译,可以看到:
public static void main(java.lang.String[]) throws java.util.concurrent.ExecutionException, java.sql.SQLException, java.lang.ClassNotFoundException, java.io.IOException;
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 14: 0
line 15: 3
……
确实是使用invokestatic直接调用常量池中的#5,看下常量池:
Constant pool:
……
#5 = Methodref #6.#28 // com/huawei/cbc/udrmetricservice/MyTest.sayHello:()V
#5确实是sayHello实际方法,说明解析阶段就已经用直接引用替换了符号引用
静态分派与重载
非虚方法是静态分派的
首先观察下面这段代码
public class MyTest {
static abstract class Human {}
static class Man extends Human {}
static class Woman extends Human {}
public void sayHello(Human guy) {
System.out.println("hellow guy");
}
public void sayHello(Man guy) {
System.out.println("hellow man");
}
public void sayHello(Woman guy) {
System.out.println("hellow woman");
}
public static void main(String[] args) {
Man man = new Man();
Human woman = new Woman();
MyTest myTest = new MyTest();
myTest.sayHello(man);
myTest.sayHello(woman);
myTest.sayHello((Woman)woman);
}
}
// hellow man
// hellow guy
// hellow woman
Java虚拟机分别选择了入参是Man的和入参是Human的两个方法重载。
对于一个对象创建语句Human woman = new Woman()
来说,Human是静态类型或外观类型,Woman叫实际类型或运行时类型。静态类型和实际类型在程序中都可能发生变化,区别是静态类型的变化仅仅在使用时发生(例如显式类型转换),变量本身的静态类型不会被改变,且是编译期可知的。
简言之,写代码时使用的是静态类型,而实际运行时是分配内存后执行实际指令创建的静态类型
上面这个例子,这种重载的写法,就是在编译期基于静态类型分派调用哪个方法的,即Human woman = new Woman()
中,woman的静态类型是Human,编译器为Human类选择有对应入参的方法。一般来说,静态分派往往是选择一个更合适的版本。
总结下来:方法多态是编译期基于静态类型选择方法版本的
如果没有对应类型的重载方法,则会降级选择次合适的,例如以下案例:
public class MyTest {
public static void sayHello(char s) {
System.out.println("Hello char");
}
public static void sayHello(int s) {
System.out.println("Hello int");
}
public static void sayHello(long s) {
System.out.println("Hello long");
}
public static void main(String[] args) throws ExecutionException, SQLException, ClassNotFoundException, IOException {
sayHello('s');
}
}
代码输出Hello char,如果注释掉sayHello(char s)
,则会输出Hello int,再注释sayHello(int s)
,则输出Hello long因为基本数据类型是互通的,参考如下链接:
重载的匹配顺序是char > int > long > float > double > Character > Serializable > Object,但不会匹配byte或short,因为JVM认为char转byte或short不安全
动态分派与@Override
与重载不同,@Override则是基于运行时类型决定方法的版本,例如:
public class MyTest {
static abstract class Human {
public void sayHello() {
System.out.println("hellow guy");
}
}
static class Man extends Human {
@Override
public void sayHello() {
System.out.println("hellow man");
}
}
static class Woman extends Human {
@Override
public void sayHello() {
System.out.println("hellow woman");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
// hellow man
// hellow woman
// hellow woman
}
因为观察main方法的字节码,可以看到执行sayHello之前都做了aload操作,即取实际的实例引用放在局部变量槽中,压到栈顶,
16 aload_1
17 invokevirtual #13 <com/huawei/cbc/udrmetricservice/MyTest$Human.sayHello : ()V>
20 aload_2
21 invokevirtual #13 <com/huawei/cbc/udrmetricservice/MyTest$Human.sayHello : ()V>
但是invokevirtual调用常量池#13常量,都是com/huawei/cbc/udrmetricservice/MyTest$Human.sayHello
,但是实际执行目标却不一样,这是由于invokevirtual的执行逻辑决定的:
找到操作数栈顶第一个元素所指向的对象的实际类型,记作C
如果C中有与常量中描述符和简单名称都相符的方法,则校验访问权限,如果通过则返回方法的直接引用,否则返回IllegalAccessError异常
如果第二步没找到,则按照继承关系去父类依次重复第二步操作
如果始终没找到,抛出AbstractMethodError异常
多分派与单分派
父类和子类中既有重载方法,又有覆写方法的场景
例如父类和子类都有同名方法,且父类和子类中的同名方法都有不同的入参版本(实际有四个同名方法)。调用子类的方法1,其实相当于入参宗量已经确定,只看方法接收者为子类,这就是单分派。
java是静态多分派,动态单分派语言
java对动态语言的支持
什么是动态语言
动态类型语言的关键特征是它的类型检查主体过程是在运行时期而非编译器。例如python和java:
# python
i = 10
list = [1, 2, “num”, 0.5]
for i in list:
print(i)
// java
int i = 10
python代码可以不指定i类型,列表中甚至可以放不同类型元素,遍历list,也可以对每个元素执行同一个方法(只要它继承了该方法或都可以作为该方法的参数),因为python的类型检查在运行期间。java就不可以。
产生这种差别的根本原因是java在编译期间就已经把方法的完整符号引用生成好了,并且作为方法调用指令的参数存储到Class文件中。例如前面的案例:
17 invokevirtual #13 <com/huawei/cbc/udrmetricservice/MyTest$Human.sayHello : ()V>
静态语言和动态语言各有优势。静态语言更加严谨,稳定性更强,大型项目使用静态语言更稳定,但代码就更复杂。动态语言更灵活,代码更简洁。
java的动态语言支持 - java.lang.invoke包
早期在jdk7以前,invoke字节码有四种:invokevirtual、invokespecial、invokestatic、invokeinterface,其中第一个参数都调用方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),方法的符号在编译时就会产生了,这样就没法支持动态语言
在jdk8以后,invoke包引入了方法句柄(Method Handle)处理动态确定目标方法。句柄类似于函数指针的概念。看以下案例:
public class MyTest {
static class ClassA {
public void println(String s) {
System.out.println(s);
}
}
private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
// MethodType代表方法类型,通过methodType()方法创建,其中包含了方法的返回值(一参)和入参(二参)
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()方法用于在指定类中查找符合给定方法名称、方法类型,并且符合调用权限的方法句柄
// findVirtual()方法是走的invokevirtual指令的执行过程,第一个参数是隐式的,代表该方法的接收者,也即this指向的对象,这个参数以前是放在参数
// 列表中传递的,但是现在显式提供了bindTo()方法去指定
return MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}
public static void main(String[] args) throws Throwable {
Object object = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
getPrintlnMH(object).invokeExact("icyfenix");
}
}
首先定义了一个ClassA,定义了与System.out
中重名的方法println。System.out是标准输出流,可以参考流的部分:
然后看main方法,首先定义了一个Object,它根据当前时间是奇数秒还是偶数秒,决定自己实际是标准输出流还是ClassA类型
然后想要调用Object#println
方法,正常写代码的时候,Object类型是没有println方法的,也没法这样写,因为是静态分派,要先确定方法的执行对象。这里通过一个getPrintlnMH方法实现了动态化,看它的实现:
首先基于
MethodType#methodType
方法构造了一个符合条件“返回void,入参为String”的方法类型然后基于
MethodHandles.lookup().findVirtual()
方法在指定的obect中找名称为“println”,条件符合methodType的对应方法通过invokevirtual调用这个方法,并且显式地通过
bindTo()
方法挂接原本放在参数中传递的方法接收者this对象
方法句柄与反射有点类似,但二者也有区别:
Reflection是在代码层次模拟方法调用,而MethodHandle是在字节码层次模拟方法调用,在
MethodHandles.lookup()
中提供了findStatic、findVirtual、findSpecial正是对应了对invokestatic、invokevirtual(以及invokeinterface)、invokespecial这几个字节码的模拟Reflection模拟代码层次,是对方法前面、描述符、属性表中各种属性、权限等信息的全面描述,而MethodHandle进士对执行该方法相关信息的描述。换句话说,Reflection更重量级,MethodHandle更轻量级
MethodHandle是对字节码的模拟,因此可以支持虚拟机在这方面做的各种优化
另外,JDK7以后还引入了invokedynamic指令,是第五条调用方法指令,用以支持动态类型语言
基于栈的指令集与基于寄存器的指令集
以计算1+1为例。
# 基于栈
iconst_1
iconst_1
iadd
istore_0
解释:连续把两个常量1压入栈,iadd执行栈顶出栈相加,然后结果放回栈顶。istore_0取出栈顶的值放入局部变量表第0个变量槽。
# 基于寄存器
mov eax, 1
add eax, 1
解释:mov指令把EAX寄存器值设置为1,然后add再把这个值加1,结果存在EAX寄存器
基于寄存器是x86指令集中的主流指令,每个指令都有两个单独的输入参数,依赖寄存器存取数据。
评论区