深入理解JVM方法調(diào)用的內(nèi)部機(jī)制
你知道的越多,不知道的就越多,業(yè)余的像一棵小草!
你來,我們一起精進(jìn)!你不來,我和你的競(jìng)爭(zhēng)對(duì)手一起精進(jìn)!
編輯:業(yè)余草
blog.csdn.net/TellH
推薦:https://www.xttblog.com/?p=5261
我們都知道,Java 源代碼需要編譯成字節(jié)碼文件,由JVM解釋執(zhí)行,而方法調(diào)用可以說是很常見的操作。Java 不同于 C++,Java 中的實(shí)例方法默認(rèn)是虛方法,因此父類引用調(diào)用被子類覆蓋的方法時(shí)能體現(xiàn)多態(tài)性。下面我們來看看 JVM 是如何完成方法調(diào)用操作并實(shí)現(xiàn)動(dòng)態(tài)綁定的。
棧幀結(jié)構(gòu)
為了能高效地管理程序方法調(diào)用,有條不紊地進(jìn)行嵌套的方法調(diào)用和方法返回,JVM 維護(hù)了一個(gè)棧結(jié)構(gòu),稱為虛擬機(jī)方法棧(這里沒考慮 Native 方法)。棧里面存放的一個(gè)個(gè)實(shí)體稱為棧幀,每一個(gè)棧幀都包括了局部變量表,操作數(shù)棧,動(dòng)態(tài)連接,方法返回地址和一些額外的附加信息。在編譯時(shí),棧幀中需要多大的局部變量表,多深的操作數(shù)棧都已經(jīng)完全確定了,并且寫入到方法表的 Code 屬性之中。
局部變量表
局部變量表用于存放方法參數(shù)和方法內(nèi)部定義的局部變量。局部變量表的容量以 Slot 為最小單位,一個(gè) Slot 可以存放一個(gè) 32 位以內(nèi)的數(shù)據(jù)類型,long 和 double 需要兩個(gè) Slot 存放。
如果執(zhí)行的方法是非 static 方法,那局部變量表中第 0 位索引的 Slot 默認(rèn)是用于傳遞方法所屬對(duì)象實(shí)例的引用(this)。
為了節(jié)省棧幀空間,局部變量表中的 Slot 是可以重用的。如果一個(gè)局部變量定義了但沒有賦初值是不能使用的。
操作數(shù)棧
JVM 解析執(zhí)行字節(jié)碼是基于棧結(jié)構(gòu)的。比如做算術(shù)運(yùn)算時(shí)是通過操作數(shù)棧來進(jìn)行的,在調(diào)用其他方法時(shí)是通過操作數(shù)棧來進(jìn)行參數(shù)的傳遞。
方法調(diào)用大致過程
除非被調(diào)用的方法是類方法,每一次方法調(diào)用指令之前,JVM 先會(huì)把方法被調(diào)用的對(duì)象引用壓入操作數(shù)棧中,除了對(duì)象的引用之外,JVM 還會(huì)把方法的參數(shù)依次壓入操作數(shù)棧。 在執(zhí)行方法調(diào)用指令時(shí),JVM 會(huì)將函數(shù)參數(shù)和對(duì)象引用依次從操作數(shù)棧彈出,并新建一個(gè)棧幀,把對(duì)象引用和函數(shù)參數(shù)分別放入新棧幀的局部變量表 slot 0,1,2…。 JVM 把新棧幀 push 入虛擬機(jī)方法棧,并把 PC 指向函數(shù)的第一條待執(zhí)行的指令。
到此,有人可能會(huì)問,JVM 是如何得到被調(diào)用方法的地址呢??jī)煞N方式,一種是編譯期的靜態(tài)綁定,另一種是運(yùn)行期的動(dòng)態(tài)綁定。不同類型的方法用不同的綁定方式。
方法調(diào)用的字節(jié)碼指令
JVM 里面提供了 4 條方法調(diào)用字節(jié)碼指令。分別如下:
「invokestatic」:調(diào)用靜態(tài)方法 「invokespecial」:調(diào)用實(shí)例構(gòu)造器 <init>方法、私有方法和父類方法(super(),super.method())「invokevirtual」:調(diào)用所有的虛方法(靜態(tài)方法、私有方法、實(shí)例構(gòu)造器、父類方法、final 方法都是非虛方法) 「invokeinterface」:調(diào)用接口方法,會(huì)在運(yùn)行時(shí)期再確定一個(gè)實(shí)現(xiàn)此接口的對(duì)象
invokestatic 和 invokespecial 指令調(diào)用的方法都可以在解析階段中確定唯一的調(diào)用版本,符合這個(gè)條件的有靜態(tài)方法、私有方法、實(shí)例構(gòu)造器、父類方法 4 類,它們?cè)陬惣虞d階段就會(huì)把符號(hào)引用解析為該方法的直接引用。直接引用就是一個(gè)指針或偏移量,可以讓 JVM 快速定位到具體要調(diào)用的方法。
invokevirtual 和 invokeinterface 指令調(diào)用的方法是在運(yùn)行時(shí)確定具體的方法地址,接口方法和實(shí)例對(duì)象公有方法可以用這兩個(gè)指令來調(diào)用。
下面我們通過一個(gè)代碼示例來展現(xiàn)這幾種方法調(diào)用:
public class Test {
private void run() {
List<String> list = new ArrayList<>(); // invokespecial 構(gòu)造器調(diào)用
list.add("a"); // invokeinterface 接口調(diào)用
ArrayList<String> arrayList = new ArrayList<>(); // invokespecial 構(gòu)造器調(diào)用
arrayList.add("b"); // invokevirtual 虛函數(shù)調(diào)用
}
public static void main(String[] args) {
Test test = new Test(); // invokespecial 構(gòu)造器調(diào)用
test.run(); // invokespecial 私有函數(shù)調(diào)用
}
}
反編譯字節(jié)碼:
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
private void run();
Code:
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: ldc #4 // String a
11: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
16: pop
17: new #2 // class java/util/ArrayList
20: dup
21: invokespecial #3 // Method java/util/ArrayList."<init>":()V
24: astore_2
25: aload_2
26: ldc #6 // String b
28: invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
31: pop
32: return
public static void main(java.lang.String[]);
Code:
0: new #8 // class Test
3: dup
4: invokespecial #9 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #10 // Method run:()V
12: return
}
從上面的字節(jié)碼可以看出,每一條方法調(diào)用指令后面都帶一個(gè) Index 值,JVM 可以通過這個(gè)索引值從常量池中獲取到方法的符號(hào)引用。
每個(gè) class 文件都有一個(gè)常量池,主要是關(guān)于類、方法、接口等中的常量,也包括字符串常量和符號(hào)引用。方法的符號(hào)引用是唯一標(biāo)識(shí)一個(gè)方法的信息結(jié)構(gòu)體,包含類名,方法名和方法描述符,方法描述符又包含返回值、函數(shù)名和參數(shù)列表。這些字符值都存放到 class 文件的常量池中,通過整型的 Index 來標(biāo)識(shí)和索引。
動(dòng)態(tài)分派
當(dāng) JVM 遇到 invokevirtual 或 invokeinterface 時(shí),需要運(yùn)行時(shí)根據(jù)方法的符號(hào)引用查找到方法地址。具體過程如下:
在方法調(diào)用指令之前,需要將對(duì)象的引用壓入操作數(shù)棧 在執(zhí)行方法調(diào)用時(shí),找到操作數(shù)棧頂?shù)牡谝粋€(gè)元素所指向的對(duì)象實(shí)際類型,記作 C 在類型 C 中找到與常量池中的描述符和方法名稱都相符的方法,并校驗(yàn)訪問權(quán)限。如果找到該方法并通過校驗(yàn),則返回這個(gè)方法的引用; 否則,按照繼承關(guān)系往上查找方法并校驗(yàn)訪問權(quán)限; 如果始終沒找到方法,則拋出 java.lang.AbstractMethodError 異常;
可以看到,JVM 是通過繼承關(guān)系從子類往上查找的對(duì)應(yīng)的方法的,為了提高動(dòng)態(tài)分派時(shí)方法查找的效率,JVM 為每個(gè)類都維護(hù)一個(gè)虛函數(shù)表。
虛函數(shù)表
JVM 實(shí)現(xiàn)動(dòng)態(tài)綁定的原理類似于 C++ 的虛函數(shù)表機(jī)制,但 C++ 的虛函數(shù)表是實(shí)現(xiàn)多態(tài)中必不可少的數(shù)據(jù)結(jié)構(gòu),但 JVM 里引入虛函數(shù)表的目的是加快虛方法的索引。
JVM 會(huì)在鏈接類的過程中,給類分配相應(yīng)的方法表內(nèi)存空間。每個(gè)類對(duì)應(yīng)一個(gè)方法表。這些都是存在于方法區(qū)中的。這里與 C++ 略有不同,C++ 中每個(gè)對(duì)象的第一個(gè)指針就是指向了相應(yīng)的虛函數(shù)表。而 Java 中每個(gè)對(duì)象的對(duì)象頭有一個(gè)類型指針,可以索引到對(duì)應(yīng)的類,在對(duì)應(yīng)的類數(shù)據(jù)中對(duì)應(yīng)一個(gè)方法表。也就是 C++ 的方法表是對(duì)象級(jí)別的,而 Java 的方法表是類級(jí)別的。
一個(gè)類的方法表包含類的所有方法入口地址,從父類繼承的方法放在前面,接下來是接口方法和自定義的方法。如果某個(gè)方法在子類中沒有被重寫,那子類的虛方法表里面的地址入口和父類相同的方法的入口地址一致。如果子類重寫了這個(gè)方法,子類方法表中的地址將會(huì)替換為指向子類實(shí)現(xiàn)版本的入口地址。
比如對(duì)于如下的 Foo 類:
class Foo {
@Override
public String toString() {
return "Foo";
}
void run(){}
}
它的虛函數(shù)表如下:

invokevirtual 和 invokeinterface 的區(qū)別
從上面我們可以發(fā)現(xiàn),虛函數(shù)表上的虛方法是按照從父類到子類的順序排序的,因此對(duì)于使用 invokevirtual 調(diào)用的虛函數(shù),JVM 完全可以在編譯期就確定了虛函數(shù)在方法表上的 offset,或者在首次調(diào)用之后就把這個(gè) offset 緩存起來,這樣就可以快速地從方法表中定位所要調(diào)用的方法地址。
然而對(duì)于接口類型引用,由于一個(gè)接口可以被不同的 Class 來實(shí)現(xiàn),所以接口方法在不同類的方法表的 offset 當(dāng)然就(很可能)不一樣了。因此,每次接口方法的調(diào)用,JVM 都會(huì)搜尋一遍虛函數(shù)表,效率會(huì)比 invokevirtual 要低。
參考鏈接
How the Java virtual machine handles method invocation and return 圖解JVM執(zhí)行引擎之方法調(diào)用 Java:方法的虛分派(virtual dispatch)和方法表(method table) Java調(diào)用重載方法(invokevirtual)和接口方法(invokeinterface)的解析
