本文算是《深入理解Java 虚拟机》的学习笔记
# 符号引用和直接引用
首先来了解一下
符号引用
和
直接引用
的概念:
Java类从加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载,共七个阶段。
注意:加载、验证、准备、初始化这四个阶段发生的顺序是固定的,而解析阶段在某些情况下位于初始化之后。此外,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
其中
解析阶段
主要是虚拟机将
常量池
中的
符号引用
转化为
直接引用
的过程。那么什么是符号引用和直接引用呢?
符号引用
:以一组符号来描述所引用的目标,可以是任何形式的字面量(整数、字符串等),只要是能无歧义的定位到目标就好。符号引用与虚拟机的布局无关。
直接引用
:即可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。直接引用与虚拟机的布局相关,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定是被加载到了内存中。
# 解析和分派
说完了符号引用和直接引用我们回归正题!
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是
确定被调用方法的版本
(即调用哪个方法),暂时还未涉及方法内部的具体运行过程。一切方法调用在.claa文件中存储的都只是
符号引用
,而不是方法在实际运行时内存布局中的
入口地址
(即
直接引用
)。
方法调用形式有:解析和分派。且两者之间的关系并不是二选一的排他关系,他们是在
不同层次
上去筛选、确定目标方法的过程。
解析(Resolution) |
解析(Resolution)
调用目标在程序写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析。
调用不同类型的方法,字节码指令集里设计了不同的指令。JVM支持以下5条方法调用字节码指令,分别是:
-
invokestatic
。用于调用静态方法。 -
invokespecial
。用于调用实例构造方法<init>()方法、私有方法和父类中的方法。 -
invokevirtual
。用于调用所有虚方法。 -
invokeinterface
。用于调用接口方法,会在运行时再确定一个实现该接口的对象。 -
invokedynamic
。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
前面 4 条调用指令,分派逻辑都固化在 JVM 内部,而
invokedynamic
指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被
invokestatic
和
invokespecial
指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 中符合这个条件的方法有
静态方法
、
私有方法
、
实例构造器
、
父类方法
4 种,再加上被
final
修饰的方法(尽管它使用 invokevirtual 调用),这5种方法调用会在类加载时就可以把符号引用解析为该方法的直接引用。这些方法统称为「
非虚方法(Non-Virtual Method)
」,与此相反,其他方法则被称为「
虚方法(Virtual Method)
」。
解析调用一定是个静态的过程,在编译期就完全确定,在类加载的解析阶段就会把涉及的符号引用全部变为明确的直接引用,不必延迟到运行期再去完成。
分派(Dispactch) |
分派调用相对于解析调用要复杂得多,按照状态来分可分为
静态分派
和
动态分派
,
众所周知,Java 是一门面向对象的程序语言,Java 具备面向对象的 3 个基本特征:
继承
、
封装
和
多态
。分派调用过程将会揭示多态特性的一些最基本的体现,如「
重载(Overload)
」和「
覆盖(Override)
」在 JVM 中是如何实现的,这里的实现当然不是语法上该如何实现,而是 JVM 如何根据相关信息定位到应该执行的方法版本。
1.
静态分派
首先来看下面的这个例子:
public class StaticDispatch {
static abstract class OperatingSystem {}
static class Linux extends OperatingSystem {}
static class MacOS extends OperatingSystem {}
public void run(OperatingSystem OS){
System.out.println("hello,OS");
}
public void run(Linux linux){
System.out.println("hello,Linux");
}
public void run(MacOS macOS){
System.out.println("hello,MacOS");
}
public static void main(String[] args) {
StaticDispatch sr = new StaticDispatch();
OperatingSystem linux = new Linux();
OperatingSystem macOS = new MacOS();
sr.run(linux);
sr.run(macOS);
}
}
运行结果:
hello,OS
hello,OS
StaticDispatch
类中有多个重载
run()
方法,传入的参数类型分别为父类
OperatingSystem
、子类
Linux
和子类
MacOS
,
main()
方法中的代码为「父类引用指向子类对象」。
之所以运行结果为
“hello,OS”
,是因为
JVM 选择执行的是参数类型为 OperatingSystem 的重载版本
。在解决这个问题之前,我们先通过以下代码来定义两个关键概念:
静态类型
、
实际类型
。
OperatingSystem linux = new Linux();
这里的
“OperatingSystem”
称为变量的 「
静态类型(Static Type)
」,或者叫「
外观类型(Apparent Type)
」,后面的 “Linux” 则被称为变量的 「
实际类型(Actual Type)
」或者叫「
运行时类型(Runtime Type)
」。
静态类型
和
实际类型
在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在
编译期
就可知的;而实际类型变化的结果在
运行期
才能确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。下面通过一个例子来帮助大家对上面这段话进行理解:
// 实际类型的变化 OperatingSystem os = (new Random()).nextBoolean() ? new Linux() : new MacOS(); // 静态类型的变化 sr.run((linux) os) sr.run((macOS) os)
对象 os 的实际类型是可变的,编译期间它是不确定的,到底是 Linux 还是 MacOS,必须等到程序运行到这一行的时候才能够确定。而 os 的静态类型是OperatingSystem,也可以在使用时(如下面run()方法中的强转)临时改变这个类型,但是这个改变在编译器仍然是可知的,两次 run() 方法的调用,在编译期完全可以明确转型的是 linux 还是 macOS。
解释清楚了静态类型与实际类型的概念,我们接着言归正传。在最初的
StaticDispatch
类中,
main()
方法里面的两次
run()
方法调用,在接收者已经确实是对象 “sr” 的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。而 JVM (或者准确地说是编译器)在
重载
时是通过参数的
静态类型
而不是实际类型作为判断依据的。
静态类型在编译期可知,所以在编译阶段,Javac 编译器就根据参数的静态类型决定会使用哪个重载版本,因此选择了
run(OperatingSystem)
作为调用目标,并把这个方法的符号引用写到
main()
方法里的两条
invokevirtual
指令中。
所有依赖静态类型来决定方法执行版本的分派动态,都称为「静态分派」。静态分派最典型的应用表现就是方法的重载。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料把它归入「解析」而不是「分派」的原因。
需要注意的是,Javac 编译器虽然能确定出方法的重载版本,但是很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更加合适的”版本。
2.
动态分派
动态分派的实现过程与
重写
(Override)有着很密切的关系。
同样采用上面的例子:
public class StaticDispatch {
static abstract class OperatingSystem {
protected abstract void run();
}
static class Linux extends OperatingSystem {
@Override
protected void run(){
System.out.println("hello,Linux");
}
}
static class MacOS extends OperatingSystem {
@Override
protected void run(){
System.out.println("hello,MacOS");
}
}
public static void main(String[] args) {
OperatingSystem linux = new Linux();
OperatingSystem macOS = new MacOS();
linux.run(); // ①
macOS.run(); // ②
linux = new MacOS();
linux.run(); // ③
}
}
运行结果:
hello,Linux // ①
hello,MacOS // ②
hello,MacOS // ③
仔细思考,不难发现,这里选择调用的方法版本
不再是根据静态类型决定的
,而是
实际类型
。这也就是平时我们所说的
多态
–
同样的静态类型,调用同样的方法名,却产生了不同的效果
。
那么,JVM 是如何根据实际类型来分派方法执行版本的呢?
因为
invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型
,所以两次调用中的
invokevirtual
指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中重载的本质。
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
另外有个问题是:字段会不会产生多态?
答案是:不会!
因为这种多态性的根源在于虚方法调用指令
invokevirtual
的执行逻辑,那么,就可以得出结论:其只对方法有效,对字段是无效的,因为字段不使用这条指令。
事实上,在 Java 里面存在虚方法,而字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。
为了加深理解,请看如下代码:
public class DynamicDispatch {
static abstract class Father {
public int money = 1;
public Father(){
money = 2;
showMeTheMoney();
}
public void showMeTheMoney(){
System.out.println("I am Father,I have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son(){
money = 4;
showMeTheMoney();
}
public void showMeTheMoney(){
System.out.println("I am Son,I have $" + money);
System.out.println("---");
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}
运行结果:
I am Son,I have $0
---
I am Son,I have $4
---
This gay has $2
小盆友,你是否有很多问号???
这是因为
Son
这个类在创建的时候,首先隐式地调用了
Father
的构造函数,而
Father
构造函数中对
showMeTheMoney()
的调用是一次虚方法调用,实际执行的版本是
Son:: showMeTheMoney()
方法,所以输出的是
"I am Son"
。而这时候虽然父类的
money
字段已经被初始化为 2 了,但
Son:: showMeTheMoney()
方法中访问的却是子类的
money
字段,这时,结果自然还是 0(因为它要到子类的构造函数执行时才会被初始化),
main()
的最后一句话通过静态类型访问到了父类的
money
,输出了2。