我們都知道,Java源代碼需要編譯成字節碼文件,由JVM解釋執行,而方法調用可以說是很常見的操作。Java不同於C++,Java中的實例方法默認是虛方法,因此父類引用調用被子類覆蓋的方法時能體現多態性。下面我們來看看JVM是如何完成方法調用操作並實現動態綁定的。
棧幀結構
爲了能高效地管理程序方法調用,有條不紊地進行嵌套的方法調用和方法返回,JVM維護了一個棧結構,稱爲虛擬機方法棧(這裏沒考慮Native方法)。棧裏面存放的一個個實體稱爲棧幀,每一個棧幀都包括了局部變量表,操作數棧,動態連接,方法返回地址和一些額外的附加信息。在編譯時,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定了,並且寫入到方法表的Code屬性之中。
局部變量表
局部變量表用於存放方法參數和方法內部定義的局部變量。局部變量表的容量以Slot爲最小單位,一個Slot可以存放一個32位以內的數據類型,long和double需要兩個Slot存放。
如果執行的方法是非static方法,那局部變量表中第0位索引的Slot默認是用於傳遞方法所屬對象實例的引用(this)。
爲了節省棧幀空間,局部變量表中的Slot是可以重用的。如果一個局部變量定義了但沒有賦初值是不能使用的。
操作數棧
JVM解析執行字節碼是基於棧結構的。比如做算術運算時是通過操作數棧來進行的,在調用其他方法時是通過操作數棧來進行參數的傳遞。
方法調用大致過程
- 除非被調用的方法是類方法,每一次方法調用指令之前,JVM先會把方法被調用的對象引用壓入操作數棧中,除了對象的引用之外,JVM還會把方法的參數依次壓入操作數棧。
- 在執行方法調用指令時,JVM會將函數參數和對象引用依次從操作數棧彈出,並新建一個棧幀,把對象引用和函數參數分別放入新棧幀的局部變量表slot0,1,2…。
- JVM把新棧幀push入虛擬機方法棧,並把PC指向函數的第一條待執行的指令。
到此,有人可能會問,JVM是如何得到被調用方法的地址呢?兩種方式,一種是編譯期的靜態綁定,另一種是運行期的動態綁定。不同類型的方法用不同的綁定方式。
方法調用的字節碼指令
JVM裏面提供了4條方法調用字節碼指令。分別如下:
- invokestatic:調用靜態方法
- invokespecial:調用實例構造器
<init>
方法、私有方法和父類方法(super(),super.method()) - invokevirtual:調用所有的虛方法(靜態方法、私有方法、實例構造器、父類方法、final方法都是非虛方法)
- invokeinterface:調用接口方法,會在運行時期再確定一個實現此接口的對象
invokestatic和invokespecial指令調用的方法都可以在解析階段中確定唯一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法4類,它們在類加載階段就會把符號引用解析爲該方法的直接引用。直接引用就是一個指針或偏移量,可以讓JVM快速定位到具體要調用的方法。
invokevirtual和invokeinterface指令調用的方法是在運行時確定具體的方法地址,接口方法和實例對象公有方法可以用這兩個指令來調用。
下面我們通過一個代碼示例來展現這幾種方法調用:
public class Test {
private void run() {
List<String> list = new ArrayList<>(); // invokespecial 構造器調用
list.add("a"); // invokeinterface 接口調用
ArrayList<String> arrayList = new ArrayList<>(); // invokespecial 構造器調用
arrayList.add("b"); // invokevirtual 虛函數調用
}
public static void main(String[] args) {
Test test = new Test(); // invokespecial 構造器調用
test.run(); // invokespecial 私有函數調用
}
}
反編譯字節碼:
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
}
從上面的字節碼可以看出,每一條方法調用指令後面都帶一個Index值,JVM可以通過這個索引值從常量池中獲取到方法的符號引用。
每個class文件都有一個常量池,主要是關於類、方法、接口等中的常量,也包括字符串常量和符號引用。方法的符號引用是唯一標識一個方法的信息結構體,包含類名,方法名和方法描述符,方法描述符又包含返回值、函數名和參數列表。這些字符值都存放到class文件的常量池中,通過整型的Index來標識和索引。
動態分派
當JVM遇到invokevirtual或invokeinterface時,需要運行時根據方法的符號引用查找到方法地址。具體過程如下:
- 在方法調用指令之前,需要將對象的引用壓入操作數棧
- 在執行方法調用時,找到操作數棧頂的第一個元素所指向的對象實際類型,記作C
- 在類型C中找到與常量池中的描述符和方法名稱都相符的方法,並校驗訪問權限。如果找到該方法並通過校驗,則返回這個方法的引用;
- 否則,按照繼承關係往上查找方法並校驗訪問權限;
- 如果始終沒找到方法,則拋出java.lang.AbstractMethodError異常;
可以看到,JVM是通過繼承關係從子類往上查找的對應的方法的,爲了提高動態分派時方法查找的效率,JVM爲每個類都維護一個虛函數表。
虛函數表
JVM實現動態綁定的原理類似於C++的虛函數表機制,但C++的虛函數表是實現多態中必不可少的數據結構,但JVM裏引入虛函數表的目的是加快虛方法的索引。
JVM 會在鏈接類的過程中,給類分配相應的方法表內存空間。每個類對應一個方法表。這些都是存在於方法區中的。這裏與 C++略有不同,C++中每個對象的第一個指針就是指向了相應的虛函數表。而 Java 中每個對象的對象頭有一個類型指針,可以索引到對應的類,在對應的類數據中對應一個方法表。也就是C++的方法表是對象級別的,而Java的方法表是類級別的。
一個類的方法表包含類的所有方法入口地址,從父類繼承的方法放在前面,接下來是接口方法和自定義的方法。如果某個方法在子類中沒有被重寫,那子類的虛方法表裏面的地址入口和父類相同的方法的入口地址一致。如果子類重寫了這個方法,子類方法表中的地址將會替換爲指向子類實現版本的入口地址。
比如對於如下的Foo類:
class Foo {
@Override
public String toString() {
return "Foo";
}
void run(){}
}
它的虛函數表如下:
invokevirtual和invokeinterface的區別
從上面我們可以發現,虛函數表上的虛方法是按照從父類到子類的順序排序的,因此對於使用invokevirtual調用的虛函數,JVM完全可以在編譯期就確定了虛函數在方法表上的offset,或者在首次調用之後就把這個offset緩存起來,這樣就可以快速地從方法表中定位所要調用的方法地址。
然而對於接口類型引用,由於一個接口可以被不同的Class來實現,所以接口方法在不同類的方法表的offset當然就(很可能)不一樣了。因此,每次接口方法的調用,JVM都會搜尋一遍虛函數表,效率會比invokevirtual要低。