點贊關(guān)注,不再迷路培己,你的支持對我意義重大胚泌!
?? Hi,我是丑丑零蓉。本文 「Java 路線」| 導(dǎo)讀 —— 他山之石敌蜂,可以攻玉 已收錄津肛,這里有 Android 進階成長路線筆記 & 博客,歡迎跟著彭丑丑一起成長秸脱。(聯(lián)系方式在 GitHub)
前言
- 對于習(xí)慣使用面向?qū)ο箝_發(fā)的工程師們來說部蛇,重載 & 重寫 這兩個概念應(yīng)該不會陌生了。在中 / 低級別面試中巷查,也常常會考察面試者對它們的理解(隱約記得當(dāng)年在校招面試時遇到過)岛请;
- 網(wǎng)上大多數(shù)資料 & 面經(jīng)對這兩個概念的闡述幢踏,多數(shù)僅停留在討論兩者在 表現(xiàn)上 的差異,讓讀者去被動地接受知識僚匆。在這篇文章里咧擂,我將更有深度地理解重載 & 重寫的原理,應(yīng)深入理解Java 虛擬機執(zhí)行引擎是如何進行方法調(diào)用的云芦。請點贊贸桶,你的點贊和關(guān)注真的對我非常重要皇筛!
首先,嘗試寫出以下程序的輸出:
public class Base {
public static void funcStatic(String str){
System.out.println("Base - funcStatic - String");
}
public static void funcStatic(Object obj){
System.out.println("Base - funcStatic - Object");
}
public void func(String str){
System.out.println("Base - func - String");
}
public void func(Object obj){
System.out.println("Base - func - Object");
}
}
public class Child extends Base {
public static void funcStatic(String str){
System.out.println("Child - funcStatic - String");
}
public static void funcStatic(Object obj){
System.out.println("Child - funcStatic - Object");
}
@Override
public void func(String str){
System.out.println("Child - func - String");
}
@Override
public void func(Object obj){
System.out.println("Child - func - Object");
}
}
public class Test{
public static void main(String[] args){
Object obj = new Object();
Object str = new String();
Base base = new Base();
Base child1 = new Child();
Child child2 = new Child();
base.funcStatic(obj); // 正常編程中不應(yīng)該用實例去調(diào)用靜態(tài)方法
child1.funcStatic(obj);
child2.funcStatic(obj);
base.func(str);
child1.func(str);
child2.func(str);
}
}
程序輸出:
Base - funcStatic - Object
Base - funcStatic - Object
Child - funcStatic - Object
Base - func - Object
Child - func - Object
Child - func - Object
程序輸出是否與你的預(yù)期一致呢?遇到困難了嗎拄踪,相信這篇文章一定能幫到你...
延伸文章
對于
Java
編譯過程不了解惶桐,請閱讀:《Java | 聊一聊編譯過程(編譯前端 & 編譯后端)》對于
Class 文件 & 符號引用
不了解,請閱讀:《Java | 請概述一下 Class 文件的結(jié)構(gòu)》對于
類加載
的流程不太了解想虎,請閱讀:《Java | 談?wù)勀銓︻惣虞d過程的理解》
目錄
1. 靜態(tài)類型 & 實際類型
每一個變量都有兩種類型:靜態(tài)類型(Static Type) & 實際類型(Actual Type)。例如下面代碼中忿薇,Base
為變量base
的靜態(tài)類型躏哩,Child
為實際類型:
Base base = new Child();
兩者的具體區(qū)別如下:
- 靜態(tài)類型:引用變量的類型扫尺,在編譯期確定,無法改變
- 實際類型:實例對象的類型弊攘,在編譯期無法確定,需在運行期確定迈倍,可以改變
這里先談到這里啼染,后文會從字節(jié)碼的角度理解繼續(xù)討論兩個類型焕梅。
2. 方法調(diào)用的本質(zhì)
這一節(jié),我們來討論Java
中方法調(diào)用的本質(zhì)斜棚。我們知道打肝,Java
前端編譯的產(chǎn)物是字節(jié)碼挪捕,與C/C++
不同级零,前端編譯過程中并沒有鏈接步驟,字節(jié)碼中所有的方法調(diào)用都是使用符號引用鉴嗤。舉個例子:
- 源碼:
public class Child extends Base {
@Override
void func() {
}
void test1(){
func();
}
void test2(){
super.func();
}
}
- 字節(jié)碼(javap -c Child.class):
Compiled from "Child.java"
public class com.Child extends com.Base {
// 構(gòu)造函數(shù)序调,默認調(diào)用父類構(gòu)造函數(shù)
public com.Child();
Code:
0: aload_0
1: invokespecial #1 // Method com/Base."<init>":()V
4: return
void func();
Code:
0: return
void test1();
Code:
0: aload_0
// invokevirtual 調(diào)用實例方法
1: invokevirtual #2 // Method func:()V
4: return
void test2();
Code:
0: aload_0
// invokespecial 調(diào)用靜態(tài)方法
1: invokespecial #3 // Method com/Base.func:()V
4: return
}
上面的字節(jié)碼中发绢,invokespecial
和invokevirtual
都是方法調(diào)用的字節(jié)碼指令边酒,具體細節(jié)下文會詳細解釋。后面的#1 #2 #3
表示符號引用在常量池中的索引號坯认,根據(jù)這個索引號檢索常量表,可以查到最終表示的是一個字符串字面量陋气,例如func:()V
荆隘,這個就是方法的符號引用椰拒。
為了方便理解字節(jié)碼,
javap
反編譯的字節(jié)碼已經(jīng)在注釋中提示了最終表示的值褒脯,例如Method func:()V
缆毁。
符號引用(Symbolic References)是一個用來無歧義地標(biāo)識一個實體(例如方法/字段)的字符串脊框,在運行期它會翻譯為直接引用(Direct Reference)。對于方法來說沉御,就是方法的入口地址吠裆。
下圖描述了方法符號引用的基本格式:
這個符號引用包含了變量的靜態(tài)類型(如果是變量的靜態(tài)類型與本類相同试疙,不需要指明)抠蚣、簡單方法名以及描述符(參數(shù)順序、參數(shù)類型和方法返回值)缓屠。通過這個符號引用,Java虛擬機就可以翻譯出該方法的直接引用储耐。但是,同一個符號引用晦攒,運行時翻譯出來的直接引用可能是不同的得哆,為什么會這樣呢贩据?
- 小結(jié):
1. 方法調(diào)用的本質(zhì)是根據(jù)方法的符號引用確定方法的直接引用(入口地址)
3. 從符號引用到直接引用
為什么同一個符號引用,運行時翻譯出來的直接引用可能是不同的矾芙?這與使用的方法調(diào)用指令的處理過程有關(guān)近上,Java
字節(jié)碼的方法調(diào)用指令一共有以下 5 種:
其中壹无,根據(jù)調(diào)用方法的版本是否在編譯期可以確定斗锭,(注意:只是版本,而不是入口地址骚秦,入口地址只能在運行時確定)可以將方法調(diào)用劃分為靜態(tài)解析 & 動態(tài)分派兩種璧微。
# 誤區(qū)(重要)#
《深入理解Java虛擬機》中將方法調(diào)用分為解析前硫、靜態(tài)分派、動態(tài)分派三種阶剑,又根據(jù)宗量的數(shù)量引入了靜態(tài)多分派牧愁,動態(tài)單分派的概念外莲。這些概念事實上過于字典化,也很容易讓讀者誤認為靜態(tài)分派與動態(tài)分派是非此即彼的互斥關(guān)系磨确。事實上乏奥,一個方法可以同時重寫與重載 ,重載 & 重寫是方法調(diào)用的兩個階段恨诱,而不是兩個種類胡野。
下面痕鳍,我將介紹Java
中方法選擇的三個步驟:
3.1 步驟1:生成符號引用(編譯時)
上一節(jié)我們提到過方法符號引用的基本格式笼呆,分為三個部分:
- 變量的靜態(tài)類型:
類的全限定名中將.
替換為/
诗赌,例如java.lang.Object
對應(yīng)java/lang/Object
- 簡單名稱:
方法的名稱,例如Object#toString()
的簡單名稱為:toString
- 描述符:
方法的參數(shù)列表和返回值洪碳,例如Object#toString()
的描述符為()LJava/lang/String;
描述符的規(guī)則不是本文重點瞳腌,這里便不再贅述了镜雨,若不了解可閱讀延伸文章苇羡。這里我們用兩段程序驗證上述規(guī)則遏乔,這兩段程序中我們考慮了重載 & 重寫妇穴、靜態(tài) & 實例兩個維度的因素:
程序一(重載 & 重寫)
public class Base {
public void func() {}
public void func(int i){}
}
public class Child extends Base {
@Override
public void func() {}
@Override
public void func(int i){}
}
public class Test{
public static void main(String[] args){
Base base1 = new Base();
Base child1 = new Child();
Child child2 = new Child();
base1.func(); // invokevirtual com.Base.func:():V
child1.func(); // invokevirtual com.Base.func:():V
child2.func(); // invokevirtual com.Child.func:():V
base1.func(1); // invokevirtual com.Base.func:(I):V
child1.func(1); // invokevirtual com.Base.func:(I):V
child2.func(1); // invokevirtual com.Child.func:(I):V
}
}
可以看到疗我,符號引用中的類名確實是變量的靜態(tài)類型,而不是變量的實際類型溺健;方法名不用多說鞭缭,方法描述符則選擇重載方法中最合適的一個方法岭辣。這個例程很容易判斷重載方法選擇結(jié)果,具體選擇規(guī)則其實更為復(fù)雜偷遗。
程序二(靜態(tài) & 實例)
public class Base {
public static void func() {}
public void func(int i){}
}
public class Child extends Base {
public static void func() {}
@Override
public void func(int i){}
}
public class Test{
public static void main(String[] args){
Base base1 = new Base();
Base child1 = new Child();
Child child2 = new Child();
符號引用與程序一相同,僅指令不同
base1.func(); // invokestatic com.Base.func:():V
child1.func(); // invokestatic com.Base.func:():V
child2.func(); // invokestatic com.Child.func:():V
base1.func(1); // invokevirtual com.Base.func:(I):V
child1.func(1); // invokevirtual com.Base.func:(I):V
child2.func(1); // invokevirtual com.Child.func:(I):V
}
}
可以看到,static
對符號引用沒有影響纪铺,僅影響使用的指令(靜態(tài)方法調(diào)用使用invokestatic
)。而通過對象實例去調(diào)用靜態(tài)方法是javac
的語法糖烹棉,編譯時會轉(zhuǎn)換為使用變量的靜態(tài)類型固化到符號引用中。
- 小結(jié):
1. 方法的符號引用在編譯期確定抠刺,并固化到字節(jié)碼中方法調(diào)用指令的參數(shù)中
2. 是否有static
修飾對符號引用沒有影響速妖,僅影響使用的字節(jié)碼指令罕容,對象實例去調(diào)用靜態(tài)方法是javac
的語法糖
3.2 步驟二:解析(類加載時)
為什么靜態(tài)方法锦秒、私有實例方法喉镰、實例構(gòu)造器<init>侣姆、父類方法以及final修飾這五種方法(對應(yīng)的關(guān)鍵字: static、private汇歹、<init>产弹、super弯囊、final
)可以在編譯期確定版本呢匾嘱?因為無論運行時加載多少個類,這些方法都保證唯一的版本:
方法 | 原因 |
---|---|
static |
相同簽名的子類方法會隱藏父類方法 |
private |
只在本類可見 |
<init> |
由編譯器生成撬讽,源碼無法編寫 |
super |
Java 是單繼承游昼,只有一個父類 |
final |
禁止被重寫 |
既然可以確定方法的版本烘豌,虛擬機在處理invokestatic
看彼、invokespecial
、invokevirtual(final)
時顽铸,就可以提前將符號引用轉(zhuǎn)換為直接引用鸯绿,不必延遲到方法調(diào)用時確定瓶蝴,具體來說舷手,是在類加載的解析階段完成轉(zhuǎn)換的劲绪。
invokestatic 指令
1)類加載解析階段:根據(jù)符號引用中類名(如下例中
java/lang/String
變量的靜態(tài)類型中)贾富,在對應(yīng)的類中找到簡單名稱與描述符相符合的方法,如果找到則將符號引用轉(zhuǎn)換為直接引用汗捡;否則扇住,按照繼承關(guān)系從下往上依次在各個父類中搜索2)調(diào)用階段:符號引用已經(jīng)轉(zhuǎn)換為直接引用艘蹋;調(diào)用
invokestatic
不需要將對象加載到操作數(shù)棧票灰,只需要將所需要的參數(shù)入棧就可以執(zhí)行invokestatic
指令屑迂。例如:
源碼:
String str = String.valueOf("1")
字節(jié)碼:
0: iconst_1
1: invokestatic #2 // Method java/lang/String.valueOf:(I)Ljava/lang/String;
4: astore_1
invokespecial 指令
1)類加載解析階段:同
invokestatic
屈糊,也是從符號引用中的靜態(tài)類型開始查找2)調(diào)用階段:同
invokestatic
逻锐,符號引用已經(jīng)轉(zhuǎn)換為直接引用雕薪;<init>所袁、父類方法凶掰、私有實例方法這3種情況都是屬于實例方法懦窘,所以調(diào)用invokespecial
指令需要將對象加載到操作數(shù)棧畅涂。例如:
1、源碼(實例構(gòu)造器):
String str = new String();
字節(jié)碼:
0: new #2 // class java/lang/String
3: dup
4: invokespecial #3 // Method java/lang/String."<init>":()V
7: astore_1
--------------------------------------------------------------------
2立宜、源碼(父類方法):
super.func();
字節(jié)碼:
0: aload_0
1: invokespecial #2 // Method com/Base.func:()V
--------------------------------------------------------------------
3橙数、源碼(私有方法):
funcPrivate();
字節(jié)碼:
0: aload_0
1: invokespecial #2 // Method funPrivate:()V
3.3 步驟三:動態(tài)分派(類使用時)
動態(tài)分派分為invokevitrual
灯帮、invokeinterface
與 invokedynamic
施流,其中動態(tài)調(diào)用invokedynamic
是 JDK 1.7 新增的指令鄙信,我們單獨在另一篇中解析装诡。有些同學(xué)可能會覺得方法不重寫不就只有一個版本了嗎?這個想法忽略了Java
動態(tài)鏈接的特性宾巍,Java
可以從任何途徑加載一個class
顶霞,除非解析的 5 種的情況外选浑,無法保證方法不被重寫。
invokevirtual指令
虛擬機為每個類生成虛方法表vtable(virtual method table)
的結(jié)構(gòu)拓提,類中聲明的方法的入口地址會按固定順序存放在虛方法表中代态;虛方法表還會繼承父類的虛方法表,順序與父類保持一致疹吃,子類新增的方法按順序添加到虛方法末尾(這以Java
單繼承為前提)蹦疑;若子類重寫父類方法,則重寫方法位置的入口地址修改為子類實現(xiàn)萨驶;
- 1)類加載解析階段:解析類的繼承關(guān)系必尼,生成類的虛方法表 (包含了這個類型所有方法的入口地址)。舉個例子篡撵,有
Class B
繼承與Class A
,并重寫了A
中的方法:
Object
是所有類的父類豆挽,所有每個類的虛方法表頭部都會包含Object
的虛方法表。另外帮哈,B
重寫了A#printMe()
膛檀,所以對應(yīng)位置的入口地址方法被修改為B
重寫方法的入口地址。
需要注意的是娘侍,被final
咖刃、static
或private
修飾的方法不會出現(xiàn)在虛方法表中,因為這些方法無法被繼承重寫憾筏。
- 2)調(diào)用階段(動態(tài)分派):解析階段生成虛方法表后嚎杨,每個方法在虛方法表中的索引是固定的,這是不會隨著實際類型變化影響的氧腰。調(diào)用方法時枫浙,首先根據(jù)變量的實際類型獲得對應(yīng)的虛方法表(包含了這個類型所有方法的入口地址),然后根據(jù)索引找到方法的入口地址古拴。
invokeinterface指令
接口方法的選擇行為與類方法的選擇行為略有區(qū)別箩帚,主要原因是Java
接口是支持多繼承的,就沒辦法像虛方法表那樣直接繼承父類的虛方法表黄痪。虛擬機提供了itable(interface method table)
來支持多接口紧帕,itable
由偏移量表offset table
與方法表method table
兩部分組成。
當(dāng)需要調(diào)用某個接口方法時桅打,虛擬機會在offset table
查找對應(yīng)的method table
是嗜,隨后在該method table
上查找方法愈案。
3.4 性能對比
-
invokestatic & invokespecial
可以直接調(diào)用方法入口地址,最快 -
invokevirtual
通過編號在vtable
中查找方法叠纷,次之 -
invokeinterface
現(xiàn)在offset table
中查找method table
的偏移位置刻帚,隨后在method table
中查找接口方法的實現(xiàn)
4. 總結(jié)
- 方法調(diào)用的本質(zhì)是從符號引用轉(zhuǎn)換到直接引用(方法入口地址)的過程,一共需要經(jīng)過(編譯時)生成符號引用涩嚣、(類加載時)解析崇众、(調(diào)用時)動態(tài)分派三個步驟
-
invokestatic & invokespecial
指令在(類加載時)解析時根據(jù)靜態(tài)類型完成轉(zhuǎn)換 -
invokevirtual & invokeinterface
在(調(diào)用時)根據(jù)實際類型,查找vtable & itable
完成轉(zhuǎn)換 - 重載其實是編譯器的語法特性與多態(tài)無關(guān)航厚,對編譯時符號引用生成有影響顷歌,在運行時已經(jīng)沒有影響了;重寫是多態(tài)的基礎(chǔ)幔睬,虛擬機通過
vtable & itable
來支持虛方法的方法選擇眯漩。
參考資料
- 《深入理解Java虛擬機(第3版本)》(第8章)—— 周志明 著
- 《深入理解Android:Java虛擬機 ART》(第2章) —— 鄧凡平 著
- 《深入理解 JVM 字節(jié)碼》(第2、3章)—— 張亞 著
創(chuàng)作不易麻顶,你的「三連」是丑丑最大的動力赦抖,我們下次見!