轉自:http://www.jianshu.com/p/56a7c4b26b14
前言
Java具備三種特性:封裝、繼承、多態。
Java文件在編譯過程中不會進行傳統編譯的連接步驟,方法調用的目標方法以符號引用的方式存儲在Class文件中,這種多態特性給Java帶來了更靈活的擴展能力,但也使得方法調用變得相對複雜,需要在類加載期間,甚至到運行期間才能確定目標方法的直接引用。
方法調用
所有方法調用的目標方法在Class文件裏面都是常量池中的符號引用。在類加載的解析階段,如果一個方法在運行之前有確定的調用版本,且在運行期間不變,虛擬機會將其符號引用解析爲直接調用。
這種 編譯期可知,運行期不可變 的方法,主要包括靜態方法和私有方法兩大類,前者與具體類直接關聯,後者在外部不可訪問,兩者都不能通過繼承或別的方式進行重寫。
JVM提供瞭如下方法調用字節碼指令:
- invokestatic:調用靜態方法;
- invokespecial:調用實例構造方法<init>,私有方法和父類方法;
- invokevirtual:調用虛方法;
- invokeinterface:調用接口方法,在運行時再確定一個實現此接口的對象;
- invokedynamic:在運行時動態解析出調用點限定符所引用的方法之後,調用該方法;
通過invokestatic和invokespecial指令調用的方法,可以在解析階段確定唯一的調用版本,符合這種條件的有靜態方法、私有方法、實例構造器和父類方法4種,它們在類加載時會把符號引用解析爲該方法的直接引用。
invokestatic
class StaticTest {
public static void hello() {
System.out.println("hello");
}
public static void main(String args[]) {
hello();
}
}
通過javap命令查看main方法字節碼
可以發現hello方法是通過invokestatic指令調用的。
invokespecial
class VirtualTest {
private int id;
public static void main(String args[]) {
new VirtualTest();
}
}
通過javap命令查看main方法字節碼
可以發現實例構造器是通過invokespecial指令調用的。
通過invokestatic和invokespecial指令調用的方法,可以稱爲非虛方法,其餘情況稱爲虛方法,不過有一個特例,即被final關鍵字修飾的方法,雖然使用invokevirtual指令調用,由於它無法被覆蓋重寫,所以也是一種非虛方法。
非虛方法的調用是一個靜態的過程,由於目標方法只有一個確定的版本,所以在類加載的解析階段就可以把符合引用解析爲直接引用,而虛方法的調用是一個分派的過程,有靜態也有動態,可分爲靜態單分派、靜態多分派、動態單分派和動態多分派。
靜態分派
靜態分派發生在代碼的編譯階段。
public class StaticDispatch {
static abstract class Humnan {}
static class Man extends Humnan {}
static class Woman extends Humnan {}
public void hello(Humnan guy) {
System.out.println("hello, Humnan");
}
public void hello(Man guy) {
System.out.println("hello, Man");
}
public void hello(Woman guy) {
System.out.println("hello, Woman");
}
public static void main(String[] args) {
Humnan man = new Man();
Humnan woman = new Woman();
StaticDispatch dispatch = new StaticDispatch();
dispatch.hello(man);
dispatch.hello(woman);
}
}
運行結果:
hello, Humnan
hello, Humnan
相信有經驗的同學看完代碼後就能得出正確的結果,但爲什麼會這樣呢?先看看main方法的字節碼指令
通過字節碼指令,可以發現兩次hello方法都是通過invokevirtual指令進行調用,而且調用的是參數爲Human類型的hello方法。
Humnan man = new Man();
上述代碼中,變量man擁有兩個類型,一個靜態類型Human,一個實際類型Man,靜態類型在編譯期間可知。
在編譯階段,Java編譯器會根據參數的靜態類型決定調用哪個重載版本,但在有些情況下,重載的版本不是唯一的,這樣只能選擇一個“更加合適的版本”進行調用,所以不建議在實際項目中使用這種模糊的方法重載。
動態分派
在運行期間根據參數的實際類型確定方法執行版本的過程稱爲動態分派,動態分派和多態性中的重寫(override)有着緊密的聯繫。
public class DynamicDispatch {
static abstract class Humnan {
abstract void say();
}
static class Man extends Humnan {
@Override
void say() {
System.out.println("hello, i'm Man");
}
}
static class Woman extends Humnan {
@Override
void say() {
System.out.println("hello, i'm Woman");
}
}
public static void main(String[] args) {
Humnan man = new Man();
Humnan woman = new Woman();
man.say();
woman.say();
}
}
運行結果:
hello, i'm Man
hello, i'm Woman
對於習慣了面向對象思維的同學對於這個結果應該是理所當然的。這種情況下,顯然不能再根據靜態類型來決定方法的調用了,導致不同輸出結果的原因很簡單,man和woman的實際類型不同,但是JVM如何根據實際類型決定需要調用哪個方法?
main方法的字節碼指令
- 字節碼0 ~ 15行對應以下代碼:
在Java堆上申請內存空間和實例化對象,並將這兩個實例的引用分別存放到局部變量表的第1、2位置的Slot中。Humnan man = new Man(); Humnan woman = new Woman();
- 字節碼16~21行對應以下代碼:
16和20行指令分別把之前存放到局部變量表1、2位置的對象引用壓入操作數棧的棧頂,這兩個對象是執行say方法的接收者(Receiver),17和21行指令進行方法調用。man.say(); woman.say();
可以發現,17和21兩條指令完全一樣,但最終執行的目標方法卻不相同,這得從invokevirtual指令的多態查找說起了,invokevirtual指令在運行時分爲以下幾個步驟:
- 找到操作數棧的棧頂元素所指向的對象的實際類型,記爲C;
- 如果C中存在描述符和簡單名稱都相符的方法,則進行訪問權限驗證,如果驗證通過,則直接返回這個方法的直接引用,否則返回java.lang.IllegalAccessError異常;
- 如果C中不存在對應的方法,則按照繼承關係對C的各個父類進行第2步的操作;
- 如果各個父類也沒對應的方法,則返回異常;
所以上述兩次invokevirtual指令將相同的符號引用解析成了不同對象的直接引用,這個過程就是Java語言中重寫的本質。
JVM動態分派實現
由於動態分派是非常頻繁的動作,因此在虛擬機的實際實現中,會基於性能的考慮,並不會如此頻繁的搜索對應方法,一般會在方法區中建立一個虛方法表,使用虛方法表代替方法查詢以提高性能。
虛方法表在類加載的連接階段進行初始化,存放着各個方法的實際入口地址,如果某個方法在子類中沒有被重寫,那麼子類的虛方法表中該方法的入口地址和父類保持一致。
abstract class Humnan {
abstract void say();
void run() {
System.out.println("Human is run");
}
}
class Man extends Humnan {
@Override
void say() {
System.out.println("hello, i'm Man");
}
@Override
void run() {
System.out.println("Man is run");
}
}
class Woman extends Humnan {
@Override
void say() {
System.out.println("hello, i'm Humnan");
}
}
對應的虛方法表結構
由於在Woman類中沒有重寫run方法,因此在Woman的虛方法表中,run方法直接指向Human實例。
作者:佔小狼
鏈接:http://www.jianshu.com/p/56a7c4b26b14
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。