JVM中方法调用的实现机制
本文将要介绍一下Java 8和Java 9中JVM是如何进行方法调用的。这是JVM内部实现的基础机制,如果你想理解JVM的just-in-time(JIT)编译器或者进行应用程序调优的话,这些是必需的背景知识。
字节码分析
我们先从一段简单的Java代码开始:
通过javap -c查看反编译后的代码,可以看到Java编译器所生成的字节码:
首次阅读JVM级别的字节码的Java开发人员一定会感到奇怪,Java的方法调用全都变成了各式各样的invoke*指令。
我们先来详细看下反编译后的第一段代码:
对System.currentTimeMillis的静态方法调用变成了字节码标记0处的invokestatic操作码。这个方法没有入参,因此在方法分派调用前并没有什么需要加载进来的。
紧接着,字节流中出现了00 02两个字节。它们组成了一个16位的数字(这里对应的是#2),它是class中的一个表——被称为常量池——里的偏移量。所有的常量池索引都是16位的,因此操作码如果想引用常量池里的任意一个值,对应入口的偏移量都会被编码为两个连续的字节。
反编译器很贴心地加上了一段注释来告诉你#2处到底对应的是什么方法。这里当然就是System.currentTimeMillis方法了。javap反编译后的信息包含了被调用方法的名称,以及方法的入参类型(放在括号中),最后是方法的返回值类型。
方法一旦返回,调用结果会被存储在栈上,标记3处可以看到一个单独的,无参数的操作码lstore_1,它会将返回值存储到long类型的局部变量中。
读代码的人显然能发现这个返回值后面是用不上的。不过,Java编译器的一个设计目标就是要尽可能忠实地反映源代码的内容——不管逻辑上是不是有意义的。因此,这段程序中System.currentTimeMillis的返回值还是被存储起来了,尽管最后确实是没有用上。
我们再来看一下后面这段字节码:
标记4到10处新建了一个HashMap对象,11处的指令将对象引用的拷贝存储到一个局部变量中。紧接着,12到16的指令将put()方法用到的HashMap对象以及参数都压入了栈中。最终由17到19的指令来真正地完成方法调用。
这次用到的invoke指令是invokevirtual。这和静态方法调用不同,因为静态方法调用不需要实例对象;这个实例有时又称为接收者对象(receiver object)。(在字节码中,调用实例方法必须先在栈上设置好接收者对象和调用参数才能发起invoke指令。)由于这里没有用到put()方法的返回值,因此20的指令把它给丢弃了。
21到25的字节码看上去会有点奇怪:
在前面标记4处创建了一个HashMap实例,并由11处的指令存储到了局部变量3中,现在又被重新加载回栈上,将其引用复制了一份存储到局部变量4中。这个完成后会把引用从栈上删除掉,因此在使用它前还得再重新加载一下(从变量4加载,aload 4)。
这个看似左手倒右手的操作是因为在原始的Java代码中创建了一个额外的局部变量(Map类型的m),尽管引用的仍然是原始变量中的同一个对象。这又是一个字节码忠实于源代码的例子。Java之所以采用这么一种“愚笨的字节码”,主要原因之一就是为了能够给JVM的JIT编译器提供一个尽可能简单的输入格式。
变量加载到栈上后,26到29的指令负责把要存到Map中的值加载进来。现在接收者对象和参数都已经在栈上准备好了,30指令会去分派call()方法的调用。这回用的invoke指令是invokeinterface——尽管调用的仍是同一个方法。返回值仍会通过35的指令pop给丢弃掉。
目前为此,我们已经看到,Java编译器会根据不同的调用上下文来生成invokestatic, invokevirtual, 或者invokeinterface指令。
JVM中方法调用的字节码
我们来看下JVM中能够用来进行方法调用的五种字节码(表一)。字节b0和b1会组成起来,代表常量池中的c1。
可以通过编写Java代码、然后使用javap反编译class文件,来帮助我们了解不同的情况下会生成什么样的字节码。
虚函数表及方法重载
最常见的方法调用类型是invokevirtual,它对应的是虚分派(virtual dispatch)。虚分派这个术语意味着调用的方法要在运行时才能确定。要理解这点,需要知道的是当应用程序在运行时,每一个类在JVM中都会有一块内存来存储与该类型相关的元数据。这块区域又被称为klass(至少在HotSpot VM中是这样的),可以认为它是该类型在JVM中的呈现方式。
在Java 7以及更早的版本中,klass元信息是存储在一块叫持久代的堆空间中的。由于Java堆中的对象一定会有一个对象头(称为oop),klass又被称为klassOops。在Java 8和Java 9中,klass元数据从Java堆中被移到了本地堆中,因此便不再需要对象头了。对Java开发人员来说,klass中的一些信息可以通过对应类型的Class<?>对象来获取到——不过它们是两个不同的概念。
klass中最重要的一块区域叫做虚函数表(vtable)。本质上来说它是一个函数指针表,会指向这个类型所定义的方法的具体实现。当你通过invokevirtual来调用一个实例方法时,JVM会咨询虚函数表来看究竟要执行哪段代码。如果klass中没有这个方法的定义,JVM会通过klass指向父类的指针来继续查找。
这个过程就是JVM中方法重写(override)的基本思路。为了使得这个过程更加高效,虚函数表的排列方式比较特殊。每个klass都会把父类中定义过的方法放在虚函数表的起始处。并且这些方法的排列顺序和父类是严格一致的。而这个类型所新增的独有方法会放在虚函数表的尾部。
这意味着当一个子类重写了父类方法时,它在虚函数表中的位置和被重写的方法是一样的。这样查找重写的方法就非常简单了,因为它俩在虚函数表中的位置完全一致。
图一的例子中包含类Pet,Cat, Bear以及接口Furry。
Java 7中虚函数表的排列如图二所示。可以看到,图中展示的是拥有持久代的Java 7中的视图,因此这里是klassOops,并且有两个字长的对象头(在图中是m和kk)。如前所述,Java 8和Java 9中是没有这几项的,但其余的东西都一样。
如果调用的是Cat::feed方法,JVM在Cat类中没有找到重写的实现,因此它会顺着指针去找Pet的klass。而它实现了feed()方法,因此会调到到它的代码。因为Java只支持简单继承,因此这样的虚函数表结构是没有问题的。这意味着一个类型只能有一个直接的父类(除了Object,它是没有父类的)。
但invokeinterface就会有一些复杂了。比方说,groom()方法在虚表中的实现的位置就不是固定的了。 Cat::groom和Bear::groom的偏移量不同是由于它们的类继承结构是不同的。一个编译时只知道接口类型的对象,当调用它上面的方法时,就会增加额外的查找工作。
注意的是尽管接口调口会有一些额外开销,但也不要为了做一些无谓的优化而不去使用接口。别忘了JVM还有JIT编译器,这种性能上的差别就由它来消除就好了。
方法调用的例子
我们再来看另一个例子。看下这段代码:
Cat tom = new Cat();
Bear pooh = new Bear();
Furry f;
tom.groom();
pooh.groom();
f = tom;
f.groom();
f = pooh;
f.groom();
生成的字节码如下:
27和35处的两次调用看起来是一样的,但实际上是两个不同的方法。27调用的是Cat::groom,而35处是Bear::groom。
了解完invokevirtual和invokeinterface,invokespecial就好理解一些了。如果一个方法是通过invokespecial来调用的,那它一定没进行虚方法查找。JVM只会在虚表中的固定位置来查找请求的方法。invokespecial有三种使用场景:私有方法,父类方法,构造方法(在字节码中这个被转成一个叫
final方法
再说一个不太常见的情况:final方法。看起来final方法应该归到invokespecial这类。但是Java语言规范中的13.4.17节中有提到:“将一个方法从final改成非final不能破坏已发布的二进制程序的兼容性”。
假设下编译器已经把一个final方法的调用编译成了一条invokespecial指令。如果这个方法又改成了非final的话,那它就有可能被子类重写。再假设这个时间有一个子类的实例被传入到编译完的代码中。那么仍然执行invokespecial指令的话,就会调用到错误的实现。这和Java的面向对象的设计原则是有冲突的(严格来说,这是违反了里氏替换原则)。
因此final方法的调用只能编译成invokevirtual指令。在实践中Java HotSpot VM会有一些优化能够检测出final方法并执行得更高效一些。
结论
本文我们学习了JVM支持的5种调用指令中的4种。还有一个没提到的是invokedynamic,这个指令非常有趣,包含的内容也很丰富,需要一篇文章来单独介绍。本文的姊妹篇就将会专门介绍下这个指令以及相关的一些主题。