深入解析多態和方法調用在JVM中的實現
深入解析多態和方法調用在JVM中的實現
1. 什麼是多態
多態(polymorphism)是面向對象編程的三大特性之一,它建立在繼承的基礎之上。在《Java核心技術卷》中這樣定義:
一個對象變數可以指示多種實際類型的現象稱為多態。
在面向對象語言中,多態性允許你將一個子類型的實際對象賦予給一個父類型的變數。在這樣的賦值完成之後,父類變數就可以根據實際賦予它的子類對象的不同,而以不同的方式工作。
在下面的示例中,Son類繼承了Father類並重寫了f()
方法,又將Son類型的對象賦值給Father類型的變數,再用它調用f()
方法,稍微有點Java基礎的程式設計師都知道,此時會使用的是Son類中的f()
,這種重寫就是一種典型的多態的體現。
class Father{
f(){ ... }
}
class Son extends Father{
f(){ ... }
}
// 調用程式碼
Father object = new Son();
object.f();
在一些資料中,也把重載稱為一種多態的表現形式,本文也將重載視為多態的一種進行講解,但這種說法確實尚存爭議。
2. 一些知識準備
2.1 運行時棧幀結構
Java虛擬機規範中,為所有的Java虛擬機位元組碼執行引擎規定了統一的輸入輸出:
- 輸入為位元組碼形式的二進位流。
- 輸出為執行結果。
在解釋運行階段,JVM以方法作為最基本的執行單元,棧幀是用於支援虛擬機進行方法調用和執行的數據結構,每一個方法從調用開始至執行結束的過程,都對應著一個棧幀在虛擬機棧裡面從入棧到出棧的過程。處於棧頂的棧幀就是當前棧幀,對應的方法就是正在運行的當前方法。
在這裡我們以服務解釋方法調用為前提,簡單說明JVM的運行時棧幀結構。
- 局部變數表。用於存放方法參數和方法內部定義的局部變數。
- 操作數棧。一個後入先出的LIFO棧,輔助方法執行中的運算操作。
- 動態連接。動態連接是一個指向運行時常量池中該棧幀所屬方法的引用,指向的顯然是一個符號引用。它的存在主要是支援方法調用過程中的動態連接。
- 方法調用中,符號引用一部分在類載入或者第一次使用時被轉化成直接引用,這種轉化稱為靜態解析。
- 另外一部分符號引用在每一次運行期間都轉化為直接引用,這種轉化稱為動態連接。
- 方法返回地址。
- 正常退出方法時,方法返回地址指向主調方法的PC計數器。
- 異常退出方法時,方法返回地址指向異常處理表。
- 附加資訊。服務於調試、性能收集等等。
2.2 方法調用位元組碼指令
針對不同類型的方法,Java虛擬機支援以下五種方法調用位元組碼指令。
- invokestatic。用於調用靜態方法。
- invokespecial。用於調用實例構造器
<init>()
方法、私有方法和父類中的方法。- 在Java11以後,invokespecial已經常常不被用來調用私有方法,詳見下文的實驗和說明。
- invokevirtual。用於調用所有的虛方法。
- invokeinterface。用於調用介面方法。在運行時確定實現該介面的對象。
- invokedynamic。先在運行時動態解析出調用點限定符所引用的方法,然後執行該方法。
- 詳見《深入理解Java虛擬機》p321
非虛方法指那些能夠在解析階段確定唯一的調用版本的方法,即上面由invokestatic
和invokespecial
調用的那些方法。而其他那些屬於類的,需要在運行時動態確定調用版本的方法,我們稱之為虛方法,最常見的虛方法就是普通的實例方法。
下面我們用位元組碼的形式看看這些方法調用指令。
// Java程式碼
public class Test {
public static void staticMethod() {
System.out.println("static method");
}
private void privateMethod() {
System.out.println("private method");
}
public static void main(String[] args) {
Test.staticMethod();
new Test().privateMethod();
}
}
javac Test.java
javap -verbose Test
// javap工具得到的main部分的位元組碼文件
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: invokestatic #23 // Method staticMethod:()V
3: new #24 // class Test
6: dup
7: invokespecial #28 // Method "<init>":()V
10: invokevirtual #29 // Method privateMethod:()V
13: return
LineNumberTable:
line 12: 0
line 14: 3
line 15: 13
在上面的程式碼中,我們顯然可以看到,staticMethod
使用invokestatic
來進行調用,"<init>"
構造方法使用了invokespecial
來調用,這些都符合上面的約定。
但是!作為私有方法的privateMethod
方法,卻在位元組碼中被編譯為使用invokevirtrual
指令來調用。這是為什麼呢?
筆者查閱資料後,發現在JEP181中,對方法調用位元組碼指令進行了一定程度上的修改。在Java11版本及以後,嵌套類之間的私有方法的訪問許可權控制,就從編譯期轉移到了運行時,從而這樣的私有方法也被使用invokevirtual
指令來調用,
總而言之,在Java11及以後,類中的私有方法往往用invokevirtual
來調用,介面中的私有方法往往用invokeinterface
調用,invokespecial
往往僅用於實例構造器方法和父類中的方法。
2.3 位元組碼方法解析過程
解析過程是JVM將常量池內的符號引用替換為直接引用的過程。
- 符號引用以一組符號來描述所引用的目標,符號可以是任意形式的字面量,只要使用時能無歧義地定位到目標即可。
- 直接引用是可以直接指向目標的指針、相對偏移量或一個能間接定位到目標的句柄。
《Java虛擬機規範》中明確要求在執行方法調用位元組碼指令之前,必須先對它們使用的符號引用進行解析。即所有invoke...
指令之前。由於對同一個符號引用收到多次解析請求是很常見的事,虛擬機實現可以對第一次解析的結果進行快取,譬如在運行時直接引用常量池中的記錄,並把常量標識為已解析狀態,從而避免解析動作重複進行。(invokedynamic有一些特殊性質,這裡不做解釋)。
方法解析第一步需要解析出方法表的class_index
項中索引的方法所屬的類或介面的符號引用,如果解析成功,那麼用C表示這個類,接下來虛擬機將按照以下步驟進行後續的方法搜索。
-
如果我們在解析一個類方法,但C是一個介面,直接拋出
java.lang.IncompatibleClassChangeError
異常。- 如果我們在解析的是介面方法,但C是一個類,也拋出
java.lang.IncompatibleClassChangeError
異常。
- 如果我們在解析的是介面方法,但C是一個類,也拋出
-
如果通過了第一步,在C中查找是否有簡單名稱和描述符都與目標匹配的方法,有則返回直接引用。
-
否則,依次在C的父類、介面列表、父介面中進行查找。如果找到則根據情況返回直接引用或者拋出
java.lang.AbstractMethodError
異常。 -
如果都找不到,說明方法查找失敗。拋出
java.lang.NoSuchMethodError
。 -
最後,如果成功返回了直接引用,就對這個方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,則拋出
java.lang.IllegalAccessError
異常。
2.4 靜態類型和實際類型
已知有類Father
、Son
,且Son
類繼承了Father
類。假設我們以以下方式初始化變數。
class Father{}
class Son extends Father{}
Father object = new Son();
那我們把上面程式碼中的Father
稱為變數object的靜態類型或外觀類型,將Son
稱為object的實際類型或運行時類型。
當變數被定義的時候,它的靜態類型就已經確定,而實際類型可能會在運行過程中不斷變化,例如下面給出一個例子。
class Father{}
class Son extends Father{}
class Daughter extends Father{}
Father object = new Random().nextBoolean() ? new Son() : new Daughter();
這個例子中,object的靜態類型始終是Father
,而實際類型就只有到運行時才知道了。
3.方法調用
3.1 解析
非虛方法,即使用invokespecial
和invokestatic
指令調用的方法,由於無法被覆蓋,不可能存在其他版本,所以可以在類載入的解析階段直接進行方法解析,將符號引用全部轉變為明確的直接引用,不必延遲到運行期完成。
解析調用一定是一個靜態的過程,在編譯期間就完全確定。
值得說明的一點是,《Java虛擬機規範》明確地將final方法定義為非虛方法,但final方法是使用invokevirtual
調用的,故使用下面講的分派機制,而非解析。
3.2 靜態分派
靜態分派用於解釋重載的場景,下面給出一個簡單的例子
public class Test {
public void overLoad(Father father){
System.out.println("get father method");
}
public void overLoad(Son father){
System.out.println("get son method");
}
public static void main(String[] args) {
Test test = new Test();
Father object = new Son();
test.overLoad(object);
}
}
class Father{}
class Son extends Father{}
//運行結果
get father method
顯然,JVM選擇了參數類型為Father的重載方法。
在虛擬機處理重載的情況時,是通過參數的靜態類型而不是實際類型作為判斷依據的。由於靜態類型在編譯期可知,所以在編譯階段Javac編譯器就根據參數的靜態類型決定了會使用哪個重載版本。比如上面會選擇overload(Father)
作為調用目標,並把這個方法的符號引用寫入到main()
方法的invokevirtual
指令的參數中,後續在解釋階段執行invokevirtual
時,這個選好的方法就會直接被使用。這個操作是在Javac前端編譯的語法分析階段直接完成的。
值得注意的是Javac編譯器確定的重載版本並非確定的某一個,而是在現有的選擇中選擇的「最合適的」一個。下面給出一個示例。
public class Overload {
// 從上到下,優先順序遞減
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void sayHello(char... arg) {
System.out.println("hello char ...");
}
public static void main(String[] args) {
sayHello('a');
}
}
假如按照上面的程式碼運行,那麼會被調用的是sayHello(char arg)
方法,這就是Javac認為的最合適的方法。但假如我們將sayHello(char arg)
注釋掉,那麼會被調用的是sayHello(int arg)
方法,以此類推。
當然,一個腦子正常的程式設計師,不應該在自己的任何工程中寫出上述這樣的重載程式碼。
3.3 動態分派
靜態分派用於解釋重寫的場景,下面給出一個簡單的例子
public class Test {
public static void main(String[] args) {
Father object = new Son();
object.override();
}
}
class Father{
public void override(){
System.out.println("get father method");
}
}
class Son extends Father{
public void override(){
System.out.println("get son method");
}
}
//運行結果
get son method
顯然,JVM選擇了子類Son的重寫方法。顯然,在進行動態分派的時候,選擇方法的依據是調用方法的變數的實際類型。為了解釋清楚invokevirtual
的作用方式,我們使用javap
命令輸出這段程式碼中main
部分的位元組碼。
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #7 // class Son
3: dup
4: invokespecial #9 // Method Son."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #10 // Method Father.override:()V
12: return
LineNumberTable:
line 3: 0
line 5: 8
line 6: 12
0 ~ 7 行的位元組碼是一些準備工作。創建了用於存放變數object
的記憶體空間,調用了對應的構造器,並將對象實例存放在了局部變數表的第一個槽中。實際上對應程式碼中下面這行。
Father object = new Son();
第 8 行 的aload_1
指令將剛剛創建的object
對象引用壓到了操作數棧頂,這個對象即將調用override()
方法。
第 9 行,正式使用了方法調用位元組碼指令invokevirtual
。根據《Java虛擬機規範》,invokevirtual
指令的運行時解析過程分為以下幾步。
- 找到操作數棧頂第一個元素指向的對象的實際類型並記作C。
- 在C中查找是否有簡單名稱和描述符都與目標匹配的方法,有則返回直接引用。
- 這裡所謂的「目標」,是目標方法的簡單外觀,在編譯階段就已經傳遞給
invokevirtual
作為參數
- 這裡所謂的「目標」,是目標方法的簡單外觀,在編譯階段就已經傳遞給
- 否則,依次在C的父類、介面列表、父介面中進行查找。如果找到則根據情況返回直接引用或者拋出
java.lang.AbstractMethodError
異常。 - 如果都找不到,說明方法查找失敗。拋出
java.lang.NoSuchMethodError
。 - 最後,如果成功返回了直接引用,就對這個方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,則拋出
java.lang.IllegalAccessError
異常。
你應該可以看出來,其實就是我們在2.3
節中講的位元組碼方法解析。重點就是我們從操作數棧頂找到了第一個元素指向的實際類型,並用它為基礎來做接下來的方法查找。這種運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
這裡再給出一個示例,幫助讀者更深入地了解動態分派。
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}
// 輸出結果
I am Son, i have $0
I am Son, i have $4
This gay has $2
應該不難理解,第一行的輸出來自父類Father構造器調用子類的showmeTheMoney()
方法,此時子類尚未初始化,所以結果為0。
第二行的輸出來自子類調用showmeTheMoney()
方法,此時子類已經初始化,結果為4。
第三行的輸出,使用gay.money
直接取值,注意這個時候通過靜態類型訪問變數,自然沒有類似invokevirtual
的東西來找所謂的實際類型。所以使用的是變數 gay 的靜態類型,那麼就從Father
類中取值,取到money
的值為2。
所以,動態分派僅限於方法!
4. 知識補充
4.1 單分派與多分派
方法的接收者和方法的參數統稱為方法的宗量。選擇方法時使用一種宗量稱為單分派,使用多種宗量稱為多分派。那麼顯而易見的,我們可以總結出Java是一種靜態多分派,動態單分派的語言。
- 靜態多分派:在靜態分派的過程中,即重載的過程中,我們同時將方法的接收者和方法的參數作為選擇方法的依據,所以是多分派。
- 動態單分派:在動態分派的過程中,方法的參數模式在編譯階段就已經確定,唯一動態決定的是方法接收者的實際類型,所以是單分派。
註:方法的接收者指調用方法的對象。如
object.f()
,那麼object就是方法的接收者。
4.2 虛擬機動態分派的優化實現
我們可以想見的是,在程式碼運行過程中,一個虛方法可能會被大量多次地調用。所以一種在現代JVM中常見的優化手段是創建一個虛方法表,同理對於invokeinterface
指令,也有介面方法表,它們的結構如下所示。
虛方法表中存放的是各種方法的實際入口地址。如果父類的方法在子類中沒有重寫,那麼子類虛方法表中的地址入口和父類虛方法表中的入口地址是一致的,都指向父類的實現。否則子類的地址入口就會指向自己的實現。這樣可以節省大量的,動態分派過程中搜索方法的開銷。
同時要求在父類和子類的虛方法表中,具有相同簽名的方法應該具有相同的索引序號,這樣當類型動態發生變化的時候,只需要動態改變要查找的虛方法表,而不需要重新考慮在表中的位置。
虛方法表一般在類載入的連接階段進行初始化,準備了類的變數初始值後,虛擬機就會為該類的虛方法表進行初始化。
4.2 虛方法的方法內聯
方法內聯是編譯器最重要的優化手段!簡單說就是把目標程式碼以類似複製的方式替換到調用方法的位置,避免發生真實的方法調用。下面是一個示例。
// 內聯前的程式碼
static class C {
int val;
final int get(){
return val;
}
}
public void f(){
C c = new C();
int x = c.get();
int y = c.get();
int sum = x + y;
}
// 內聯後的程式碼
public void f(){
C c = new C();
int x = c.val;
int y = c.val;
int sum = x + y;
}
方法內聯有兩個重要功能
- 去除方法調用的成本,包括查找方法版本和建立棧幀等。
- 為建立其他優化打好基礎。
所以我們稱方法內聯為最重要的優化手段。然而在Java虛擬機中,方法內聯卻有著一些天生的問題存在。對於Java中的虛方法,在將Java程式碼翻譯為位元組碼的編譯階段,很多情況下編譯器根本不可能確定該使用哪個方法版本。而Java作為面向對象的語言,在Java編程中絕大多數的方法都是虛方法,絕大多數的方法調用都是invokevirtual
或invokeinterface
負責的。
但是方法內聯對於優化來說又過於重要,所以Java虛擬機的設計者們想了很多辦法來盡量解決問題。
Java虛擬機引入了一種名為類型繼承關係分析(CHA)的技術,它用於確定在目前已經載入的類中,那些虛方法是否存在多個版本。根據分析結果的不同,Java虛擬機可以採取不同的處理方法。
- 假如只有一個方法,那麼就可以直接進行內聯,即假設整個應用程式也只有這一個版本。這種內聯被稱為守護內聯。當然我們知道,並不是所有的類都被載入,保不齊未來就會有這個方法的新版本出現,所以我們預留好了逃生門,當假設不成立時就通過逃生門拋棄掉已經編譯的程式碼,退回到解釋狀態進行執行,或者重新進行編譯。
- 假如有多個方法版本可供選擇,那麼編譯器會嘗試使用內聯快取的方式來減少方法調用的開銷。內聯快取的基本原理很好理解,就是當方法第一次調用發生後,快取下方法接收者的版本資訊和對應的方法調用點。
- 每次方法調用時都比較接收者的版本,如果版本不變,那麼就是一種單態內聯快取。通過該快取進行調用就解除了方法搜索帶來的開銷,而僅僅多了一個比較版本的微小開銷。
- 如果版本發生改變,說明程式用到了虛方法的多態特性,這時候會退化成超多態內聯快取,這裡說是一種內聯快取,其實就是不要快取了,直接正常進行動態分派操作。
- 當快取未命中的時候,大多數JVM的實現時退化成超多態內聯快取,也有一些JVM選擇重寫單態內聯快取,就是更新快取為新的版本。這樣做的好處是以後還可能會命中,壞處是可能白白浪費一個寫的開銷。