@
目錄- 前言
- 正文
- 決議
- 分派
- 靜態分派
- 動態分派
- 單分派和多分派
- 動態分派的實作
- Lambda運算式的實作原理
- MethodHandle
- 總結
前言
在最開始講解JVM記憶體結構的時候有簡單分析過方法的執行原理——每一次方法呼叫都會生成一個堆疊幀并壓入堆疊中,方法鏈的執行就是一個個堆疊幀彈出堆疊的程序,本篇就從位元組碼層面詳細分析方法的呼叫細節,
正文
決議
Java中方法的呼叫對應位元組碼有5條指令:
- invokestatic:用于呼叫靜態方法,
- invokespecial:用于呼叫實體構造器<init>方法、私有方法和父類中的方法,
- invokevirtual:用于呼叫所有的虛方法,
- invokeinterface:用于呼叫介面方法,會在運行時再確定一個實作該介面的物件,
- invokedynamic:先在運行時動態決議出呼叫點限定符所參考的方法,然后再執行該方法,
invokedynamic與前4條指令不同的是,該指令分派的邏輯是由用戶指定,用于支持動態型別語言特性(相關概念后文會詳細描述),
Java中有非虛方法和虛方法,前者是指在決議階段可以確定的唯一的呼叫版本,如靜態方法、構造器方法、父類方法(特指在子類中使用super呼叫,而不是在客戶端使用物件參考呼叫)、私有方法(上述幾種方法是使用invokestatic和invokespecial指令呼叫的)以及被final修飾的方法(使用invokevirtual呼叫),這些方法在類加載階段就會把方法的符號參考決議為直接參考;除此之外的都是虛方法,虛方法則只能在運行期進行分派呼叫,
分派
分派分為靜態和動態,同時還會根據宗量數(可以簡單理解為影響方法選擇的因素,如方法的接收者和引數)分為靜態單分派、靜態多分派、動態單分派、動態多分派,
靜態分派
靜態分派就是指根據靜態型別(方法中定義的變數)來決定方法執行版本的分派動作,Java中典型的靜態分派就是方法多載,下面先來看段代碼示例:
public class StaticDispatch{
static abstract class Human{}
static class Man extends Human{ }
static class Woman extends Human{}
public void sayHello(Human guy){
System.out.println("hello,guy!");
}
public void sayHello(Man guy){
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy){
System.out.println("hello,lady!");
}
public static void main(String[] args) {
StaticDispatch sr = new StaticDispatch();
Human man = new Man();
Human woman = new Woman();
sr.sayHello(man);
sr.sayHello(woman);
}
}
下面的結果是否跟你想的是否一樣呢?
hello,guy!
hello,guy!
這里全都是呼叫的引數為Human型別的方法,原因就是在main方法中定義的變數型別都是Human,這個就屬于靜態型別,而等于后面的物件則屬于實際型別,實際型別只能在運行期間獲取到,因此編譯器在編譯階段時只能根據靜態型別選取到對應的方法,所以這里列印的都是"hello,guy!",
不過不要想當然的認為靜態型別就只會匹配到一個唯一的方法,如果有自動拆、裝箱,變長引數,向上轉型等引數,就可以匹配到多個,不過它們是存在優先級關系的,
動態分派
Java里面的動態分派與它的多型性息息相關,即方法重寫,如下面的代碼:
public class DynamicDispatch {
static abstract class Virus{ //病毒
protected abstract void ill();//生病
}
static class Cold extends Virus{
@Override
protected void ill() {
System.out.println("感冒了,好不舒服!");
}
}
static class CoronaVirus extends Virus{//冠狀病毒
@Override
protected void ill() {
System.out.println("粘膜感染,空氣傳播,請帶好口罩!");
}
}
public static void main(String[] args) {
Virus clod=new Cold();
clod.ill();
clod = new CoronaVirus();
clod.ill();
}
}
這里的輸出結果相信大家都清楚,但你是否深入考慮過它的呼叫細節呢?先來看看位元組碼:
public static void main(java.lang.String[]);
Code:
0: new #2 // class ex8/DynamicDispatch$Cold
3: dup
4: invokespecial #3 // Method ex8/DynamicDispatch$Cold."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method ex8/DynamicDispatch$Virus.ill:()V
12: new #5 // class ex8/DynamicDispatch$CoronaVirus
15: dup
16: invokespecial #6 // Method ex8/DynamicDispatch$CoronaVirus."<init>":()V
19: astore_1
20: aload_1
21: invokevirtual #4 // Method ex8/DynamicDispatch$Virus.ill:()V
24: return
可以看到呼叫方法時都是通過invokevirtual指令呼叫的,但注釋顯示兩次呼叫的常量池以及符號參考都是一樣的,那為什么就會產生不同的結果呢?在《Java虛擬機規范》中規定了invokevirtual的呼叫邏輯:
- 找到運算元堆疊頂的第一個元素所指向的物件的實際型別,記作C,
- 如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則回傳這個方法的直接參考,查找程序結束;不通過則回傳java.lang.IllegalAccessError例外,
- 否則,按照繼承關系從下往上依次對C的各個父類進行第二步的搜索和驗證程序,
- 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError例外.
這里面第一步就是在運行期間找到接收者的實際型別,在真正呼叫方法時就是根據這個型別進行呼叫的,所以會產生不同的結果,不過需要注意的是欄位不存在多型的概念,即invokevirtual指令對欄位是無效的,當子類宣告與父類同名的欄位時,就會掩蓋父類中的欄位,如下面的代碼:
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}
輸出結果如下:
I am Son, i have $0
I am Son, i have $4
This gay has $2
在創建Son物件時,首先會呼叫父類的構造器,而父類構造器又呼叫了showMeTheMoney方法,該方法會呼叫子類的版本,對應的拿到的欄位也是子類中的,而此時子類構造器還沒有執行,所以輸出的money是0,但最后根據gay的靜態型別輸出money是2,即沒有拿到運行中的實際型別,所以Java中欄位是不存在動態分派的,
這里的解釋看似合情合理,但仍然有一個問題,呼叫子類構造器首先會呼叫父類構造器,也就是說這時候子類還沒有初始化完成,那為什么父類就可以呼叫子類的實體方法呢?這時候可以反編譯main的位元組碼看看:
public static void main(java.lang.String[]);
Code:
0: new #2 // class ex8/Test$Son
3: dup
4: invokespecial #3 // Method ex8/Test$Son."<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: new #5 // class java/lang/StringBuilder
14: dup
15: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
18: ldc #7 // String This gay has $
20: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: aload_1
24: getfield #9 // Field ex8/Test$Father.money:I
27: invokevirtual #10 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
30: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
33: invokevirtual #12 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
36: return
}
重點看到第一句,首先就是呼叫new位元組碼創建物件并將其參考值壓入堆疊頂,也就是說在呼叫構造方法之前物件在記憶體中已經分配好了,所以在父類構造器中可以呼叫子類的實體方法,這個其實在之前的物件創建章節已經講過了,現在就串在一起了,
單分派和多分派
Java是一門靜態多分派,動態單分派的語言,讀者如果充分理解了上文,這里是非常好理解的,再來看一段代碼:
public class Dispatch {
static class QQ{}
static class WX{}
public static class Father{
public void hardChoice(QQ arg){
System.out.println("father choose qq");
}
public void hardChoice(WX arg){
System.out.println("father choose weixin");
}
}
public static class Son extends Father{
public void hardChoice(QQ arg){
System.out.println("son choose qq");
}
public void hardChoice(WX arg){
System.out.println("son choose weixin");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new WX());
son.hardChoice(new QQ());
}
}
通過這段代碼,我們可以看出,在編譯階段選取方法有兩個影響因素:一是需要看靜態型別是Father還是Son,二是方法引數,所以Java中靜態分派屬于靜態多分派,而在運行階段,呼叫的方法簽名是已經確定了的,即不管引數的實際型別是“騰訊QQ”還是“奇瑞QQ”,走的都是hardChoice(QQ arg)方法,唯一的影響就是該方法的實際接收者,所以Java中的動態分派屬于動態單分派,
動態分派的實作
說了這么多,虛擬機到底是怎么實作動態分派的呢?不可能在整個方法區去搜索尋找,那樣效率是非常低的,實際上虛擬機在方法區會為每個型別建立一個虛方法表(支持invokevirtual 指令)以及介面方法表(支持invokeinterface指令),如下圖:

方發表中存的是各個方法的實際入口地址,如果子類沒有重寫父類中的方法,那么父子類指向同一個地址,否則,子類就會指向自己重寫后的方法入口地址,
Lambda運算式的實作原理
java8增加了對Lambda運算式的支持:
public static void main(String[] args) {
Runnable r = () -> System.out.println("Hello Lambda!");
r.run();
}
上面代碼是Lambda運算式最簡單的運用,有沒有想過它的底層是怎么實作的呢?直接用javap -v命令反編譯看看:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
}
SourceFile: "LambdaDemo.java"
InnerClasses:
public static final #57= #56 of #60; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#28 ()V
#29 invokestatic ex8/LambdaDemo.lambda$main$0:()V
#28 ()V
我刪掉了不重要的部分,可以看到Lambda的呼叫時通過invokedynamic指令實作的,另外從位元組碼中我們可以看到會生成Bootstrap Method引導方法,該方法存在于BootstrapMethods屬性中,這個是JDK1.7新加入的,從這個屬性我們可以發現Lambda運算式的最終是通過MethodHandle方法句柄來實作的,虛擬機會執行引導方法并獲得回傳的CallSite物件,通過這個物件最終呼叫到我們自己實作的方法上,
Lambda還分為捕獲和非捕獲,當從運算式外部獲取了非靜態的變數時,這個運算式就是捕獲的,反之就是非捕獲的,如下面兩個方法:第一個方法就是非捕獲的,第二個是捕獲的,
public static void repeatMessage() {
Runnable r = () -> {
System.out.println("Hello Lambda!");
};
}
public static void repeatMessage(String msg, int num) {
Runnable r = () -> {
for (int i = 0; i < num; i++) {
System.out.println(msg);
}
};
}
非捕獲的比捕獲的Lambda運算式性能更高,因為前者只需要計算一次,而后者每次都要重新計算,但無論如何,最差的情況下和內部類性能也是差不多的,所以盡量使用非捕獲的Lambda運算式,
關于Lambda的實作就講解到這,下面主要來看看MethodHandle的使用,
MethodHandle
var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..}
for(item in arrays){
item.sayHello();
}
上面這段代碼在動態型別語言(型別檢查的主體程序是在運行期而不是編譯期進行)中是沒有什么問題的,但是在Java中實作的話就會產生很多副作用,比如額外的性能開銷(陣列中每個型別都不一樣,就會導致方法行內失去它本來的作用,還會帶來更大的負擔),因此JDK1.7新加入invokedynamic指令和java.lang.invoke包,MethodHandle就存在于該包中,這個包的主要目的是在之前單純依靠符號參考來確定呼叫的目標方法這條路之外,提供一種新的動態確定目標方法的機制,下面來看看MehtodHandler的使用:
public class MethodHandleDemo {
static class Bike {
String sound() {
return "Bike sound";
}
}
static class Animal {
String sound() {
return "Animal sound";
}
}
static class Man extends Animal {
@Override
String sound() {
return "Man sound";
}
String listen() {
return "listen";
}
}
String invoke(Object o, String name) throws Throwable {
//方法句柄
MethodHandles.Lookup lookup = MethodHandles.lookup();
// MethodType:代表“方法型別”,包含了方法的回傳值(methodType()的第一個引數)和具體引數(methodType()第二個及以后的引數),
MethodType methodType = MethodType.methodType(String.class);
// 在指定類中查找符合給定的方法名稱、方法型別,并且符合呼叫權限的方法句柄,
MethodHandle methodHandle = lookup.findVirtual(o.getClass(), name, methodType);
String obj = (String) methodHandle.invoke(o);
return obj;
}
public static void main(String[] args) throws Throwable {
String str = new MethodHandleDemo().invoke(new Bike(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Animal(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Man(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Man(), "listen");
System.out.println(str);
}
}
MethodType是用于指定方法的回傳型別和引數,然后通過MethodHandles.Lookup模擬位元組碼的呼叫,因此對應的有findVirtual、findStatic、findSpecial等方法,這些方法就會回傳一個MethodHandle的物件,最終通過這個物件的invoke或者invokeExact方法就能呼叫實際想要呼叫的物件方法(這里需要注意的是前者是松散匹配,即可以自動轉型,而后者則必須是精確匹配,引數回傳值型別都必須一樣,否則就會報錯),
通過上面的代碼我們知道,在運行中不論實際型別是什么,只要有方法簽名以及回傳值能對應上,就能呼叫成功,相當于動態的替換了符號參考中的靜態型別部分,也解決了動態語言對方法行內等編譯優化的不良影響,
另外我們可以發現MethodHandle在功能和使用上都和反射差不多,但是使用更加簡單,也更輕量級,對應的性能也比反射要高,
總結
靜態分派和動態分派在Java中都是支持的,并且是靜態多分派,動態單分派;深刻理解分派的原理以及方法的分派規則,才能更好的理解程式的運行程序,另外為什么會出現MethodHandle類,它能給我們帶來哪些便利,熟悉并掌握可以讓我們寫出更靈活的程式,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/102090.html
標籤:Java
上一篇:設計模式之禪(六大設計原則)
下一篇:MAVEN安裝與配置
