1. JVM提供的方法调用指令集
JVM提供了5种方法调用指令,其作用列举如下:
invokestatic:该指令用于调用静态方法,即使用 static 关键字修饰的方法;
invokespecial:该指令用于三种场景:调用实例构造方法
invokeinterface:该指令用于调用接口方法,在运行时再确定一个实现此接口的对象;
invokevirtual:该指令用于调用虚方法(就是除了上述三种情况之外的方法);
invokedynamic:在运行时动态解析出调用点限定符所引用的方法之后,调用该方法;在JDK1.7中推出,主要用于支持JVM上的动态脚本语言(如Groovy,Jython等)。
本文主要介绍invokespecial
和invokevirtual
指令。
2. 举个栗子
下面我们来看一个具体的例子。
1 | package com.wxweven.jvm.zzm.jvminvoke; |
那么列为看官来分析下,这段代码的输出会是什么?
如果能够很清楚地知道代码的输出如下:
1 | i am fater |
那么本文的阅读请就此打住,没必要再往下看了,再往下看也是浪费各位大神的时间。如果还有点小疑惑的话,不妨跟着我来一起分析分析。
3. 栗子逻辑分析
3.1. main方法
我们的分析从main方法开始,main方法的代码如下:
1 | public static void main(String[] args) { |
main方法很简单,就两行:
- 第一行定义了一个
Father
类的变量 test, 并让它指向了具体的实例Son
; - 第二行调用了test变量的say方法
3.2. say方法
那么我们接着来看看say方法的内容:
1 | public void say() { |
也很简单:
- 第一行输出了一句
"i am fater"
; - 第二行打印了当前
this
代表的实际类型; - 第三行调用了
this
对象的hello方法; - 第四行调用了
this
对象的hi方法;
无论是 Father
类的 hello/hi方法,还是 Son
类的 hello/hi方法都很简单(都是简单输出一句话),不再继续展开。
我们直接来看say方法运行时的结果:
第一行输出
"i am fater"
,这个没有什么疑问;
接着再输出 this 对象的时候,结果告诉我们这是一个Son类的实例;
当我们调用this.hello()
的时候,输出的却是"father say hello"
,也就是说实际调用的是父类的hello方法,当我们调用this.hi()
的时候,输出的才是"son say hi"
。
为什么会出现这种情况,运行时的this明明指向的是Son
实例,两次方法调用中,一次调用的是Father
的方法而另一次调用的却是Son
的方法。是Java程序执行出bug还是我们的理解没到位?
首先可以肯定的是,程序执行本身没有bug。
所以,肯定是我们的理解没到位喽。要解开这个谜题,我们只好用 javap
指令来查看编译后的字节码,看看虚拟机到底是如何执行这段代码的。
4. javap指令
4.1. InvokeVirtualTest类的指令集
执行命令
1 | javap -v com.wxweven.jvm.zzm.jvminvoke.InvokeVirtualTest |
得到InvokeVirtualTest
类的字节码,截取有用的main方法的指令集如下:
1 | public static void main(java.lang.String[]); |
这段指令集很简单(main方法本身就很简单),先new一个Son
类的实例,然后调用Son
实例的构造函数,然后再调用say方法。
这段指令集中已经出现了invokespecial
和invokevirtual
指令,先卖个关子,等后面分析。
4.2. Father类的指令集
执行命令
1 | javap -private -v com.wxweven.jvm.zzm.jvminvoke.Father |
得到Father
类的字节码,截取有用的say、hello、hi方法的指令集如下:
1 | public void say(); |
Father类的指令集是我们分析的重点,首先我们来看say方法的指令集。
4.2.1. say方法指令集
say方法指令集的第一段:
1 | 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; |
可以很明显的看出,这段代码调用 System.out.println
方法,并打印出了 "i am fater"
;
接着我们来看say方法指令集的第二段:
1 | 11: aload_0 |
其中 aload_0 用于将本地变量表的第0个slot的变量(也就是 this
对象)加载到栈顶,所以这里打印出来的this对象,即Son
类的实例。
接着我们来看say方法指令集的第三段:
1 | 15: aload_0 |
注意这里很明显的invokespecial
指令,周志明大大的《深入理解Java虚拟机》第二版中讲到:
invokespecial
指令调用的方法,在类加载的时候就会把符号引用解析为该方法的直接引用。
也就是说,当JVM在使用invokespecial
指令时,在类加载时就能确定需要调用的具体方法,而不需要等到运行时去根据实际的对象值去调用该对象的方法。(不知道的这样解释大家有没有看懂。。。)
所以这里调用的就是父类的hello方法。所以程序才会输出 "father say hello"
。
接着我们来看say方法指令集的第四段:
1 | 19: aload_0 |
注意这里调用的是invokevirtual
指令,同样,周志明大大的《深入理解Java虚拟机》第二版中讲到(哈,我就是这么崇拜周志明大大,当然还有R大):
invokevirtual
指令的调用依赖于运行时解析,其解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素(本例中就是
this
)所指向的对象的实际类型,记作C(本例中就是Son
对象实例)。 - 如果在类型C中找到了与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常(那么很明显,在本利中,类型C即
Son
对象实例存在方法hi,而且访问权限校验也通过)。 - 否则,按照继承关系对C的父类进行第2步的搜索和校验过程(很明显,本利中不需要去父类查找)。
- 如果始终都没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
注意:以上的引用,并非100%原封不动的引用了周志明大大的原书内容,因为根据R大在知乎上的回答,原书中这段描述是有些问题的(https://www.zhihu.com/question/40427344/answer/86545388) 。所以我在结合R大回答的基础上,自己做了些了解和更改。
也就是说,当JVM在使用invokevirtual
指令时,必须要在运行时,根据调用对象的实际值去调用对应的方法,跟invokespecial
指令是不一样的。
所以,在调用 this.hi()
方法时,运行时查找到 this
对象类型为 Son
,而且 Son
类中存在对应的hi方法,访问权限校验也通过,所以这里调用的是 Son
类的hi方法,程序的输出为: "son say hi"
4.3. 小结
到这里,例子中的输出问题,已经从 javap
的角度分析完毕了。小结一下,在Father
类中的say方法中,调用 "this.hello()"
和 "this.hi()"
时,出现不一致的效果,其原因就是因为:
- 调用 hello 时,用到的指令是
invokespecial
,在类加载阶段就可以确定具体调用的是哪个方法; - 调用 hi 时,用到的指令是
invokevirtual
,需要运行时去解析具体该调用哪个方法。
而造成两个方法实际执行指令不同的根本原因又是因为:
- 在
Father
类中,hello
定义为私有方法,使用invokespecial
指令调用; hi
定义为公有方法(即JVM所理解的虚方法),使用invokevirtual
指令调用。
关于这点,感兴趣的看官,可以把 Father
类中的hello方法也改为public,再运行代码,就会看到实际调用也是Son
类的hello方法,对应的输出如下:
1 | i am fater |
5. 总结
好久没有这么认真的写博客了,博客也是长草好一段时间了。最近在看周志明大大的《深入理解Java虚拟机》,这篇算是一个读书笔记,总结得有不对的地方,欢迎留言或邮件探讨。
周志明大大这本书,实在是一本难得的好书!需要多读几遍!后面还会总结一些其他的读书心得,敬请期待!
6. 参考
- 《深入理解Java虚拟机》第二版,周志明大大
- R大在知乎上的回答
- Java-工具javap讲解