1.两个概念
先给出两个概念 1.静态类型 2.实际类型,下面这行代码中 Father 是静态类型,编译期间已知。Son 是实际类型,编译期间不能确定,在运行期代码执行到这里时才能知晓。
Father guy = new Son();
为什么说不可知晓呢?例如下面的代码,只有运行到 Random 才能知晓对象的实际类型。
public static void main(String[] args) {
Father guy = new Random().nextBoolean() ? new Father() : new Son();
}
2.方法调用前
所有方法在编译期间都是在 Class 文件中的符号引用。方法被调用之前,符号引用需要被替换为直接引用。
什么是符号引用?
Class 文件中的符号引用包括三种类型:
- 类/接口的符号引用
- 字段的符号引用
- 方法的符号引用
符号引用是做什么的?
Java 程序分为编译期和运行期,在编译期间,要找到想要的类/字段/方法,需要一个无歧义的标识,这就是符号引用。
本文讨论的方法的符号引用包括 1.方法所属的类/接口(CONSTANT_Class_info) 2.方法的名称及描述符 (CONSTANT_NameAndType_info)。
例如下面这个方法及其对应的符号引用:
class Test1{
public int func(String str,long l){
return 1;
}
}
Method clazz/dynamicLink/Test1.func:(Ljava/lang/String;J)I
clazz/dynamicLink/Test1 -> 方法所属的类
func -> 方法简单名称
(Ljava/lang/String;J) -> 参数列表
J 表示 long
Ljava/lang/String 表示 String
I 返回值类型 int
什么是直接引用?
在 Java 运行期间想要找到想要的类/字段/方法,也需要一个无歧义的标识,这就是直接引用。想要在虚拟机运行时数据区域中寻找,那就需要内存地址作为标识。
3.方法调用时
方法调用对应多种字节码指令
- invokestatic 调用静态方法
- invokespecial 调用私有方法 / 父类方法
- invokevirtual 调用虚方法
- invokeinterface 调用接口方法
- invokedynamic 调用动态方法(不做讨论)
当调用以上字节码指令时,通常会跟随一个索引例如 invokestatic #1
,索引指向类中一个方法的符号引用。“编译时确定索引指向的符号引用”,这个过程称为静态解析,为什么说是静态的呢?因为这个过程是由编译器来决定的。例如以下代码:
public class Test {
static class Father{
public static void staticFunc(){ System.out.println("fatherStatic"); }
public void func(){ System.out.println("father"); }
}
static class Son extends Father{
@Override
public void func(){ System.out.println("son"); }
}
public static void main(String[] args) {
Father guy = new Son();
Father.staticFunc();
guy.func();
}
}
由 Father 类名调用 staticFunc(),由 guy 对象调用 func()。对应的字节码为:
invokestatic #2 // Method clazz/dynamicLink/Test$Father.staticFunc:()V
invokevirtual #3 // Method clazz/dynamicLink/Test$Father.func:()V
在编译期间,编译器能够通过静态类型,***静态解析***出方法属于哪个类。例如上面解析出了 Father 类名调用的 staticFunc() 方法属于 clazz/dynamicLink/Test$Father 类,guy 对象调用的 func() 方法也属于 Father 类。
相信稍微了解多态的同学已经发现问题了: guy 调用的应该是 Son 类的 func() 方法吧!
事实上也是这样,guy.func()
输出 “son”。
只有"一部分"方法能够在编译期间被确定,而"另一部分"方法光通过编译器进行静态解析并不能判断出真正调用的方法属于哪个类
这里的一部分指的是只存在一种版本的方法,例如上面代码中的 staticFunc() 方法。我们知道:1.被 private 修饰的方法是私有方法,默认是 final 的。2. 被 static 修饰的方法是静态方法,与类型关联,默认是 final 的。3.构造方法不能被继承。4我们还知道被 final 修饰的方法不能被重写,因此只存在一种版本。
3.1静态解析结论
被 private 修饰的方法,被 static 修饰的方法,被 final 修饰的方法都只存在一种版本,编译期间能够确定。这类方法叫做非虚方法,在编译期间就能被编译器静态解析出方法接收者的类型。当非虚方法被调用时,触发类的解析,将方法的符号引用替换为直接引用。
- 调用私有方法,构造方法,父类中的方法(super.xx())对应的字节码指令为 invokespecial
- 调用静态方法对应的字节码指令为 invokestatic
- 调用 final 修饰的方法是个特例,对应的字节码指令为 invokevirtual
另一部分指的是拥有多个版本的方法,例如上面代码中的 Father 类实现了 func() 方法,Son 通过继承 Father 类,重写了 func() 方法。所以在以上代码中 func() 方法存在两个版本。
这类方法叫做虚方法,在编译期间不能被完全确定,还需要在运行期间进行方法动态分派。
非虚方法对应的字节码指令为 invokevirtual(多态性的根源)
4.方法动态分派
动态分派也称为动态绑定。
参考《深入Java虚拟机》,invokevirtual 指令的运行时解析过程大致分为如下几步
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
- 如果在类型C中找到与方法的简单名称和描述符都相符的方法,则进行权限访问,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError(注意不是IllegalAccessException,异常发生有区别)
- 否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。(抽象方法)
简单来说:在运行期间方法调用时,根据方法接收者的实际类型,动态判断方法的版本。
来个例子,判断两个方法的字节码指令及符号引用:
public class DynamicDispatch {
static class Person{
int func(int param1){
System.out.println("PersonFunc");
return 0;
}
}
static class Man extends Person{
@Override
int func(int param1) {
System.out.println("ManFunc");
return 1;
}
}
static class Woman extends Person{
@Override
int func(int param1) {
System.out.println("WomanFunc");
return 2;
}
}
public static void main(String[] args) {
Person man = new Man();
Person woman = new Woman();
woman = (Woman) woman;
man.func(1);
woman.func(2);
}
}
aload_1 将 man 对象压入操作数栈顶
iconst_1
invokevirtual #6 // Method clazz/dynamicLink/DynamicDispatch$Person.func:(I)I
aload_2 将 woman 对象压入操作数栈顶
iconst_2
invokevirtual #7 // Method clazz/dynamicLink/DynamicDispatch$Person.func:(I)I
方法分派调用过程:
首先,编译器对方法进行静态解析,方法接收者(man 对象)的静态类型为 Person,所以方法解析为 clazz/dynamicLink/DynamicDispatch$ Person.func。然后运行期间执行 invokevirtual 前,aload_1 将1号变量槽中的元素(man对象 -> 方法接收者)压入操作数栈顶,执行 invokevirtual 时,判断操作数栈顶对象的实际类型为 Man,所以虚拟机选择版本为 clazz/dynamicLink/DynamicDispatch$Man.func 的方法并创建栈帧压入虚拟机栈顶,执行方法,方法执行完毕后退栈。
woman 静态类型被转换为 Person,但是对 woman 对象调用 func() 方法没有影响,与上面同理,判断方法接收者的实际类型,调用其 func 方法。
4.1虚方法动态分派的实现基础
虚方法分派的实现基础是 Klass 对象中的虚方法表 vtable。
当类被加载时,对应的 klass 对象被创建,存在于方法区中。klass 对象存储了从 Class 文件中读取并转化的类型信息,包括类名、父类、接口、方法表、常量池。
Klass 功能:
1: language level class object (method dictionary etc.) //作为类对象,提供方法字典等类型信息
2: provide vm dispatch behavior for the object //提供方法分派机制
Both functions are combined into one C++ class. //Klass 是个 C++ 类
Object 类对应的 instanceKlass 对象中的 vtable 中存放虚方法(hashCode,equals,clone,toString,finalize)。
Person 类继承 Object 类。会将 Object 的 instanceKlass 对象的 vtable 复制过来,然后在 Person 类的 vtable 末尾加上自己的虚方法。
Man 类继承 Person 类,复制 Person 类的 vtable。自己没有虚方法,vtable 的长度与父类相同。但 Man 类重写了 Person 类的 func() 方法,与父类 vtable 中的 func() 方法不是同一个。这就是方法分派的实现基础。在虚方法被调用时,需要在运行时判断方法接收者的实际类型,并找到对应的 vtable,再通过 vtable 找到该虚方法的内存地址。
5.方法静态分派
第四节讨论了invokevirtual 的动态分派过程,举了个重写方法的例子。这节我们来讨论多态的另一种体现,重载 Overload。
重写的方法,方法签名相同。重载的方法,方法签名不同。
Java 语言层面的方法签名包括:方法简单名称,方法参数列表。
Class 文件格式层面的方法签名包括:方法简单名称,方法参数列表,方法描述符(返回值)。范围更大一点,比 Java 语言更强大。
public class OverloadTest {
static class Person{}
private static class Man extends Person{}
private static class Woman extends Person{}
public static void sayHello(Person person){
System.out.println("person say hello");
}
public static void sayHello(Man man){
System.out.println("man say hello");
}
public static void sayHello(Woman woman){
System.out.println("woman say hello");
}
public static void main(String[] args) {
Person man = new Man();
Woman woman = new Woman();
sayHello(man);
sayHello(woman);
}
}
以上代码的结果:
person say hello
person say hello
编译期间编译器就可以通过静态类型来决定使用哪个重载版本。与静态解析一样,都是编译期可确定。
我也不太懂把它叫做分派的原因…
5.1重载优先级
重载具有优先级,继承深度越低的越优先。
向以上代码中加入 Human 类,使 Person 类继承 Human 类。
static class Human{}
static class Person extends Human{}
public static void sayHello(Human human){
System.out.println("human say hello");
}
public static void sayHello(Person person){
System.out.println("person say hello");
}
public static void sayHello(Woman woman){
System.out.println("woman say hello");
}
public static void main(String[] args) {
Person man = new Man();
sayHello(man);
}
//输出 person say hello
删除参数静态类型为 Person 的方法后,输出为 human say hello
5.2 向上向下转型
Apperence a = new Actual();
上面代码中可以把 静态类型Apperence 看做外观,把实际类型 Actual 看做内在。
现在有三个类 Father,Son,LaoWang。Son 继承 Father。 三个对象 father,son,laowang
Father father = new Father();
Son son = new Son();
LaoWang laowang = new LaoWang();
儿子长得像爸爸,这句话是可以存在的。那么 son 对象的"外观"(静态类型),可以由 Son 改变为 Father
Father son1 = son;
爸爸长得像儿子,这句话不符合实际。那么 father 对象的"外观"(静态类型),不能改变为 Son,下面的代码会抛出 ClassCastException
father = (Son) father;
儿子长得像老王,这句话不符合实际。儿子与老王没有血缘关系。那么 son 对象的"外观"不能改变为 LaoWang,下面的代码编译不通过。两种静态类型之间没有关系,不能转换。
son = (LaoWang) son;
在 Java 中自动向上转型的,儿子出生时就可以长得像爸爸。随着长大变老,也可以不像爸爸。
Father baby = new Son(); //自动向上转型
Son son1 = (Son) baby; //向下转型(需要强转)