java符号的定位与解析,从本质上来看,就是一个消除歧义的过程,这个过程从编译到运行,横跨了多个阶段,本文主要目的在于划分清楚哪个阶段消除了什么歧义
1.符号的起点-java代码
public class Parent {
public void sayHi(){
}
public static void main(String[] args) {
new Parent().sayHi("hi");
}
}
那么从java语义上看,表达的意思大概就是:
1、有个public的Parent类
2、这个类有个类方法叫sayHi
3、类方法sayHi是个没有参数也没有返回值的public方法
4、main函数调用了sayHi函数
如同你画我猜一般,如何将这些意思精确的传递给jvm直到最终程序的运行呢
2.编译后-字节码
编译的过程就是将某一种语言编写的程序翻译成为一个等价的、用另一种语言编写的程序。那么对于java而言,编译就是将java代码翻译成字节码,编译的细节就不分析了,主要看编译的结果--字节码,以下是截取的部分字节码,分为两块,一块是方法表,一块是常量池
常量池中有一个常量指向sayHi函数:
Constant pool:
#..."省略"
#4 = Methodref #2.#17 // Parent.sayHi:()V
Parent.sayHi:()V就是用来描述sayHi函数的符号引用,描述的很精确:类Parent的,没有入参的,返回void的,名为sayHi的方法
那么main方法里怎么调用这个sayHi()的呢,看方法表:
public static void main(java.lang.String[]);
...
7: invokevirtual #4 // Method sayHi:()V
...
invokevirtual可以暂时理解成调用,#4当然就是上文中常量池中的常量#4,它指向sayHi()方法,如此一个调用函数的过程就被字节码描述清楚了
从这个简单的例子可以得出结论:
字节码中的符号引用仅仅是一个描述性的符号(如:Parent.sayHi:()V),并没有保存最终的内存布局信息,这点与C语言的编译结果不一样
3.编译期间解决的歧义
3.1 重载
假如一个类中,void sayHi(String string)
和 void sayHi(Object object)
两个方法构成重载,当调用 sayHi("hi")时,看看字节码中的指令是什么:
invokevirtual #9 // Method sayHi:(Ljava/lang/String;)V
显然,#9常量指向的是void sayHi(String string)
方法,那么可以得出结论:
编译期间,重载造成的歧义就被解决了,在字节码中,已经选出应该调用的方法
继续看这个例子,调用方法时传入的参数为"hi",既是String类型,也是Object类型,但是在字节码中,直接认定了调用(Ljava/lang/String;)V
,这是为什么呢,以下是《深入理解jvm》书中给出的解释:
Java 编译器选取重载方法的过程共分为三个阶段:
1、在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
2、如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
3、如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
3.2 遮蔽
下面这段代码,在init()函数中定义了一个与类变量同名的局部变量a
public class Parent5 {
public String a = "out";
public void init(){
String a = "in";
System.out.println(a);
System.out.println(this.a);
}
public static void main(String[] args){
new Parent5().init();
}
}
直接看字节码中是如何获取a和this.a对应的值的:
常量池:
Constant pool:
#3 = Fieldref #7.#24 // Parent5.a:Ljava/lang/String;
#4 = String #25 // in
获取a的值
0: ldc #4 // String in
从常量池的#4常量中获取值(指向了字符串"in"
)
获取this.a的值
getfield #3 // Field a:Ljava/lang/String;
从常量池的#3常量中获取值(指向了Parent5.a:Ljava/lang/String
,即类变量a)
显然,字节码层面上已经对调用哪个变量区分的明明白白,那么可以得出结论:
java语言层面上定义的遮蔽,其在编译过程中就被实现
3.3 隐藏
隐藏的典型场景是对于一个静态方法,多态的特性失效了
java代码:
public class Parent4 {
public static void sayHi(){
System.out.println("hi,son");
}
}
class Son4 extends Parent4{
public static void sayHi(){
System.out.println("hi,parent");
}
public static void main(String[] args) {
Parent4 son4 = new Son4();
son4.sayHi();
}
}
本例中,将打印出"hi,son",这与多态的场景基本一致,区别在于本例中的方法是静态方法,所以尽管真正的实例是Son4类型的,但最后还是调用的父类的方法
son4.sayHi();对应的字节码:
10: invokestatic #7 // Method Parent4.sayHi:()V
上述指令中,#7常量指向的是父类方法(Method Parent4.sayHi:()V
),且使用的是invokestatic
指令,这条指令是专门用来调用静态方法的,具体invokestatic的实现逻辑可以看源码解析
简单总结一下就是: 在解析invokestatic #7时,直接将#7指向的符号引用解析成直接引用并返回,并不关心运行时实际对象的类型(即只有连接时解析,没有运行时解析,后文会进行介绍)
那么可以得出结论:
java语言层面上定义的隐藏,是在编译时消除的歧义
4.解析期间解决的歧义
上文讲到,在字节码中,调用方法时,指向的只是一个方法的符号引用,而不是最终的内存地址,那么就需要一个步骤将符号引用转为直接引用,这一步骤称为解析,以下是一些基本的名词解释
符号引用:是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可. 符号引用的目标不一定要加载到内存中.
直接引用:是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄. 如果有了直接引用,那引用的目标必定存在于内存中.
虚拟机规范中并没有明确规定解析阶段发生的具体时间,只要求了在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic 用于操作符号引用的字节码指令前,先对它们所使用的符号进行解析. 所以虚拟机实现可以根据需要来判断到底是在类被加载时就对常量池中的符号进行解析,还是等到一个符号将要被使用前才解析它.
4.1 多态&重写
以一个多态的例子来分析解析的过程
java代码:
class Son3 extends Parent3
...
Parent3 son3 = new Son3();
son3.sayHi();
son3.sayHi()对应的字节码:
invokevirtual #7 // Method Parent3.sayHi:()V
可以看出,#7常量指向的是父类方法(Method Parent3.sayHi:()V
),显然在编译后,歧义没有消除,那么来看解析invokevirtual
指令,可以将其分为两个步骤:连接时解解析 和 运行时解析 (根据源码函数翻译而来)
4.1.1 连接时解析(linktime_resolve_virtual_method)
根据字节码中的符号引用Parent3.sayHi:()V
,解析其对应的直接引用,具体寻找过程如下(源码可看另一篇文章):
1、遍历Parent3类的方法列表,根据符号引用来找到匹配的方法,并返回它的直接引用(methodHandle)
2、如果第一步找不到,就去Parent3的父类中找,还找不到,就去Parent3实现的接口中找
4.1.2 运行时解析(runtime_resolve_virtual_method)
连接时解析已经获得了Parent3.sayHi:()V
对应的直接引用methodHandle,接下来的步骤是:
1、根据methodHandle,获取sayHi()函数在Parent3类对应虚方法表中的位置vtable_index
2、jvm在运行时可以获取到实际对象的类型,即Son3类,获取其对应的虚表
3、从Son3的虚方法表中获取vtable_index位置指向的直接引用
invokevirtual #7 // Method Parent3.sayHi:()V
解析前,常量#7指向Parent3.sayHi:()V的符号引用
解析后,常量#7指向Son3::sayHi()V的直接引用
这就是多态的实现,在运行时解析消除了歧义
解析的过程中设计到了虚方法表的概念,下面会进行介绍
4.1.3 虚方法表
虚方法表(简称虚表)的具体初始化细节和源码可以看另一篇文章,这里总结一些相关的概念:
1、虚表是一种用于实现dynamic dispatch机制(或者说runtime method binding机制,也就是我们说的多态)的工具,C++也有用到
2、每个类对应一个虚表,虚表中不存放静态方法和私有方法(只为invoke_virtual服务)
3、虚表中,也会存放父类的方法,且对于同一个函数,在父类和子类的虚表中的位置是一样的,如果没有重写,二者指向同一个引用,若发生重写,则不同
举个简单的例子,Son3重写了Parent3的sayHi方法,以下是它们对应的虚表
5. 总结
1、编译期间消除了重载、遮蔽、隐藏这三种情况的歧义
2、解析是符号引用转化成直接引用的过程
3、解析可分为连接时解析和运行时解析两步,静态方法的解析没有第二步
4、多态&重写则是在解析时(通过虚表)消除的歧义