《深入理解Java虚拟机》 方法调用 重载 & 重写
Java虚拟机以方法作为最基本的执行单元,而栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。
1. 运行时栈帧
栈帧存储了方法的局部变量表、操作数栈、动态连接和返回地址等信息,在Java代码编译的时候,需要多深的操作数栈就已经确定下来,并写入到方法表的Code属性之中。每一个方法从调用开始到执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
- 局部变量表
局部变量表用于存放方法参数和方法内部定义的局部变量,在编译Class文件时,就已经在方法Code属性max_locals中确定了该方法的局部变量表的最大容量。
局部变量表以槽Slot为单位进行存储,虚拟机规范中并没有明确指出一个Slot应该占用的内存大小,只要能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。
其中reference表示对一个对象实例的引用,一般来说,虚拟机实现至少应该能通过引用做到两件事,一是根据引用能直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用能直接或间接地查找到对象所属类型数据在方法区中地址,否则将无法实现《Java语言规范》中定义的语法约定。并不是所有语言的对象引用都能满足这两点,比如C++语言,默认情况下只能满足第一点,这也是为何C++中无法提供像Java一样的反射机制。
为了尽可能节省栈帧所占用的内存,局部变量表中的Slot被设计成可以重用的。不过这样的设计除了节省栈帧空间外,在某些情况下还会影响垃圾收集的行为。
示例1:
1 | public static void main(String[] args) { |
1 | 0.098: [GC (System.gc()) 66213K->66120K(129024K), 0.0007262 secs] |
示例中,System.gc()
并没有回收掉变量placeholder
所占的内存,这是因为变量placeholder
还处于作用域范围之内,虚拟机自然不敢进行回收。
示例2:
1 | public static void main(String[] args) { |
1 | 0.115: [GC (System.gc()) 66246K->66136K(129024K), 0.0006681 secs] |
将示例改一下,将变量placeholder
的作用域限制到花括号中,这样在System.gc()
时,变量placeholder
将无法再被访问。但是执行GC之后会发现,虚拟机依然没有进行回收。
示例3:
1 | public static void main(String[] args) { |
1 | 0.132: [GC (System.gc()) 66208K->66120K(129024K), 0.0006794 secs] |
再改下示例,在System.gc();
之前增加一个变量赋值int a = 0;
,然后发现内存真的被回收了。所以,placeholder
能否被回收的根本原因是局部变量表中的变量槽是否还有关于placeholder数组对象的引用。
- 操作栈
与局部变量表一样,操作数栈的最大深度也是在编译时被写到Code属性的max_stacks中。
- 动态连接
每个栈帧都包含一个指向运行时常量池中对应方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class的常量池中存在大量的符号引用,字节码中的方法调用指令就是以常量池中的符号引用作为参数,这些符号引用一部分在类加载阶段或第一次使用时会被转化为直接引用,这种转化称为静态解析。另一部分则在每次运行时都进行转化,这部分就称为动态连接。
- 方法返回地址
方法的退出只有两种方式,即遇到返回指令或者有异常抛出,无论采用何种方式,在退出之后,都必须返回到最初方法被调用时的位置,可能也需要在栈帧中保存一些信息,用来帮助恢复它上层调用方法的执行状态。
方法的退出过程想当与将当前栈帧出栈,因此退出时可能的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
2. 方法调用
方法调用阶段唯一的任务就是确定被调用方法的版本,即调用哪一个方法,暂时还未涉及到方法内部的具体运行过程。在程序运行的过程中,方法调用是最普遍、最频繁地操作之一,但在Class的编译过程中并不包含传统语言编译的连接步骤,一切方法调用在Class文件中存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
2.1. 解析
在类加载阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期不可改变。
在Java语言中符号“编译器可知,运行期不可变”要求的,主要有静态方法和私有方法两大类,前者直接与类型关联,后者在外部不可访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
字节码指令集设计了不同的指令,来调用不同类型的方法
- invokevirtual 调用虚方法
- invokespecial 调用实例构造器
()、私有方法、或父类方法 - invokestatic 调用静态方法
- invokeinterface 调用接口方法,会在运行时再确定一个实现该接口的对象
- invokedynamic 区别于前4条调用指令将分派逻辑固化在Java虚拟机内部,invokedynamic将分派逻辑交给用户来决定
只要能被 invokestatic 和 invokespecial 调用的方法,都可以在解析阶段中确定唯一的调用版本,在Java语言中符号这个条件的方法有静态方法、私有方法、实例构造器、父类方法4种,统称为非虚方法,相反,其他方法就称为虚方法。
另外还有一种就是final方法,虽然由于历史设计的原因,final方法是通过 invokevirtual 进行调用,但是因为它无法被覆盖,没有其他版本的可能,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果是唯一的。《Java语言规范》中明确定义了final方法就是一种非虚方法。
解析调用一定是一个静态的过程,在编译期间就完全确定,在类加载阶段就会把涉及的符号引用全部转变为明确的直接引用,而不必延迟到运行期再去完成。
2.2. 分派
2.2.1. 静态分派 & 重载
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是有虚拟机来执行的,这也是有些地方将静态分派归入解析而不是分派的原因。
静态分派的最典型应用就是方法重载,比如下面示例
1 | public class StaticDispatch { |
对于示例中的Human man = new Man();
,我们将Human
称为变量的静态类型,也叫外观类型,而Man
则称为变量的实际类型,也叫运行时类型。静态类型和实际类型在程序中都可能发生变化,区别是静态类型在编译期是可知的,而实际类型的变化结果要在运行期才能确定。
实例中对于方法sayHello
重载版本的选择,完全取决于传入参数的数量和类型。代码中故意定义了两个静态类型相同,而实际类型不同的变量man
和woman
,但编译器在选择重载版本时是根据参数的静态类型而不是实际类型作为依据的,因此都选择了sayHello(Human guy)
作为调用目标,并将方法的符号引用作为 invokevirtual 的参数写到字节码中。
要注意的是,Javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,这时往往只能确定一个“相对更合适”的版本。
2.2.2. 动态分配 & 重写
下面看一下Java语言中动态分派的实现,它与Java语言多态性的另外一个重要体现:重写,有着密切的关联,下面还是先看一个与上面相似的示例:
1 | public class StaticDispatch { |
显然,这里对于调用方法版本的选择不再是根据静态类型来决定的,那么虚拟机是如何根据实际类型类分派方法执行的版本呢?这里我们先尝试通过字节码找下答案
1 | public static void main(java.lang.String[]); |
单从调用指令 invokevirtual 及其参数根本无法出来,因为都完全一样,但是这两条指令最终执行的目标方法却不相同,所以还必须从 invokevirtual 指令本身入手。
《Java虚拟机规范》中,invokevirtual 指令的运行时解析过程大致分为以下几步:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C;
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;否则返回 java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程;
- 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMehtodError 异常;
显然,invokevirtual 的第一步就是确定实际类型,所以两次调用时并不是把常量中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们将这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
上述分派过程,只能作为对Java虚拟机概念模型的理解,具体怎样实现则可能因各种虚拟机的不同而有所差别。主要因为动态分派是非常频繁地操作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机出于性能考虑,真正运行时一般不会频繁地去反复搜索类型元数据。
一种基础而且常见的办法是为类型在方法区中建立一个虚方法表,称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表,简称 itable,目的是使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类虚方法表中的入口地址就与父类是一致的,都指向父类的实现入口。如果子类重写了这个方法,那么子类虚方法表中的地址就会被替换为指向子类实现版本的入口地址。
参考:
- Copyright ©《深入理解java虚拟机》