眾所周知,多態(tài)是面向?qū)ο缶幊陶Z(yǔ)言的重要特性污朽,它允許基類的指針或引用指向派生類的對(duì)象散吵,而在具體訪問(wèn)時(shí)實(shí)現(xiàn)方法的動(dòng)態(tài)綁定。C++ 和 Java 作為當(dāng)前最為流行的兩種面向?qū)ο缶幊陶Z(yǔ)言蟆肆,其內(nèi)部對(duì)于多態(tài)的支持到底是如何實(shí)現(xiàn)的呢矾睦,本文對(duì)此做了全面的介紹。
注意到在本文中炎功,指針和引用會(huì)互換使用枚冗,它們僅是一個(gè)抽象概念,表示和另一個(gè)對(duì)象的連接關(guān)系蛇损,無(wú)須在意其具體的實(shí)現(xiàn)赁温。
Java 的實(shí)現(xiàn)方式
Java 對(duì)于方法調(diào)用動(dòng)態(tài)綁定的實(shí)現(xiàn)主要依賴于方法表,但通過(guò)類引用調(diào)用和接口引用調(diào)用的實(shí)現(xiàn)則有所不同淤齐」赡遥總體而言,當(dāng)某個(gè)方法被調(diào)用時(shí)更啄,JVM 首先要查找相應(yīng)的常量池稚疹,得到方法的符號(hào)引用,并查找調(diào)用類的方法表以確定該方法的直接引用祭务,最后才真正調(diào)用該方法内狗。以下分別對(duì)該過(guò)程中涉及到的相關(guān)部分做詳細(xì)介紹。
JVM 的結(jié)構(gòu)
典型的 Java 虛擬機(jī)的運(yùn)行時(shí)結(jié)構(gòu)如下圖所示
圖 1.JVM 運(yùn)行時(shí)結(jié)構(gòu)
此結(jié)構(gòu)中待牵,我們只探討和本文密切相關(guān)的方法區(qū) (method area)其屏。當(dāng)程序運(yùn)行需要某個(gè)類的定義時(shí)喇勋,載入子系統(tǒng) (class loader subsystem) 裝入所需的 class 文件缨该,并在內(nèi)部建立該類的類型信息,這個(gè)類型信息就存貯在方法區(qū)川背。類型信息一般包括該類的方法代碼贰拿、類變量、成員變量的定義等等熄云∨蚋可以說(shuō),類型信息就是類的 Java 文件在運(yùn)行時(shí)的內(nèi)部結(jié)構(gòu)缴允,包含了該類的所有在 Java 文件中定義的信息荚守。
注意到珍德,該類型信息和 class 對(duì)象是不同的。class 對(duì)象是 JVM 在載入某個(gè)類后于堆 (heap) 中創(chuàng)建的代表該類的對(duì)象矗漾,可以通過(guò)該 class 對(duì)象訪問(wèn)到該類型信息锈候。比如最典型的應(yīng)用,在 Java 反射中應(yīng)用 class 對(duì)象訪問(wèn)到該類支持的所有方法敞贡,定義的成員變量等等泵琳。可以想象誊役,JVM 在類型信息和 class 對(duì)象中維護(hù)著它們彼此的引用以便互相訪問(wèn)获列。兩者的關(guān)系可以類比于進(jìn)程對(duì)象與真正的進(jìn)程之間的關(guān)系。
Java 的方法調(diào)用方式
Java 的方法調(diào)用有兩類蛔垢,動(dòng)態(tài)方法調(diào)用與靜態(tài)方法調(diào)用击孩。靜態(tài)方法調(diào)用是指對(duì)于類的靜態(tài)方法的調(diào)用方式,是靜態(tài)綁定的鹏漆;而動(dòng)態(tài)方法調(diào)用需要有方法調(diào)用所作用的對(duì)象溯壶,是動(dòng)態(tài)綁定的。類調(diào)用 (invokestatic) 是在編譯時(shí)刻就已經(jīng)確定好具體調(diào)用方法的情況甫男,而實(shí)例調(diào)用 (invokevirtual) 則是在調(diào)用的時(shí)候才確定具體的調(diào)用方法且改,這就是動(dòng)態(tài)綁定,也是多態(tài)要解決的核心問(wèn)題。
JVM 的方法調(diào)用指令有四個(gè),分別是 invokestatic址否,invokespecial胆萧,invokesvirtual 和 invokeinterface。前兩個(gè)是靜態(tài)綁定揪利,后兩個(gè)是動(dòng)態(tài)綁定的。本文也可以說(shuō)是對(duì)于 JVM 后兩種調(diào)用實(shí)現(xiàn)的考察。
常量池(constant pool)
常量池中保存的是一個(gè) Java 類引用的一些常量信息礼烈,包含一些字符串常量及對(duì)于類的符號(hào)引用信息等。Java 代碼編譯生成的類文件中的常量池是靜態(tài)常量池婆跑,當(dāng)類被載入到虛擬機(jī)內(nèi)部的時(shí)候此熬,在內(nèi)存中產(chǎn)生類的常量池叫運(yùn)行時(shí)常量池。
常量池在邏輯上可以分成多個(gè)表滑进,每個(gè)表包含一類的常量信息犀忱,本文只探討對(duì)于 Java 調(diào)用相關(guān)的常量池表。
CONSTANT_Utf8_info
字符串常量表扶关,該表包含該類所使用的所有字符串常量阴汇,比如代碼中的字符串引用、引用的類名节槐、方法的名字搀庶、其他引用的類與方法的字符串描述等等拐纱。其余常量池表中所涉及到的任何常量字符串都被索引至該表。
CONSTANT_Class_info
類信息表哥倔,包含任何被引用的類或接口的符號(hào)引用戳玫,每一個(gè)條目主要包含一個(gè)索引,指向 CONSTANT_Utf8_info 表未斑,表示該類或接口的全限定名咕宿。
CONSTANT_NameAndType_info
名字類型表,包含引用的任意方法或字段的名稱和描述符信息在字符串常量表中的索引蜡秽。
CONSTANT_InterfaceMethodref_info
接口方法引用表府阀,包含引用的任何接口方法的描述信息,主要包括類信息索引和名字類型索引芽突。
CONSTANT_Methodref_info
類方法引用表试浙,包含引用的任何類型方法的描述信息,主要包括類信息索引和名字類型索引寞蚌。
圖 2. 常量池各表的關(guān)系
可以看到田巴,給定任意一個(gè)方法的索引,在常量池中找到對(duì)應(yīng)的條目后挟秤,可以得到該方法的類索引(class_index)和名字類型索引 (name_and_type_index), 進(jìn)而得到該方法所屬的類型信息和名稱及描述符信息(參數(shù)壹哺,返回值等)。注意到所有的常量字符串都是存儲(chǔ)在 CONSTANT_Utf8_info 中供其他表索引的艘刚。
方法表與方法調(diào)用
方法表是動(dòng)態(tài)調(diào)用的核心管宵,也是 Java 實(shí)現(xiàn)動(dòng)態(tài)調(diào)用的主要方式。它被存儲(chǔ)于方法區(qū)中的類型信息攀甚,包含有該類型所定義的所有方法及指向這些方法代碼的指針箩朴,注意這些具體的方法代碼可能是被覆寫的方法,也可能是繼承自基類的方法秋度。
如有類定義 Person, Girl, Boy,
清單 1
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() {
}
}
當(dāng)這三個(gè)類被載入到 Java 虛擬機(jī)之后炸庞,方法區(qū)中就包含了各自的類的信息。Girl 和 Boy 在方法區(qū)中的方法表可表示如下:
圖 3.Boy 和 Girl 的方法表
可以看到荚斯,Girl 和 Boy 的方法表包含繼承自 Object 的方法埠居,繼承自直接父類 Person 的方法及各自新定義的方法。注意方法表?xiàng)l目指向的具體的方法地址鲸拥,如 Girl 的繼承自 Object 的方法中拐格,只有 toString() 指向自己的實(shí)現(xiàn)(Girl 的方法代碼),其余皆指向 Object 的方法代碼刑赶;其繼承自于 Person 的方法 eat() 和 speak() 分別指向 Person 的方法實(shí)現(xiàn)和本身的實(shí)現(xiàn)。
Person 或 Object 的任意一個(gè)方法懂衩,在它們的方法表和其子類 Girl 和 Boy 的方法表中的位置 (index) 是一樣的撞叨。這樣 JVM 在調(diào)用實(shí)例方法其實(shí)只需要指定調(diào)用方法表中的第幾個(gè)方法即可金踪。
如調(diào)用如下:
class Party {
void happyHour() {
Person girl = new Girl();
girl.speak();
}
}
當(dāng)編譯 Party 類的時(shí)候,生成 girl.speak()的方法調(diào)用假設(shè)為:
Invokevirtual #12
設(shè)該調(diào)用代碼對(duì)應(yīng)著 girl.speak(); #12 是 Party 類的常量池的索引牵敷。JVM 執(zhí)行該調(diào)用指令的過(guò)程如下所示:
圖 4. 解析調(diào)用過(guò)程
JVM 首先查看 Party 的常量池索引為 12 的條目(應(yīng)為 CONSTANT_Methodref_info 類型胡岔,可視為方法調(diào)用的符號(hào)引用),進(jìn)一步查看常量池(CONSTANT_Class_info枷餐,CONSTANT_NameAndType_info 靶瘸,CONSTANT_Utf8_info)可得出要調(diào)用的方法是 Person 的 speak 方法(注意引用 girl 是其基類 Person 類型),查看 Person 的方法表毛肋,得出 speak 方法在該方法表中的偏移量 15(offset)怨咪,這就是該方法調(diào)用的直接引用。
當(dāng)解析出方法調(diào)用的直接引用后(方法表偏移量 15)润匙,JVM 執(zhí)行真正的方法調(diào)用:根據(jù)實(shí)例方法調(diào)用的參數(shù) this 得到具體的對(duì)象(即 girl 所指向的位于堆中的對(duì)象)诗眨,據(jù)此得到該對(duì)象對(duì)應(yīng)的方法表 (Girl 的方法表 ),進(jìn)而調(diào)用方法表中的某個(gè)偏移量所指向的方法(Girl 的 speak() 方法的實(shí)現(xiàn))孕讳。
接口調(diào)用
因?yàn)?Java 類是可以同時(shí)實(shí)現(xiàn)多個(gè)接口的匠楚,而當(dāng)用接口引用調(diào)用某個(gè)方法的時(shí)候,情況就有所不同了厂财。Java 允許一個(gè)類實(shí)現(xiàn)多個(gè)接口芋簿,從某種意義上來(lái)說(shuō)相當(dāng)于多繼承,這樣同樣的方法在基類和派生類的方法表的位置就可能不一樣了璃饱。
清單 3
interface IDance {
void dance();
}
class Person {
public String toString() {
return "I'm a person.";
}
public void eat() {
}
public void speak() {
}
}
class Dancer extends Person
implements IDance {
public String toString() {
return "I'm a dancer.";
}
public void dance() {
}
}
class Snake implements IDance {
public String toString() {
return "A snake.";
}
public void dance() {
//snake dance
}
}
圖 5.Dancer 的方法表
可以看到益咬,由于接口的介入,繼承自于接口 IDance 的方法 dance()在類 Dancer 和 Snake 的方法表中的位置已經(jīng)不一樣了帜平,顯然我們無(wú)法通過(guò)給出方法表的偏移量來(lái)正確調(diào)用 Dancer 和 Snake 的這個(gè)方法幽告。這也是 Java 中調(diào)用接口方法有其專有的調(diào)用指令(invokeinterface)的原因。
Java 對(duì)于接口方法的調(diào)用是采用搜索方法表的方式裆甩,對(duì)如下的方法調(diào)用
invokeinterface #13
JVM 首先查看常量池冗锁,確定方法調(diào)用的符號(hào)引用(名稱、返回值等等)嗤栓,然后利用 this 指向的實(shí)例得到該實(shí)例的方法表冻河,進(jìn)而搜索方法表來(lái)找到合適的方法地址。
因?yàn)槊看谓涌谡{(diào)用都要搜索方法表茉帅,所以從效率上來(lái)說(shuō)叨叙,接口方法的調(diào)用總是慢于類方法的調(diào)用的。
C++ 的實(shí)現(xiàn)方式
從上文可以看到堪澎,Java 對(duì)于多態(tài)的實(shí)現(xiàn)依賴于方法表擂错,但比較特殊的是,對(duì)于接口的支持是非常不同的樱蛤,每次調(diào)用都要搜索方法表钮呀。實(shí)際上剑鞍,在 C++ 中,單繼承時(shí)對(duì)于多態(tài)的實(shí)現(xiàn)非常類似于 Java爽醋,但由于支持多重繼承蚁署,這會(huì)碰到和 Java 支持接口動(dòng)態(tài)調(diào)用同樣的問(wèn)題,C++ 的解決方案是利用對(duì)象的多個(gè)方法表指針蚂四,不幸的是光戈,這會(huì)引入額外的指針調(diào)整的復(fù)雜性。
單繼承
單繼承時(shí)遂赠,C++ 對(duì)于多態(tài)的實(shí)現(xiàn)本質(zhì)上與 Java 是一樣的久妆,也是基于方法表。但 C++ 在編譯時(shí)就可以確認(rèn)要調(diào)用的方法在方法表中的位置解愤,而沒(méi)有 JVM 在方法調(diào)用時(shí)查詢常量池的過(guò)程镇饺。
C++ 編譯時(shí),編譯器會(huì)自動(dòng)做很多工作送讲,其中之一就是在需要時(shí)在對(duì)象插入一個(gè)變量 vptr 指向類的方法表奸笤。如 Person,、Girl 的類定義與上文中 Java 類似哼鬓,若
清單 4
class Person{
. . .
public :
Person (){}
virtual ~Person (){};
virtual void speak (){};
virtual void eat (){};
};
class Girl : public Person{
. . .
public :
Girl(){}
virtual ~Girl(){};
virtual void speak(){};
virtual void sing(){};
};
則 Person 與 Girl 實(shí)例的內(nèi)存對(duì)象模型為:
圖 6.Person 與 Girl 的對(duì)象模型
如下的調(diào)用代碼
Person *p = new Girl();
p->speak();
p->eat();
經(jīng)編譯器編譯后調(diào)用代碼為:
p->vptr[1](p);
p->vptr[2](p);
這樣在運(yùn)行時(shí)监右,會(huì)自然的過(guò)渡到對(duì) Girl 的相應(yīng)函數(shù)的調(diào)用。
可以看到方法表中沒(méi)有各自的構(gòu)造函數(shù)异希,這是因?yàn)?C++ 的方法表中僅含有用 virtual 修飾的方法健盒,非 virtual 的方法是靜態(tài)綁定的,沒(méi)有必要占用方法表的空間称簿。這與 Java 是不同的扣癣,Java 的方法表含有類所支持的所有的方法,可以說(shuō)憨降,Java 類的所有方法都是”virtual”(動(dòng)態(tài)綁定)的父虑。
多重繼承
多重繼承下,情況就完全不一樣了授药,因?yàn)閮蓚€(gè)不同的類士嚎,其繼承自與同一個(gè)基類的方法,在各自的方法表中的位置可能不同(和 Java 中的接口情況類似)悔叽,但 Java 在運(yùn)行時(shí)有 JVM 的支持莱衩,C++ 在這里引入了多個(gè)指向方法表的指針來(lái)解決這個(gè)問(wèn)題,由此帶來(lái)了調(diào)整指針位置的額外復(fù)雜性娇澎。
若有如下關(guān)系的三個(gè)類笨蚁,Engineer 繼承自 Person 和 Employee
圖 7. 類靜態(tài)結(jié)構(gòu)關(guān)系圖
Engineer 實(shí)例對(duì)象模型為:
圖 8.Engineer 對(duì)象模型
可以看到 Engineer 實(shí)例有兩個(gè)指向方法表的指針,這是與 Java 大不相同的。
設(shè)有如下的代碼 ,
清單 5
Engineer *p = new Engineer();
Person * p1 = (Person *)p;
Empolyee *p2 = (Employee *)p;
則各指針在運(yùn)行時(shí)分別指向各自的子對(duì)象赚窃,如下所示:
圖 7.Engineer 實(shí)例
C++ 中對(duì)象的指針總是指向?qū)ο蟮钠鹗继幉嵴校缟鲜龃a中岔激,p 是 Engineer 對(duì)象的起始地址勒极,而 p1 指向 p 轉(zhuǎn)型成 Person 子對(duì)象的指針,可以看到實(shí)際上虑鼎,兩者是相等的辱匿;但 Employee 子對(duì)象的指針 p2 則于 p 和 p1 不同,實(shí)際上
p2 = p + sizeof(Person);
p1->eat();
p2->work();
則編譯后生成的調(diào)用代碼為:
*(p1->vptr1[i]) (p1)
*(p2->vptr2[j]) (p2)
某些情況下炫彩,甚至需要將 this 指針調(diào)整到整個(gè)對(duì)象的起始處匾七,如:
delete p2;
析構(gòu)函數(shù)的 this 指針要被調(diào)整到 p 所指向的位置,否則則會(huì)出現(xiàn)內(nèi)存泄漏江兢。設(shè)析構(gòu)函數(shù)在方法表中的位置為 0昨忆,則編譯后為:
*(p2->vptr2[0]) (p)
對(duì)于指針的調(diào)整,編譯器沒(méi)有足夠的知識(shí)在編譯時(shí)刻完成這個(gè)任務(wù)杉允。如上例中邑贴,對(duì)于 p2 所指向的對(duì)象,該對(duì)象類型可能是 Employee 或任何該類的子類 ( 其它的子類如 Teacher 等 )叔磷,編譯器無(wú)法確切的知道 p2 和整個(gè)對(duì)象的初始地址的距離 (offset), 這樣的調(diào)整只能發(fā)生在運(yùn)行時(shí)刻拢驾。
一般有兩種方法來(lái)調(diào)整指針,如下圖:
圖 8. 指針調(diào)整 - 擴(kuò)展方法表
這種方法將指針?biāo)姓{(diào)整的 offset 存儲(chǔ)于方法表的每個(gè)條目中改基,當(dāng)調(diào)用方法表中的方法時(shí)繁疤,首先利用 offset 的值完成指針調(diào)整再做實(shí)際的調(diào)用。缺點(diǎn)顯而易見秕狰,增加了方法表的大小稠腊,而且并不是每個(gè)方法都需要做指針調(diào)整。
圖 9. 指針調(diào)整 -thunk 技術(shù)
這就是所謂的 thunk 技術(shù)鸣哀,方法表的每個(gè)條目指向一小段匯編代碼架忌,這段代碼來(lái)保證做指針調(diào)整和調(diào)用正確的方法,相當(dāng)于加了一層抽象诺舔。
多態(tài)在 Java 和 C++ 中的實(shí)現(xiàn)比較
上文分別對(duì)于多態(tài)在 Java 和 C++ 中的實(shí)現(xiàn)做了比較詳細(xì)的介紹鳖昌,下面對(duì)這兩種語(yǔ)言的多態(tài)實(shí)現(xiàn)的異同做個(gè)小結(jié):
- 單繼承情況下,兩者實(shí)現(xiàn)在本質(zhì)上相同低飒,都是使用方法表许昨,通過(guò)方法表的偏移量來(lái)調(diào)用具體的方法。
- Java 的方法表中包含 Java 類所定義的所有實(shí)例方法褥赊,而 C++ 的方法表則只包含需要?jiǎng)討B(tài)綁定的方法 (virtual 修飾的方法 )糕档。這樣,在 Java 下所有的實(shí)例方法都要通過(guò)方法表調(diào)用,而 C++ 中的非虛方法則是靜態(tài)綁定的速那。
- 任意 Java 對(duì)象只 “指向”一個(gè)方法表俐银,而 C++ 在多重繼承下則可能指向多個(gè)方法表,編譯器保證這多個(gè)方法表的正確初始化端仰。
- 多層繼承中 C++ 面臨的主要問(wèn)題是 this 指針的調(diào)整捶惜,設(shè)計(jì)更精巧更復(fù)雜;而 Java 在接口調(diào)用時(shí)完全采用搜索的方式荔烧,實(shí)現(xiàn)更直觀吱七,但調(diào)用效率比實(shí)例方法調(diào)用要慢許多。
可以看到鹤竭,兩者之間既有相似之處踊餐,也有不同的地方。對(duì)于單繼承的實(shí)現(xiàn)本質(zhì)上是一樣的臀稚,但也有細(xì)微的差別(如方法表)吝岭;差別最大的是對(duì)于多重繼承(多重接口)的支持。實(shí)際上吧寺,由于 C++ 是靜態(tài)編譯型語(yǔ)言窜管,它無(wú)法像 Java 那樣,在運(yùn)行時(shí)刻動(dòng)態(tài)的“查找”所要調(diào)用的方法撮执。
原文地址: https://www.ibm.com/developerworks/cn/java/j-lo-polymorph/