本系列文章将整理到我在GitHub上的《Java面試指南》倉庫,更多精彩内容請到我的倉庫裡檢視
https://github.com/h2pl/Java-Tutorial
喜歡的話麻煩點下Star哈
文章首發于我的個人部落格:
www.how2playlife.com
從JVM結構開始談多态
Java 對于方法調用動态綁定的實作主要依賴于方法表,但通過類引用調用和接口引用調用的實作則有所不同。總體而言,當某個方法被調用時,JVM 首先要查找相應的常量池,得到方法的符号引用,并查找調用類的方法表以确定該方法的直接引用,最後才真正調用該方法。以下分别對該過程中涉及到的相關部分做詳細介紹。
JVM 的結構
典型的 Java 虛拟機的運作時結構如下圖所示
圖 1.JVM 運作時結構
此結構中,我們隻探讨和本文密切相關的方法區 (method area)。當程式運作需要某個類的定義時,載入子系統 (class loader subsystem) 裝入所需的 class 檔案,并在内部建立該類的類型資訊,這個類型資訊就存貯在方法區。類型資訊一般包括該類的方法代碼、類變量、成員變量的定義等等。可以說,類型資訊就是類的 Java 檔案在運作時的内部結構,包含了改類的所有在 Java 檔案中定義的資訊。
注意到,該類型資訊和 class 對象是不同的。class 對象是 JVM 在載入某個類後于堆 (heap) 中建立的代表該類的對象,可以通過該 class 對象通路到該類型資訊。比如最典型的應用,在 Java 反射中應用 class 對象通路到該類支援的所有方法,定義的成員變量等等。可以想象,JVM 在類型資訊和 class 對象中維護着它們彼此的引用以便互相通路。兩者的關系可以類比于程序對象與真正的程序之間的關系。
Java 的方法調用方式
Java 的方法調用有兩類,動态方法調用與靜态方法調用。靜态方法調用是指對于類的靜态方法的調用方式,是靜态綁定的;而動态方法調用需要有方法調用所作用的對象,是動态綁定的。類調用 (invokestatic) 是在編譯時刻就已經确定好具體調用方法的情況,而執行個體調用 (invokevirtual) 則是在調用的時候才确定具體的調用方法,這就是動态綁定,也是多态要解決的核心問題。
JVM 的方法調用指令有四個,分别是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前兩個是靜态綁定,後兩個是動态綁定的。本文也可以說是對于 JVM 後兩種調用實作的考察。
常量池(constant pool)
常量池中儲存的是一個 Java 類引用的一些常量資訊,包含一些字元串常量及對于類的符号引用資訊等。Java 代碼編譯生成的類檔案中的常量池是靜态常量池,當類被載入到虛拟機内部的時候,在記憶體中産生類的常量池叫運作時常量池。
常量池在邏輯上可以分成多個表,每個表包含一類的常量資訊,本文隻探讨對于 Java 調用相關的常量池表。
CONSTANT_Utf8_info
字元串常量表,該表包含該類所使用的所有字元串常量,比如代碼中的字元串引用、引用的類名、方法的名字、其他引用的類與方法的字元串描述等等。其餘常量池表中所涉及到的任何常量字元串都被索引至該表。
CONSTANT_Class_info
類資訊表,包含任何被引用的類或接口的符号引用,每一個條目主要包含一個索引,指向 CONSTANT_Utf8_info 表,表示該類或接口的全限定名。
CONSTANT_NameAndType_info
名字類型表,包含引用的任意方法或字段的名稱和描述符資訊在字元串常量表中的索引。
CONSTANT_InterfaceMethodref_info
接口方法引用表,包含引用的任何接口方法的描述資訊,主要包括類資訊索引和名字類型索引。
CONSTANT_Methodref_info
類方法引用表,包含引用的任何類型方法的描述資訊,主要包括類資訊索引和名字類型索引。
圖 2. 常量池各表的關系
可以看到,給定任意一個方法的索引,在常量池中找到對應的條目後,可以得到該方法的類索引(class_index)和名字類型索引 (name_and_type_index), 進而得到該方法所屬的類型資訊和名稱及描述符資訊(參數,傳回值等)。注意到所有的常量字元串都是存儲在 CONSTANT_Utf8_info 中供其他表索引的。
方法表與方法調用
方法表是動态調用的核心,也是 Java 實作動态調用的主要方式。它被存儲于方法區中的類型資訊,包含有該類型所定義的所有方法及指向這些方法代碼的指針,注意這些具體的方法代碼可能是被覆寫的方法,也可能是繼承自基類的方法。
如有類定義 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{
return "I'm a girl";
}
public void sing(){}
}
當這三個類被載入到 Java 虛拟機之後,方法區中就包含了各自的類的資訊。Girl 和 Boy 在方法區中的方法表可表示如下:
圖 3.Boy 和 Girl 的方法表
可以看到,Girl 和 Boy 的方法表包含繼承自 Object 的方法,繼承自直接父類 Person 的方法及各自新定義的方法。注意方法表條目指向的具體的方法位址,如 Girl 的繼承自 Object 的方法中,隻有 toString() 指向自己的實作(Girl 的方法代碼),其餘皆指向 Object 的方法代碼;其繼承自于 Person 的方法 eat() 和 speak() 分别指向 Person 的方法實作和本身的實作。
Person 或 Object 的任意一個方法,在它們的方法表和其子類 Girl 和 Boy 的方法表中的位置 (index) 是一樣的。這樣 JVM 在調用執行個體方法其實隻需要指定調用方法表中的第幾個方法即可。
如調用如下:
清單 2
class Party{
…
void happyHour(){
Person girl = new Girl();
girl.speak();
…
}
}
當編譯 Party 類的時候,生成
girl.speak()
的方法調用假設為:
Invokevirtual #12
設該調用代碼對應着 girl.speak(); #12 是 Party 類的常量池的索引。JVM 執行該調用指令的過程如下所示:
圖 4. 解析調用過程
JVM 首先檢視 Party 的常量池索引為 12 的條目(應為 CONSTANT_Methodref_info 類型,可視為方法調用的符号引用),進一步檢視常量池(CONSTANT_Class_info,CONSTANT_NameAndType_info ,CONSTANT_Utf8_info)可得出要調用的方法是 Person 的 speak 方法(注意引用 girl 是其基類 Person 類型),檢視 Person 的方法表,得出 speak 方法在該方法表中的偏移量 15(offset),這就是該方法調用的直接引用。
當解析出方法調用的直接引用後(方法表偏移量 15),JVM 執行真正的方法調用:根據執行個體方法調用的參數 this 得到具體的對象(即 girl 所指向的位于堆中的對象),據此得到該對象對應的方法表 (Girl 的方法表 ),進而調用方法表中的某個偏移量所指向的方法(Girl 的 speak() 方法的實作)。
接口調用
因為 Java 類是可以同時實作多個接口的,而當用接口引用調用某個方法的時候,情況就有所不同了。Java 允許一個類實作多個接口,從某種意義上來說相當于多繼承,這樣同樣的方法在基類和派生類的方法表的位置就可能不一樣了。
清單 3
interface IDance{
void dance();
}
class Person {
public String toString(){
return "I'm a person.";
}
class Dancer extends Person
implements IDance {
return "I'm a dancer.";
}
public void dance(){}
class Snake implements IDance{
return "A snake.";
}
public void dance(){
//snake dance
}
圖 5.Dancer 的方法表( 檢視大圖 )
可以看到,由于接口的介入,繼承自于接口 IDance 的方法 dance()在類 Dancer 和 Snake 的方法表中的位置已經不一樣了,顯然我們無法通過給出方法表的偏移量來正确調用 Dancer 和 Snake 的這個方法。這也是 Java 中調用接口方法有其專有的調用指令(invokeinterface)的原因。
Java 對于接口方法的調用是采用搜尋方法表的方式,對如下的方法調用
invokeinterface #13
JVM 首先檢視常量池,确定方法調用的符号引用(名稱、傳回值等等),然後利用 this 指向的執行個體得到該執行個體的方法表,進而搜尋方法表來找到合适的方法位址。
因為每次接口調用都要搜尋方法表,是以從效率上來說,接口方法的調用總是慢于類方法的調用的。
執行結果如下:
可以看到
System.out.println(dancer);
調用的是Person的toString方法。
繼承的實作原理
Java 的繼承機制是一種複用類的技術,從原理上來說,是更好的使用了組合技術,是以要了解繼承,首先需要了解類的組合技術是如何實作類的複用的。
使用組合技術複用類
假設現在的需求是要建立一個具有基本類型,String 類型以及一個其他非基本類型的對象。該如何處理呢?
對于基本類型的變量,在新類中成員變量處直接定義即可,但對于非基本類型變量,不僅需要在類中聲明其引用,并且還需要手動初始化這個對象。
這裡需要注意的是,編譯器并不會預設将所有的引用都建立對象,因為這樣的話在很多情況下會增加不必要的負擔,是以,在合适的時機初始化合适的對象,可以通過以下幾個位置做初始化操作:
在定義對象的地方,先于構造方法執行。
在構造方法中。
在正要使用之前,這個被稱為惰性初始化。
使用執行個體初始化。
class Soap {
private String s;
Soap() {
System.out.println("Soap()");
s = "Constructed";
}
public String tiString(){
return s;
}
}
public class Bath {
// s1 初始化先于構造函數
private String s1 = "Happy", s2 = "Happy", s3, s4;
private Soap soap;
private int i;
private float f;
public Both() {
System.out.println("inSide Both");
s3 = "Joy";
f = 3.14f;
soap = new Soap();
}
{
i = 88;
}
public String toString() {
if(s4 == null){
s4 = "Joy"
}
return "s1 = " + s1 +"\n" +
"s2 = " + s2 +"\n" +
"s3 = " + s3 +"\n" +
"s4 = " + s4 +"\n" +
"i = " + i +"\n" +
"f = " + f +"\n" +
"soap = " + soap;
}
}
繼承
Java 中的繼承由 extend 關鍵字實作,組合的文法比較平實,而繼承是一種特殊的文法。當一個類繼承自另一個類時,那麼這個類就可以擁有另一個類的域和方法。
class Cleanser{
private String s = "Cleanser";
public void append(String a){
s += a;
}
public void apply(){
append("apply");
}
public void scrub(){
append("scrub");
}
public String toString(){
return s;
}
public static void main(String args){
Cleanser c = new Cleanser();
c.apply();
System.out.println(c);
}
}
public class Deter extends Cleanser{
public void apply(){
append("Deter.apply");
super.scrub();
}
public void foam(){
append("foam");
}
public static void main(String args){
Deter d = new Deter();
d.apply();
d.scrub();
d.foam();
System.out.println(d);
Cleanser.main(args);
}
}
上面的代碼中,展示了繼承文法中的一些特性:
子類可以直接使用父類中公共的方法和成員變量(通常為了保護資料域,成員變量均為私有)
子類中可以覆寫父類中的方法,也就是子類重寫了父類的方法,此時若還需要調用被覆寫的父類的方法,則需要用到 super 來指定是調用父類中的方法。
子類中可以自定義父類中沒有的方法。
可以發現上面兩個類中均有 main 方法,指令行中調用的哪個類就執行哪個類的 main 方法,例如:java Deter。
繼承文法的原理
接下來我們将通過建立子類對象來分析繼承文法在我們看不到的地方做了什麼樣的操作。
可以先思考一下,如何了解使用子類建立的對象呢,首先這個對象中包含子類的所有資訊,但是也包含父類的所有公共的資訊。
下面來看一段代碼,觀察一下子類在建立對象初始化的時候,會不會用到父類相關的方法。
class Art{
Art() {
System.out.println("Art Construct");
}
}
class Drawing extends Art {
Drawing() {
System.out.println("Drawing Construct");
}
}
public class Cartoon extends Drawing {
public Cartoon() {
System.out.println("Cartoon construct");
}
public void static main(String args) {
Cartoon c = new Cartoon();
}
}
/*output:
Art Construct
Drawing Construct
Cartoon construct
*/
通過觀察代碼可以發現,在執行個體化Cartoon時,事實上是從最頂層的父類開始向下逐個執行個體化,也就是最終執行個體化了三個對象。編譯器會預設在子類的構造方法中增加調用父類預設構造方法的代碼。
是以,繼承可以了解為編譯器幫我們完成了類的特殊組合技術,即在子類中存在一個父類的對象,使得我們可以用子類對象調用父類的方法。而在開發者看來隻不過是使用了一個關鍵字。
注意:雖然繼承很接近組合技術,但是繼承擁有其他更多的差別于組合的特性,例如父類的對象我們是不可見的,對于父類中的方法也做了相應的權限校驗等。
那麼,如果類中的構造方法是帶參的,該如何操作呢?(使用super關鍵字顯示調用)
見代碼:
class Game {
Game(int i){
System.out.println("Game Construct");
}
}
class BoardGame extends Game {
BoardGame(int j){
super(j);
System.out.println("BoardGame Construct");
}
}
public class Chess extends BoardGame{
Chess(){
super(99);
System.out.println("Chess construct");
}
public static void main(String args) {
Chess c = new Chess();
}
}
/*output:
Game Construct
BoardGame Construct
Chess construc
*/
重載和重寫的實作原理
剛開始學習Java的時候,就了解了Java這個比較有意思的特性:重寫 和 重載。開始的有時候從名字上還總是容易弄混。我相信熟悉Java這門語言的同學都應該了解這兩個特性,可能隻是從語言層面上了解這種寫法,但是jvm是如何實作他們的呢 ?
重載官方給出的介紹:
一. overload:
The Java programming language supports overloading methods, and Java can distinguish between methods with different method signatures. This means that methods within a class can have the same name if they have different parameter lists .
Overloaded methods are differentiated by the number and the type of the arguments passed into the method.
You cannot declare more than one method with the same name and the same number and type of arguments, because the compiler cannot tell them apart.
The compiler does not consider return type when differentiating methods, so you cannot declare two methods with the same signature even if they have a different return type.
首先看一段代碼,來看看代碼的執行結果:
public class OverrideTest {
class Father{}
class Sun extends Father {}
public void doSomething(Father father){
System.out.println("Father do something");
}
public void doSomething(Sun father){
System.out.println("Sun do something");
}
public static void main(String [] args){
OverrideTest overrideTest = new OverrideTest();
Father sun = overrideTest.new Sun();
Father father = overrideTest.new Father();
overrideTest.doSomething(father);
overrideTest.doSomething(sun);
}
}
看下這段代碼的執行結果,最後會列印:
Father do something
為什麼會列印出這樣的結果呢? 首先要介紹兩個概念:靜态分派和動态分派
靜态分派:依賴靜态類型來定位方法執行版本的分派動作稱為靜态分派
動态分派:運作期根據實際類型确定方法執行版本的分派過程。
他們的差別是:
1. 靜态分派發生在編譯期,動态分派發生在運作期;
2. private,static,final 方法發生在編譯期,并且不能被重寫,一旦發生了重寫,将會在運作期處理。
3. 重載是靜态分派,重寫是動态分派
回到上面的問題,因為重載是發生在編譯期,是以在編譯期已經确定兩次 doSomething 方法的參數都是Father類型,在class檔案中已經指向了Father類的符号引用,是以最後會列印兩次Father do something。
二. override:
An instance method in a subclass with the same signature (name, plus the number and the type of its parameters) and return type as an instance method in the superclass overrides the superclass's method.
The ability of a subclass to override a method allows a class to inherit from a superclass whose behavior is "close enough" and then to modify behavior as needed. The overriding method has the same name, number and type of parameters, and return type as the method that it overrides. An overriding method can also return a subtype of the type returned by the overridden method. This subtype is called a covariant return type.
還是上面那個代碼,稍微改動下
public class OverrideTest {
class Father{}
class Sun extends Father {}
public void doSomething(){
System.out.println("Father do something");
}
public void doSomething(){
System.out.println("Sun do something");
}
public static void main(String [] args){
OverrideTest overrideTest = new OverrideTest();
Father sun = overrideTest.new Sun();
Father father = overrideTest.new Father();
overrideTest.doSomething();
overrideTest.doSomething();
}
}
最後會列印:
Sun do something
相信大家都會知道這個結果,那麼這個結果jvm是怎麼實作的呢?
在編譯期,隻會識别到是調用Father類的doSomething方法,到運作期才會真正找到對象的實際類型。
首先該方法的執行,jvm會調用invokevirtual指令,該指令會找棧頂第一個元素所指向的對象的實際類型,如果該類型存在調用的方法,則會走驗證流程,否則繼續找其父類。這也是為什麼子類可以直接調用父類具有通路權限的方法的原因。簡而言之,就是在運作期才會去确定對象的實際類型,根據這個實際類型确定方法執行版本,這個過程稱為動态分派。override 的實作依賴jvm的動态分派。
參考文章
https://blog.csdn.net/dj_dengjian/article/details/80811348 https://blog.csdn.net/chenssy/article/details/12757911 https://blog.csdn.net/fan2012huan/article/details/51007517 https://blog.csdn.net/fan2012huan/article/details/50999777 https://www.cnblogs.com/serendipity-fly/p/9469289.html https://blog.csdn.net/m0_37264516/article/details/86709537微信公衆号
Java技術江湖
如果大家想要實時關注我更新的文章以及分享的幹貨的話,可以關注我的公衆号【Java技術江湖】一位阿裡 Java 工程師的技術小站,作者黃小斜,專注 Java 相關技術:SSM、SpringBoot、MySQL、分布式、中間件、叢集、Linux、網絡、多線程,偶爾講點Docker、ELK,同時也分享技術幹貨和學習經驗,緻力于Java全棧開發!
Java工程師必備學習資源: 一些Java工程師常用學習資源,關注公衆号後,背景回複關鍵字 “Java” 即可免費無套路擷取。

個人公衆号:黃小斜
作者是跨考軟體工程的 985 碩士,自學 Java 兩年,拿到了 BAT 等近十家大廠 offer,從技術小白成長為阿裡工程師。
作者專注于 JAVA 後端技術棧,熱衷于分享程式員幹貨、學習經驗、求職心得和程式人生,目前黃小斜的CSDN部落格有百萬+通路量,知乎粉絲2W+,全網已有10W+讀者。
黃小斜是一個斜杠青年,堅持學習和寫作,相信終身學習的力量,希望和更多的程式員交朋友,一起進步和成長!
關注公衆号【黃小斜】後回複【原創電子書】即可領取我原創的電子書《菜鳥程式員修煉手冊:從技術小白到阿裡巴巴Java工程師》
程式員3T技術學習資源: 一些程式員學習技術的資源大禮包,關注公衆号後,背景回複關鍵字 “資料” 即可免費無套路擷取。