通过实例一行一行分析JVM的invokespecial和invokevirtual指令


1. JVM提供的方法调用指令集

JVM提供了5种方法调用指令,其作用列举如下:

invokestatic:该指令用于调用静态方法,即使用 static 关键字修饰的方法;

invokespecial:该指令用于三种场景:调用实例构造方法,调用私有方法(即private关键字修饰的方法)和父类方法(即super关键字调用的方法);

invokeinterface:该指令用于调用接口方法,在运行时再确定一个实现此接口的对象;

invokevirtual:该指令用于调用虚方法(就是除了上述三种情况之外的方法);

invokedynamic:在运行时动态解析出调用点限定符所引用的方法之后,调用该方法;在JDK1.7中推出,主要用于支持JVM上的动态脚本语言(如Groovy,Jython等)。

本文主要介绍invokespecialinvokevirtual指令。

2. 举个栗子

下面我们来看一个具体的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.wxweven.jvm.zzm.jvminvoke;

/**
* @author wxweven
* @date 2017/9/15
*/
public class InvokeVirtualTest {
public static void main(String[] args) {
Father test = new Son();
test.say();
}
}

class Father {
public void say() {
System.out.println("i am fater");
System.out.println(this);
this.hello();
this.hi();
}

private void hello() {
System.out.println("father say hello");
}

public void hi() {
System.out.println("father say hi");
}
}

class Son extends Father {

public void hello() {
System.out.println("son say hello");
}

public void hi() {
System.out.println("son say hi");
}
}

那么列为看官来分析下,这段代码的输出会是什么?

如果能够很清楚地知道代码的输出如下:

1
2
3
4
i am fater
com.wxweven.jvm.zzm.jvminvoke.Son@816f27d
father say hello
son say hi

那么本文的阅读请就此打住,没必要再往下看了,再往下看也是浪费各位大神的时间。如果还有点小疑惑的话,不妨跟着我来一起分析分析。

3. 栗子逻辑分析

3.1. main方法

我们的分析从main方法开始,main方法的代码如下:

1
2
3
4
public static void main(String[] args) {
Father test = new Son();
test.say();
}

main方法很简单,就两行:

  • 第一行定义了一个 Father 类的变量 test, 并让它指向了具体的实例 Son;
  • 第二行调用了test变量的say方法

3.2. say方法

那么我们接着来看看say方法的内容:

1
2
3
4
5
6
public void say() {
System.out.println("i am fater");
System.out.println(this);
this.hello();
this.hi();
}

也很简单:

  • 第一行输出了一句 "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
2
3
4
5
6
7
8
9
10
11
12
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class com/wxweven/jvm/zzm/jvminvoke/Son
3: dup
4: invokespecial #3 // Method com/wxweven/jvm/zzm/jvminvoke/Son."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method com/wxweven/jvm/zzm/jvminvoke/Father.say:()V
12: return

这段指令集很简单(main方法本身就很简单),先new一个Son类的实例,然后调用Son实例的构造函数,然后再调用say方法。

这段指令集中已经出现了invokespecialinvokevirtual指令,先卖个关子,等后面分析。

4.2. Father类的指令集

执行命令

1
javap -private -v com.wxweven.jvm.zzm.jvminvoke.Father

得到Father类的字节码,截取有用的say、hello、hi方法的指令集如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public void say();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String i am fater
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_0
12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
15: aload_0
16: invokespecial #6 // Method hello:()V
19: aload_0
20: invokevirtual #7 // Method hi:()V
23: return

private void hello();
descriptor: ()V
flags: ACC_PRIVATE
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #8 // String father say hello
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 23: 0
line 24: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lcom/wxweven/jvm/zzm/jvminvoke/Father;

public void hi();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #8 // String father say hello
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 27: 0
line 28: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lcom/wxweven/jvm/zzm/jvminvoke/Father;

Father类的指令集是我们分析的重点,首先我们来看say方法的指令集。

4.2.1. say方法指令集

say方法指令集的第一段:

1
2
3
4
0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String i am fater
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;

可以很明显的看出,这段代码调用 System.out.println 方法,并打印出了 "i am fater";

接着我们来看say方法指令集的第二段:

1
2
11: aload_0
12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V

其中 aload_0 用于将本地变量表的第0个slot的变量(也就是 this 对象)加载到栈顶,所以这里打印出来的this对象,即Son类的实例。

接着我们来看say方法指令集的第三段:

1
2
15: aload_0
16: invokespecial #6 // Method hello:()V

注意这里很明显的invokespecial指令,周志明大大的《深入理解Java虚拟机》第二版中讲到:

invokespecial指令调用的方法,在类加载的时候就会把符号引用解析为该方法的直接引用。

也就是说,当JVM在使用invokespecial指令时,在类加载时就能确定需要调用的具体方法,而不需要等到运行时去根据实际的对象值去调用该对象的方法。(不知道的这样解释大家有没有看懂。。。)

所以这里调用的就是父类的hello方法。所以程序才会输出 "father say hello"

接着我们来看say方法指令集的第四段:

1
2
19: aload_0
20: invokevirtual #7 // Method hi:()V

注意这里调用的是invokevirtual指令,同样,周志明大大的《深入理解Java虚拟机》第二版中讲到(哈,我就是这么崇拜周志明大大,当然还有R大):

invokevirtual指令的调用依赖于运行时解析,其解析过程大致分为以下几个步骤:

  1. 找到操作数栈顶的第一个元素(本例中就是 this)所指向的对象的实际类型,记作C(本例中就是Son对象实例)。
  2. 如果在类型C中找到了与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常(那么很明显,在本利中,类型C即Son对象实例存在方法hi,而且访问权限校验也通过)。
  3. 否则,按照继承关系对C的父类进行第2步的搜索和校验过程(很明显,本利中不需要去父类查找)。
  4. 如果始终都没有找到合适的方法,则抛出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
2
3
4
i am fater
com.wxweven.jvm.zzm.jvminvoke.Son@816f27d
son say hello
son say hi

5. 总结

好久没有这么认真的写博客了,博客也是长草好一段时间了。最近在看周志明大大的《深入理解Java虚拟机》,这篇算是一个读书笔记,总结得有不对的地方,欢迎留言或邮件探讨。

周志明大大这本书,实在是一本难得的好书!需要多读几遍!后面还会总结一些其他的读书心得,敬请期待!

6. 参考