1. 程式人生 > >JVM虛擬機器解析——方法過載與重寫

JVM虛擬機器解析——方法過載與重寫

1.什麼是方法的過載

在Java中,同一個類下同名方法如果引數型別相同,是無法通過編譯的。因此,我們在寫同名方法時會使用不同型別(型別、數量、順序)的引數來定義,這種定義方式,我們稱做方法的過載。

在一般情況下,我們會認為同名方法如果有相同的引數型別是不被允許的。而實際上,在位元組碼檔案中,同名且同參數型別而方法返回值不同的方法是可以的,JVM虛擬機器會直接選取第一個方法名以及引數型別匹配的方法。並且,它會根據所選取方法的返回型別來決定可不可以通過編譯,以及需不需要進行值轉換。我們可以通過位元組碼工具繞過編譯器限制,在編譯後的位元組碼檔案上新增這樣的方法。

2.方法過載在編譯器中的邏輯

之所以我們可以做到方法的過載,是因為在編譯的過程中,除了名稱以外,編譯器還會根據被呼叫方法的宣告引數的型別進行匹配。

方法過載在Java虛擬機器中的判定的兩個引數是拆型裝箱和可變長引數。

  1. 不考慮拆裝箱和變長引數的情況下進行匹配,如果匹配不到,執行下個階段
  2. 拆裝箱後再重新進行匹配,如果還不行,執行下個階段
  3. 在考慮是否有可變長引數影響再次進行匹配。

在可變長引數的干擾下,有時不能確定具體呼叫的是哪個方法時,引數是子類的方法會被優先選取。如
void damo(Object obj, Object... args) { ... }
void damo(String s, Object obj, Object... args) { ... }


同時在一個類中時,如果我們呼叫damo(null, 1, 2);就會呼叫第二個方法,因為String是Object的子類。
需要注意的是,過載是一個編譯器的邏輯。

3.方法重寫在Java虛擬機器中的邏輯

Java虛擬機器是通過類名、方法名和方法描述符進行方法的載入的。其中方法描述符是由方法的引數型別和返回值構成的,和剛才我們說的方法過載不同,方法描述符中包含有返回值,這個時候如果同一個類中出現方法名和方法描述符都一樣的方法,java虛擬機器會在驗證階段就報錯。

上文說過,同名同參數型別的方法在編譯時不會通過,但只要返回值不同,它在Java虛擬機器上就可以,就是因為方法描述符中還包含有返回值。
Java虛擬機器在方法重寫的判定上也是通過這樣,通過方法描述符來進行判定的。如果子類中定義了與父類中同名的方法,而且這個方法既非靜態也非私有,且方法的引數型別也與父類中一致,那麼在Java語言中就會被判定成重寫,當然,和方法過載一樣,在Java虛擬機器中,這個判定還需要有一個條件,就是他們的返回值也必須一樣。也就是說,

當子類和父類中方法名和方法描述符都一致時,Java虛擬機器就會判定子類重寫了父類的方法

對於在Java語言中屬於重寫而在Java虛擬機器中不符合重寫的情況,編譯器會通過橋接方法來在虛擬機器中實現Java語言對應的語義。

4.靜態繫結和動態繫結

由於過載在編譯器編譯的階段就已經完成,所以我們可以認為在Java虛擬機器中是不存在過載這一定義的。因此,有的時候人們會將方法過載成為靜態繫結,而方法的重寫被稱為動態繫結。

當然,這個說法並不是那麼嚴謹,畢竟一個類過載的方法還有可能會被它的子類重寫,在這種情況下,它還是會被編譯器判定為一個需要動態繫結的型別。

靜態繫結是在解析的時候就能夠確定對應方法的情況,而動態繫結則是需要在程式執行時根據實際呼叫情況來確定實際呼叫的方法的情況。

具體來說,Java位元組碼檔案中呼叫方法的命令分為5種

  1. invokestatic:呼叫靜態方法
  2. invokespecial:呼叫構造器和私有的例項方法,以及使用super呼叫的父類中對應的方法,和所實現介面的預設方法。
  3. invokevirtual:呼叫非私有的例項方法
  4. invokeinterface:呼叫介面方法
  5. invokedynamic:動態呼叫方法

對於前兩個,invokestatic和invokespecial命令而言,由於靜態方法和構造器都是不能被子類重寫的,所以Java虛擬機器可以直接識別對應的方法。

而對於invokevirtual和invokeinterface命令,首先invokeinterface實現的是介面方法,而介面方法在不加defalut關鍵字(有default關鍵字的是invokespecial命令)的情況下,是沒有方法體的,它必然不會是在這裡進行定義的,而對於invokevirtual命令,上文也說明過,除非被標記為final,這樣的方法是有可能被子類重寫的,我們需要知道呼叫者呼叫的究竟是不是這個類中的方法還需要動態的判斷,所以這個兩個方法都需要在執行時進行動態的判斷。這兩種呼叫,均屬於Java虛擬機器中的虛方法呼叫。

最後一個invokedynamic則涉及到了它所依賴的方法控制代碼,我們會在以後的文章中再詳細的說明。

5.呼叫方法時的符號引用

在方法編譯的過程中,我們是無法知道方法在虛擬機器中的具體地址的,所以編譯器會使用一個符號引用來表示該目標方法,它裡面包括方法所在的類(或介面)的名稱、方法名和方法描述符,儲存在常量池中。

在具體執行時,Java虛擬機器會解析這個符號引用,並替換為實際引用。在上文中我們已經提到過虛擬機器是怎麼在找到類後匹配方法的,所以這裡我們在說一說它是怎麼找到指向的類的。
我們假設有一個符號引用指向A類,那麼它會先在A類中查詢對應的方法,如果其中沒有,則會在他的父類中進行查詢,直到Object。如果仍然沒有,就會在A類直接或間接實現的介面中進行查詢,當然,這樣找到的必須是一個非私有非靜態的方法。
在查詢介面的時候,(比如是I),會先查詢I中有沒有滿足條件的對應方法,如果沒有,則會去檢視該方法是否是Object類的公有例項方法。如果不是,則會去I的超介面中進行尋找。
經過這一過程,靜態繫結會被替換為只想具體方法的指標,而動態繫結這會變成一個方法表的索引。這裡我們就遇到了一個新的名詞,方法表。

6.方法表

在上文我們提到invokevirtual和invokeinterface命令時,我們說過,這兩者都是動態繫結,都屬於虛方法的呼叫,在絕大多數的情況下,都需要虛擬機器通過呼叫者呼叫的實際型別來進行繫結,這個過程相對於不需要進行判定的靜態繫結,就會花費更多的時間。所以很多人會認為虛方法呼叫會犧牲時間從而降低程式碼的效能。
而實際上,Java虛擬機器使用了一種“用空間換時間”的策略,大大減少了虛方法呼叫所耗費的時間,讓它實際執行時並不想我們想的那麼的耗時。這個策略就是方法表。

方法表的本質是一個數組,每個陣列元素都指向一個類和其父類中的非私有例項方法。
之所以說方法表中的元素指向的是類和其父類的方法,是因為子類方法表中包含父類方法表中的所有的方法,如果子類方法重寫了父類方法,那麼他的索引值和父類中被重寫的方法的索引值一致。
這個索引值在就是呼叫方法時符號引用中儲存的那個索引值。在實際執行的過程中,呼叫方法時會根據這個索引值在對應的類的方法表中查詢這個索引值對應的方法(這個過程就是動態繫結)。

具體來說,當一個類中的一個方法被呼叫時,虛擬機器會先檢視這個被呼叫的方法是那個類中的方法,然後查詢對應類的方法表,根據索引值查詢具體是哪一個方法被呼叫了。

使用方法表的動態繫結實際上只比靜態繫結多了幾個虛擬機器的記憶體解引用操作,就是將索引值替換為實際方法的操作。相對於初始化Java棧幀來說小的幾乎可以忽略不計。
但是這種情況我們是否可以說是沒有影響呢?事實上,不能。上面的情況只是在解釋執行中,或即時編譯中的最壞的情況。我們還有更好的優化方法。就是內聯快取和方法內聯。

7.內聯快取

內聯快取是一種提高動態繫結效率的優化技術。它能夠快取虛方法呼叫中呼叫的實際型別和對應的目標方法,在之後的過程中如果再次遇到已經快取的,會直接呼叫快取中型別的對應的方法,如果沒有快取才會執行上文中說明的基於方法表的動態繫結。

我們可以將內聯快取分為單態快取、多型快取和超多型快取。

單態,即只有一種狀態的情況,所以單態內聯快取就是說內聯快取只快取了一種動態型別以及它對應的目標方法,它的實現很容易,與快取的動態型別進行比較,如果一致,則直接呼叫對應的目標方法。

多型,則是指有限數量種狀態的情況,它與超多型的則是根據一個數值進行區分。而多型混村則快取了多個動態型別和目標方法,我們需要進行逐個的對比才能知道具體去呼叫哪個對應方法。

為了達到最優,我們會將呼叫越多的方法放到越前面,這時,大部分的虛方法呼叫時單態的,也就是隻有一種動態型別,所以為了節省記憶體空間,一般Java虛擬機器只是用單態內聯快取。

因此,當呼叫方法與單態內聯快取中的方法不一致時,我們就會使用方法表動態繫結機制。

對於這種情況,我們有兩種選擇,一種是替換單態內聯快取中快取的記錄,另一種則是裂化為超多太,這也是Java虛擬機器所才有的方法,在這種狀態下的內聯快取與替換記錄的方法相比犧牲了優化的機會,但節省了重複寫快取的開銷。

可能有的朋友不是很清楚這個“寫快取”的開銷具體有多大。這麼說吧,如果我們使用單態內聯快取,第一次我們呼叫了方法A,這個時候我們快取中的就是A,之後我們又呼叫B,將記憶體中的A替換為B,而此時如果我們又呼叫了A,那麼我們還需要把A再替換回去。如此重複多次還不如一開始就不快取,直接分別呼叫A和B。

需要明確的是,內聯快取雖然有名字裡有內聯,但並沒有實際的新建棧幀和壓棧彈棧。這表示,你在實際的呼叫中,還是需要進行這些,而不是使用快取中的棧幀,這些操作的開銷仍然存在,因此我們還可以進行進一步的優化。內聯快取消除的僅僅是方法呼叫的開銷,而不是棧幀結構和站操作的開銷。