java多态理解和底层实现原理剖析
- 多态怎么理解
- java中方法调用指令
- invokespecial和invokevirtual指令的区别
- invokeinterface指令
- 方法表
- 接口方法调用为什么不能利用方法表快速定位
- 小结
多态怎么理解
抽象事务的多种具体表现,称为事务的多态性。我们在编码过程中通常都是面向接口,面向抽象编程,这其实就利用了多态的好处,帮我们屏蔽了多个子类之间的实现差异。
java中方法调用指令
我们知道c++中可以通过virtual来标注某个函数为虚函数,而在java中,除去静态函数,构造函数,私有函数,final函数,其他的函数都可以看做是虚函数,因为只有虚函数才具有多态性,才需要在运行时进行动态绑定。
Java中的方法大体分为两类: 实例方法和类(静态)方法。
- 实例方法在被调用前,需要一个实例,而类(静态)方法不需要。
- 实例方法使用动态绑定,而类方法使用静态绑定。
当java虚拟机调用一个类方法时,它会基于对象的引用类型来选择需要调用的方法。相反,当虚拟机调用一个实例方法时,它会基于对象的实际类型(运行时确定)来选择调用的方法。
对于类方法调用使用invokestaic指令,而实例方法调用使用invokevirtual指令完成:
操作码 操作数
invokevirtual 实例对象引用(this对象)和方法参数--从调用栈栈中弹出,并为当前调用方法创建一个新的栈帧,然后压入新栈帧的局部变量表中,新栈帧压入虚拟机栈中,作为当前活动栈帧
invokestatic 方法参数
对于构造函数,私有函数和super调用的函数,使用的是invokespecial进行调用:
操作码 操作数
invokespecial 实例对象引用(this对象)和方法参数--实例对象引用(this对象)和方法参数--从调用栈栈中弹出,并为当前调用方法创建一个新的栈帧,然后压入新栈帧的局部变量表中,新栈帧压入虚拟机栈中,作为当前活动栈帧
对于类构造函数< client >而言,java虚拟机总是直接在类初始化时调用类初始化方法,并确保这个过程的是线程安全的,并不会对外提供任何字节码指令来调用类构造方法。
对于接口方法的调用,使用的是invokeinterface方法:
操作码 操作数
invokeinterface 实例对象引用(this对象)和方法参数--实例对象引用(this对象)和方法参数--从调用栈栈中弹出,并为当前调用方法创建一个新的栈帧,然后压入新栈帧的局部变量表中,新栈帧压入虚拟机栈中,作为当前活动栈帧
invokespecial和invokevirtual指令的区别
invokespecial调用时,虚拟机将会按照引用类型来选择调用的方法。而invokevirtual指令执行时,会根据对象实际所属类型来选择调用哪一个方法。
可以简单的理解为虚拟机使用动态绑定来执行invokespecial指令,而对于invokevirtual指令来说,使用的是动态绑定。
invokespecial指令对于super方法的调用,会动态搜寻当前类的超类,找到离得最近的超类中该方法的实现,因此super方法调用是个例外,对于其他情况而言,都采用的是静态绑定。
invokeinterface指令
invokeinterface和invokervirtual指令功能相同: 它调用实例方法时使用动态绑定,这两个指令区别在于:
- 当引用类型为类的时候,使用invokevirtual;
- 当引用类型为接口的时候,使用invokeinterface;
除此之外,当执行invokevirtual指令调用实例方法时,由于符号引用都是懒解析的,所以第一次执行时,将实例方法的符号引用解析为直接引用,所生成的直接引用就是方法表中的一个偏移量,而且从此往后都可以使用同样的偏移量。
而对于invokeinterface指令而言,虚拟机每一次遇到invokeinterface指令,都需要重新搜寻一遍方法表,因为虚拟机不能假设这一次的偏移量与一次相同。
所以接口方法调用会比类方法调用更慢。
方法表
要讲方法表,我们先来简单回顾一下常量池解析过程,常量池解析的核心目的是将符号引用转换为直接引用,对于类型的直接引用可以是简单的指向保存类型数据的方法区中与实现相关的数据结构:
下面给出的是一个用go语言编写的Class数据结构,用于将class文件中类的静态结构映射为内存上类的动态数据结构
type Class struct {
accessFlags uint16
name string // thisClassName
superClassName string
interfaceNames []string
constantPool *ConstantPool
fields []*Field
methods []*Method
sourceFile string
loader *ClassLoader
superClass *Class
interfaces []*Class
instanceSlotCount uint
staticSlotCount uint
staticVars Slots
initStarted bool
jClass *Object
}
类变量的直接引用可以指向方法区中保存的类变量的值:
//注意上面Class类中的staticVars表示的就是类变量
staticVars Slots
类方法的直接引用可以指向方法区中一段数据结构:
//我们完全也可以在Class中再给出一个staticMethods属性,用于指向方法区中类方法元数据信息,同时用于和实例方法区分开来
staticMehthods []*Method
指向实例变量和实例方法的直接引用都是偏移量:
//Class中存储实例变量和实例方法元数据信息
fields []*Field
methods []*Method
这里的关键点在于实例变量和实例方法在数组中的占据的索引位置是不变的 ,例如: 子类继承了某个父类,子类自己的方法表中也是父类方法优先,接着是自己的方法,这样可以确保父类方法在子类和父类方法表中的索引都是一致的。
实例变量同理,例如: CockerSpaniel继承了父类Dog,父类提供了一个wagCount的实例字段,可以看到此时wagCount在父类Dog和子类CockerSpaniel实例字段表中的索引都是1,是一致的:
父类的实例变量优先被存储到子类的实例变量表前部,并且每一个类的实例变量出现顺序和他们在class文件中的出现顺序是一致的。
超类的方法出现在来自子类的方法前,并且方法表中方法指针排序顺序和方法在class文件中出现顺序相同,当然,如果存在子类覆盖父类方法的情况,那么子类覆盖的方法会出现在超类中该方法第一次出现的位置。
方法表中只会存储非私有的实例方法,静态方法不会出现在这里,因为他们是静态绑定的,不需要在方法表的间接指向。私有方法和实例的初始化方法也不需要在这里出现,因为他们也是静态绑定的。只有invokevirtual和invokeinterface指令调用的方法才需要出现在这个方法表中。
下面给出参考书上的一个简单案例:
Dog覆写了Object的toString方法,覆写的方法出现的索引还是和父类toString方法出现的位置保持一致,并且Dog类自己实现的SayHello方法排在方法表末尾。
接口方法调用为什么不能利用方法表快速定位
当通过接口引用来访问实例方法时,符合引用被解析为直接引,但是直接引用不能保证得到方法表的偏移量,因为无法保证是子类自己实现了接口还是超类实现的接口,那么接口方法在方法表中的出现顺序就无法被确定下来。
因此,不管何时java虚拟机从接口引用调用一个方法,它必须搜索对象的类的方法表来找到一个合适的方法,这种调用接口引用的实例方法会比类引用上调用实例方法慢很多。
当然,虚拟机通常会采取优化手段来加速接口方法调用执行过程,例如: 缓存第一次查找得到的方法索引等,但是,总的来说,接口方法调用还是会比类实例方法调用慢很多。
小结
java中多态是通过动态绑定实现的,动态绑定是通过invokeVirtual指令和invokeInterface指令实现,这两条指令执行时,都会根据当前实际调用对象类型去查找方法,区别在于invokeVirtual可以通过方法偏移量快速在方法表中定位方法,而invokeInterface则需要每次扫描方法表进行寻找。