为什么有这篇文章?
最初在看周志明大大的《深入理解java虚拟机 第二版》时,看到第8章下面的 方法调用-分派 这一小节,当时只理解了java语言的方法重载是静态分派,而方法重载是动态分派,而对动态分派的字节码指令实现 invokevirtual 理解得很费劲。
现在想起来,根本原因有二:
第一,那个时候几乎没有学习过数据结构,对栈这个结构一知半解,虽然很容易理解但没有真正学过,没有进行深层的学习,就一直仅限于“出栈、入栈”这两个简单的概念。
第二,对一些编程术语的理解也不到位。也就是当时对“动态”这个词,怎么“动”法,缺乏场景的理解。而现在 Pivotal 的 Spring 全家桶系列大行其道,其中推行外部化配置其实也是一种动态加载的概念。还有 java 动态代理,能在运行时的恰当时机动态生成代理类,也是一种动态的思想。有了这些接触丰富了场景之后,逐渐能理解“动态”这个概念了:整个动态过程并非完全全自动(全是动态),而是说仅通过少量的配置(spring AOP 动态代理只需少量注解声明配置)、或者一套固定的机制(如本文要讲的 invokevirtual 字节码指令其中的套路,就是一些固定的规则加上一个可以动态变化的参数来完成动态过程)。对动态代理来说,虽然不用为每个代理类手动代码去处理,但是还是要一些前置处理过程,省力但并非完全不需要出力。
引言:
java中方法重载(同名方法、参数个数相同、不同参数类型)是静态多分派,本节不讨论。
而方法重写则是动态单分派,本文讨论范围。
代码示例如下:
1 public class MethodOverride { 2 3 static abstract class Human { 4 protected void f() { 5 System.out.println("protected f()"); 6 } 7 protected abstract void hello(); 8 } 9 10 static class Man extends Human { 11 @Override 12 protected void hello() { // 直接继承父类的访问修饰符,或者可以修改为更宽松的访问修饰 13 System.out.println("man hello"); 14 } 15 } 16 17 18 static class Woman extends Human { 19 @Override 20 public void hello() { // 直接继承父类的访问修饰符,或者可以修改为更宽松的访问修饰 21 System.out.println("woman hello"); 22 } 23 } 24 25 public static void main(String[] args) { 26 Human man = new Man(); 27 Human woman = new Woman(); 28 man.hello(); 29 woman.hello(); 30 31 man = new Woman(); 32 man.hello(); 33 } 34 35 36 }
程序输出:
man hello
woman hello
woman hello
如上代码,先解释几个概念,第26行变量man的定义:Human man = new Man();
① 其中类型 Human 称为 man 的静态类型。
② 右边 new Man() 表示 Man 是 man 的实际类型。
③ 方法接收者,如 man.hello()、woman.hello() 这样的方法调用,man 和 woman 的实际类型称为“方法的接收者”。
静态类型在方法重载时会使用,不是本文内容。而实际类型则是java方法重写时底层jvm字节码确认的方法接收者类型。
在 main 函数中,man变量的实际类型在 26行第一次定义,为 Man;在 31行进行了变更,变为 Woman。结果可以看到,在打印时最后 man.hello() 输出了 “woman hello”。
以上就是java方法重写的演示,在程序运行时,变量 man 的实际类型发生了变化,最后 hello() 方法的行为也呈现出了不同的结果。
多态与面向接口编程
首先我们要知道java语言通过编译将 java源码文件编译为 class文件,而我们所写的方法的代码将会存储到class文件的方法表中,class文件的固定结构(10个部分)如下图:
既然字节码在编译期就确定了,编译器如何确定具有多态性的 java语言的方法的接收者类型呢?
上面那段代码是在 main 函数中,可能让人对多态性的感受并不是那么明显,现在再来看下面这段代码:
1 public void doSthAndSayHello(Human human) { 2 //... do something... 3 human.hello(); 4 //... do something... 5 }
以上这段示例代码模拟了我们平时通过定义接口,并用接口来完成一段方法的场景。在调用接口方法 Human#hello() 前后执行一些业务逻辑。
这样的好处就是不管是什么Human类型的实现类,这个 doSthAndSayHello() 方法都能接收。这就是面向接口编程的好处。
那么我们的问题是,对于jvm来说,如何在运行时确定传入 doSthAndSayHello() 方法中的 Human 是什么类型呢?是 Man 还是 Woman?还是其他子类型?
答案是确定不了。因为在 doSthAndSayHello() 方法中调用的 hello() 是一个实例方法,不是类方法。
实例方法是可以重写的,一个父类可以派生出无穷多的子类,子类可以无限重写同一个父类方法。多态因此而来。
类方法是当前类所有(即 static 方法),直接和类绑定,jvm在解析阶段可以直接将类的方法的符号引用解析到刚加载完成的类的类方法入口上,即完成符号引用到直接引用的转换。
由于如上原因,jvm 在运行时调用一个“不确定的方法”时,如上面示例代码 doSthAndSayHello() 方法中的 humen.hello();,只能在运行时结合方法入参 humen 的实际类型才能最终确定调用的是哪个子类的 hello() 方法。
回顾一下上面我们讲过的有关“动态”的实现套路,和多态性语言的静态类型与实际类型 这个知识点,我们可以进行如下推测:
jvm在运行期碰到一个“不确定”的方法时,需要根据入参的实际类型(入参存入本地变量表),在调用这类方法时要进行最终目标方法的搜索。
深入jvm内部字节码指令一探究竟
来看看《深入理解java虚拟机》里面对这些“确定”和“不确定”的方法是怎么说的,如下图:
原来jvm通过5条 invoke指令细分它们作用的对象。弄清楚非虚方法和虚方法的指令后,我们知道 invokevirtual 命令是实现动态分派(方法重写)的关键所在,接下来要看一下 invokevirtual 在 jvm 中的运行原理,如下图:
到此,整个java方法的动态分派过程就全部了解清楚了:invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型(不记得接收者的往开头翻),所以每次调用 invokevirtual 时都可以动态结合操作数栈顶的元素来完成整个动态分派的动作。即一个固定的 invokevirtual 指令 + 栈顶的动态元素 共同完成了动态分派这个工作。到此,动态分派的套路完全显露无疑了。