本文主要参考自 深入理解Java虚拟机
概述
Java能够做到“一处编译,处处运行”,这与.class文件的作用是密不可分的。无论在什么环境中将Java源文件编译为.class文件,都能够通过JVM执行。本篇文章就主要去讲述虚拟机方法的调用 。
JVM中的虚拟机执行引擎与常见物理执行引擎虽然都具有代码执行能力,但实际上它们并不是完全相同的,其区别主要是:物理机执行引擎直接建立在处理器、硬件、指令集和操作系上,而虚拟机的执行引擎则是由自己实现的,因此可以自行定制指令集(指令集信息请参见Java虚拟机规范)与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令格式。在不同的虚拟机实现里,执行引擎在执行Java代码的时候可能有解释执行和编译执行两种选择(详情请参见之后的文章)。
栈帧
栈帧是指用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机栈(线程私有)中的栈元素,其中存储了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息(详情请参见Java虚拟机规范关于.class文件部分内容),因此一个栈帧需要分配多少内存,不会受到运行时的状态影响,只与具体虚拟机的实现有关。
一个线程中的方法调用链一般都会很长,很多方法都处于执行状态。然而对于执行引擎来讲,活动线程中只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法成为当前方法。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译过程中,局部变量表的最大值就被写入了Code属性中的max_locals数据项中。
局部变量表的容量以Slot为最小单位,在Java虚拟机规范中并未规定Slot应占的内存空间大小,只是要求其能存放boolean、byte、char、short、int、float、reference或returnAddress类型的数据,它允许Slot的长度随处理器、操作系统或虚拟机的不同而发生变化(如果在64位虚拟机中使用了64位长度空间实现Slot,则要进行对齐和补白)。不过这里不需要考虑long和double两种类型在多线程模式下的内存撕裂现象(栈帧建立在当前线程上,属于线程的私有数据)。对于局部变量表来说,需要注意的主要有以下几点:
- 在调用非类方法时,局部变量表默认0为位this指向当前类(这也是类方法无法调用局部变量初步、非类方法的原因);
- 局部变量表可以重用,方法体中定义的变量作用于不一定覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这一个Slot就可以交由其他变量使用(局部变量表是可以作为GC Roots的,手动对不使用的变量赋值null将有利于垃圾回收)。
- 对于类变量即使未赋值也可以使用(准备阶段赋予初始值),但是对于局部变量不赋值是无法使用的。
操作数栈
与局部变量表相同,操作数栈的最大深度也被写入到了Code属性的max_stacks数据项之中。当一个方法刚开始时,此方法的操作数栈为空,在方法的执行过程中,会有各种字节码指令向操作数栈写入和提取内容。
操作数栈中的元素的数据类型必须要与字节码指令的序列严格匹配,这一部分主要通过编译器在字节码生成过程中进行检测,当然在类载入的检验阶段也会对这一部分的内容进行验证,以保证Java程序的正确性。
在传统的栈帧模型中,每一个栈帧相互之间都是完全独立的,但是在实际的JVM实现中会进行一定的优化处理,通过让两个栈帧出现一部分的重叠(调用方法的操作数栈、被调用函数函数的局部变量栈),这样在方法调用时就不需要额外的参数传递了。
动态连接
详情请参见分派部分。
方法返回地址
当一个方法被调用时,有两种方式退出这种方法:
- 正常完成出口:执行引擎遇到了一个方法返回的字节码指令(ireturn),这时候可以将操作数栈的返回值传递给上层的方法调用者;
- 异常完成出口:在方法执行过程中遇到了异常(JVM内部异常,athrow抛出异常),同时在本方法的异常表中未搜索到该异常,则会导致方法退出。
一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址。对于方法异常退出,返回地址则要通过异常处理器来确定,栈帧中一般不会存储这一部分的信息。
总的来说,方法的退出过程实际上就是一个很简单的出栈过程,这一过程一般会执行三步操作:
- 恢复上层方法的局部变量表和操作数栈;
- 将返回值压入调用方法的操作数栈中;
- 调整PC计数器指向后一条指令。
附加信息
在JVM规范中允许不同的虚拟机去增加规范中没有描述的信息到栈帧之中,但这取决于具体虚拟机的实现,对于JVM无法理解的信息,JVM将会对其忽略。
方法调用
方法调用与方法执行不同,方法调用是指通过将.class文件中的符号引用转换为方法在实际运行时内存布局的入口地址(直接引用)而确定调用方法版本过程。
解析调用
在类加载的解析阶段会将一部分的符号引用转化为直接引用。这一过程要求这些方法符合“编译器可知,运行期不可变”的原则,而在JVM中主要有五种方法调用指令字节码:
- invokestatic:调用静态方法;
- invokespecial:调用实例构造器<init>放法、私有方法以及父类方法;
- invokevirtual:调用虚方法;
- invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象;
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后在执行该方法。
其中满足上述原则的是invokestatic和invokespecial字节码,由这两条指令调用的方法都可以在解析阶段解析为方法的直接引用,这类方法统称为非虚方法。不过这里需要注意,被final修饰的非类方法也是一种非虚方法,这是由于它无法被类覆盖没有,其他的版本,所以无需在执行过程中进行多态选择(一般来说,JVM会采用内嵌的方式执行final方法)。
<span id="Dispatcher">分派</span>
解析调用是一个静态的过程,在编译器就完全确定了,在类载入时就会将涉及到的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去执行。而分派调用则可能是静态的也可能是动态的,根据宗量数(方法的调用者和方法的参数统称为宗量)又可分为单分派和多分派,这样一共会有四种情况。
在重载方法中主要看静态类型,静态类型是什么类型,就调用什么类型的参数方法;在重写方法中主要看实际类型,实际类型如果实现了该方法那么就直接调用该方法,如果没有实现,则在继承关系中从低到高搜索有无实现。那么,什么是静态类型和实际类型?
Human man = new Man();
以man这个局部变量的静态类型是Human,而实际类型是Man。当然无论是实际类型还是静态类型都是可以改变的。当静态类型发生变化(强制类型转换)时,对于编译器是可知的,而实际类型变化(对象指向其他对象)时,对于编译器来说是不可知的,只有在运行时才可知。
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
staticDispatcher.sayHello((Man) man);
staticDispatcher.sayHello((Woman) man);
由于重载只涉及到静态类型的选择,测试代码如下:
public class StaticDispatcher {
static class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human human) {
System.out.println("hello human");
}
public void sayHello(Man man) {
System.out.println("hello man");
}
public void sayHello(Woman woman) {
System.out.println("hello woman");
}
public static void main(String[] args) {
StaticDispatcher staticDispatcher = new StaticDispatcher();
Human man = new Man();
staticDispatcher.sayHello(man);
staticDispatcher.sayHello((Man) man);
staticDispatcher.sayHello((Woman) man);
}
}
输出结果如下,这与我们的预期完全相同:
hello human
Exception in thread "main" java.lang.ClassCastException: StaticDispatcher$Man cannot be cast to StaticDispatcher$Woman
hello man
at StaticDispatcher.main(StaticDispatcher.java:32)
由此可见,当静态类型发生变化时,会调用相应类型的方法。但是,当将Man强制转化为Woman时,并不会产生编译错误(其实在IDEA中会对这种行为进行警告),却会发生运行时错误。我们再来分析下字节码,了解下编译器完成的工作:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #7 // class StaticDispatcher
3: dup
4: invokespecial #8 // Method "<init>":()V
7: astore_1
8: new #9 // class StaticDispatcher$Man
11: dup
12: invokespecial #10 // Method StaticDispatcher$Man."<init>":()V
15: astore_2
16: aload_1
17: aload_2
18: invokevirtual #11 // Method sayHello:(LStaticDispatcher$Human;)V
21: aload_1
22: aload_2
23: checkcast #9 // class StaticDispatcher$Man
26: invokevirtual #12 // Method sayHello:(LStaticDispatcher$Man;)V
29: aload_1
30: aload_2
31: checkcast #13 // class StaticDispatcher$Woman
34: invokevirtual #14 // Method sayHello:(LStaticDispatcher$Woman;)V
37: return
LineNumberTable:
line 27: 0
line 28: 8
line 29: 16
line 30: 21
line 31: 29
line 32: 37
LocalVariableTable:
Start Length Slot Name Signature
0 38 0 args [Ljava/lang/String;
8 30 1 staticDispatcher LStaticDispatcher;
16 22 2 man LStaticDispatcher$Human;
}
以上是主函数中的字节码,其中对于dup指令进行一下特殊说明。dup指令是复制栈顶的应用并将之压入栈。在我们的源码中有 Human man = new Man();,在字节码上我们通过new指令创建这个类的内存空间,并将一个指向该内存地址的引用压入栈,需要注意到的是在这一过程中,还未进行实例的初始化。然后调用实例初始化函数(这一过程需要传递一个指向这个对象的引用),那么现在操作数栈中只剩下一个指向实例的指针,通常情况下还需要将这个指针的地址存储到局部变量表中,所以Java编译器会自动在new之后添加dup指令。即使不会对局部变量赋值,编译器也会增加这一指令,然后通过pop命令出栈。
通过分字节码的分析,我们发现在强制类型转换过程中,会有checkcast调用,而且invokevirtual指令也变为了转换后的静态转换的类型。因此,编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的
Java中存在类型转换,这带来了一些意思的特性,在重载时,如果没有一个完全匹配的函数可以调用,那么将会发生类型转换,假设我们的参数类型为char,那么我们重载的优先级为:
- char
- int
- long
- float
- double
- Character(不会发生自动类型转换)
- Serializable
- Object
其他类型的基本类型的优先级,除了自动装箱过程,与这个大致相同,在此就不在赘述了。
关于重载大概就先说到这里,再说说重写,还是以一个实例开始:
public class DynamicDispatcher {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("Man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("Woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
输出结果为:
Man say hello
Woman say hello
Woman say hello
再来看看主函数中的字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class DynamicDispatcher$Man
3: dup
4: invokespecial #3 // Method DynamicDispatcher$Man."<init>":()V
7: astore_1
8: new #4 // class DynamicDispatcher$Woman
11: dup
12: invokespecial #5 // Method DynamicDispatcher$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method DynamicDispatcher$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method DynamicDispatcher$Human.sayHello:()V
24: new #4 // class DynamicDispatcher$Woman
27: dup
28: invokespecial #5 // Method DynamicDispatcher$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method DynamicDispatcher$Human.sayHello:()V
36: return
LineNumberTable:
line 26: 0
line 27: 8
line 28: 16
line 29: 20
line 30: 24
line 31: 32
line 32: 36
LocalVariableTable:
Start Length Slot Name Signature
0 37 0 args [Ljava/lang/String;
8 29 1 man LDynamicDispatcher$Human;
16 21 2 woman LDynamicDispatcher$Human;
}
从字节码中,我们可以看到实际上在重写调用时,执行的都是同一条字节码:
invokevirtual #6 // Method DynamicDispatcher$Human.sayHello:()V
但在执行时确是不同的方法。这是由于编译器只知道对象的静态类型,而不知道实际类型,所以在.class文件中只能够确定要调用的静态类型的方法,在执行过程中JVM会根据实际类型去调用方法。如果实际类型实现了这种方法,那么就调用此方法,如果没有实现,那么按照继承关系逐级从下向上检索,检索到编直接调用,如果一直未检索到,那么就抛出异常(此时应该无法通过编译)。
这里需要注意到一些小的问题,如果父类和父接口存在相同的方法会怎么样,举一个小例子:
public class Test {
public static void main(String[] args) {
Inf inf = new Sub();
inf.f();
}
}
class Super {
public void f() {
System.out.println("Super f()");
}
}
interface Inf {
void f();
}
class Sub extends Super implements Inf {
}
输出结果为:
Super f()
当集成父类并且同时实现接口的时候,可以不重写接口方法,此时测试会执行父类的f()方法。但是如果对这段代码进行小小的修改,那么编译器将会报错:
class Super {
public void f() throws IOException {
System.out.println("Super f()");
}
}
interface Inf {
void f();
}
class Sub extends Super implements Inf {
}
在这种情况下,我们可以看到编译器的报错信息为:
Error:(23, 1) java: Super中的f()无法实现Inf中的f()
被覆盖的方法未抛出java.io.IOException
通过这种方式,我们可以发现实际上是父类覆盖了父接口中的方法,这是一个我在写代码的过程中发现到的一个比较有意思的地方。
前文中提到过单分派和多分派,顾名思义,单分派是指根据一个宗量就可以知道调用目标,而多分派则需要根据多个宗量才能确定调用目标。我们写一段很简单的代码来帮助我们认识这一过程:
public class Dispatcher {
static class QQ {
}
static class _360 {
}
public static class Father {
public void hardChoice(QQ arg) {
System.out.println("father choose QQ");
}
public void hardChoice(_360 arg) {
System.out.println("father choose _360");
}
}
public static class Son extends Father {
@Override
public void hardChoice(QQ arg) {
System.out.println("son choose QQ");
}
@Override
public void hardChoice(_360 arg) {
System.out.println("son choose 360");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}
输出结果为:
father choose _360
son choose QQ
我们再看下主函数的字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: new #2 // class Dispatcher$Father
3: dup
4: invokespecial #3 // Method Dispatcher$Father."<init>":()V
7: astore_1
8: new #4 // class Dispatcher$Son
11: dup
12: invokespecial #5 // Method Dispatcher$Son."<init>":()V
15: astore_2
16: aload_1
17: new #6 // class Dispatcher$_360
20: dup
21: invokespecial #7 // Method Dispatcher$_360."<init>":()V
24: invokevirtual #8 // Method Dispatcher$Father.hardChoice:(LDispatcher$_360;)V
27: aload_2
28: new #9 // class Dispatcher$QQ
31: dup
32: invokespecial #10 // Method Dispatcher$QQ."<init>":()V
35: invokevirtual #11 // Method Dispatcher$Father.hardChoice:(LDispatcher$QQ;)V
38: return
LineNumberTable:
line 34: 0
line 35: 8
line 36: 16
line 37: 27
line 38: 38
LocalVariableTable:
Start Length Slot Name Signature
0 39 0 args [Ljava/lang/String;
8 31 1 father LDispatcher$Father;
16 23 2 son LDispatcher$Father;
}
在编译过程中,静态分派将会根据两个参数进行方法选择,一是静态类型是Father还是Son、,二是方法参数是QQ还是_360,所以静态分派是属于多分派的。在执行过程中,虚拟机不会关心传递过来的参数是什么,只会考虑方法的接受者是Father还是Son,进行方法调用,所以Java语言中动态分派是属于单分派的。
在.class文件中会存储一种比较特殊内容,方法表。与其他内容相似,这一信息也被存储在方法区。这里需要注意一点,在方法区中存储的是类型信息(RTTI),它与常见的在堆上存储的class类不同,它在运行时用于识别一个对象的类型(传统:编译时期知道所有类型;反射:在运行时返现和实用类信息)。
方法表中存放着该类定义的所有方法指向方法代码的指针。这些方法中包括从父类继承的所有方法以及自身重写的方法。
在解析过程中,父类方法总是先于子类方法得到解析(详情请参见JVM类加载机制),所以父类方法一定位于子类方法表前列,而父接口不能保证这一特性,它只是在调用时才发生解析(这也是调用虚方法和调用接口方法的字节码不同的主要原因)。我们以IBM的多态在 Java 和 C++ 编程语言中的实现比较为例,对方法表进行简单的了解:
class Person {
public String toString() {
return "I'm a person.";
}
public void eat() {
}
public void speak() {
}
}
class Boy extends Person {
public String toString() {
return "I'm a boy";
}
public void speak() {
}
public void fight() {
}
}
class Girl extends Person {
public String toString() {
return "I'm a girl";
}
public void speak() {
}
public void sing() {
}
}
正如我们前文中所说的,对于这个例子来说在Person中的方法表它肯定是以Object方法表中的方法开始,而对于Girl和Boy两个类型它们则是以Person中方法开始的(包括从Person父类中继承到的方法),所以Person或Object中的任一方法,在它们的方法表中与其子类方法表中的偏移值是完全相同的。这样只需要通过指定调用方法表中的第几个方法便可以成功调用方法。
在通过一个小例子讲述下调用过程:
Person girl = new Girl();
girl.speak();
我们再来看一下这两句源代码对应的字节码:
0: new #2 // class Girl
3: dup
4: invokespecial #3 // Method Girl."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method Person.speak:()V
这一过程跟我们上述的大致相同(IBM文章的图跟我在使用时的常量池不大一样,不过不影响理解,以下以图中过程为准)。首先JVM查看常量池中索引为12的条目(应为CONSTANT_Methodref_info类型,可视为方法调用的符号引用),进一步查看常量池(CONSTANT_Class_info,CONSTANT_NameAndType_info ,CONSTANT_Utf8_info)可得出要调用的方法为Person中的speak方法,查看Person中的方法表可知speak的方法在方法表中的偏移量为15,这就是方法调用的直接引用。
当解析出方法调用的直接引用后,JVM根据实例方法局部变量表中的第一个参数this得到具体的调用对象,并根据此得到对应的方法表,进而调用方法表中的某个偏移量所指的方法。
对于接口方法的调用则是采用搜索方法表的形式。JVM首先查看常量池,确定方法调用的符号引用,然后利用this指向的实例得到该实例的方法表,进而搜索方法表来找到合适的方法地址,因此接口的方法调用往往是慢与类方法调用的。
我们需要注意的是,上面我们所讲述的关于多态的分析都只适用于虚方法,对于属性是不存在多态的,属性值只与静态类型相关。
结语
这是一篇关于Java中方法调用的一篇文章,由于篇幅原因,并未涉及到虚拟机在执行过程的操作(在执行过程中涉及到优化)。文章中的内容主要参考自深入理解Java虚拟机,同样也参考了很多优秀的博客,在此就不一一列出了。由于本人的能力有限,文章中会存在一定是错误或者遗漏,如果您发现文章中存在问题,请您与我联系。