引言
執行引擎子系統是JVM的重要組成部分之一,在JVM系列的開篇曾提到:JVM是一個架構在平臺上的平臺,虛擬機是一個相似于“物理機”的概念,與物理機一樣,都具備代碼執行的能力。但虛擬機與物理機最大的不同在于:物理機的執行引擎是直接建立在處理器、高速緩存、平臺指令集與操作系統層面上的,物理機的執行引擎可以直接調用各處資源對代碼進行直接執行,而虛擬機則是建立在軟件層面上的平臺,它的執行引擎則是負責解釋編譯執行自身定義的指令集代碼。同時,也正因Java設計出了JVM虛擬機的結構,從而才使得Java可以不受物理平臺限制,能夠真正實現“一次編譯,到處執行”的理念。
對于執行引擎這塊的知識,對于理解JVM是有很大幫助的,但JVM相關現有的文章/書籍資料對這塊卻少有提及或者泛泛而談,本篇文章則是準備對JVM的執行引擎子系統進行全面的闡述。
一、機器碼、指令集與匯編語言、高級語言的關系
在準備對JVM的執行引擎進行分析之前,首先得搞明白機器碼、指令集、匯編語言以及高級語言之間的關系,只有當搞清楚這幾者之間的關系后才能更好的弄懂JVM的執行引擎原理。
1.1、機器碼
機器碼也被稱為機器指令碼,也就是指各種由二進制編碼方式表示的指令(011101、11110等),最開始的程序員就是通過這種方式編寫程序,用這種方式編寫出的代碼可以直接被CPU讀取執行,因為最貼近硬件機器,所以也是執行速度最快的指令。但因為這種指令和CPU之間是緊緊相關的,所以不同種類的CPU對應的機械指令也不同。同時,機械指令都是由二進制數字組成的指令,對于人來說,實在太過繁雜、難以理解且不容易記憶,容易出錯,最終指令的方式代替了這種編碼方式。
1.2、指令與指令集
由于機器碼都是由0和1組成的指令代碼,可讀性實在太差,所以慢慢的推出了指令,用于替代機器碼的編碼方式。指令是指將機械碼中特定的0和1組成的序列,簡化為對應的指令,如INC、DEC、MOV等,從可讀性上來說,對比之前的二進制序列組成的機器碼要好上許多。但由于不同的硬件平臺的組成架構也不同,所以往往在執行一個指令操作時,對應的機器碼也不同,所以不同的硬件平臺就算是同一個指令(如INC),對應的機器碼也不同。
同時,正是因為不同的硬件平臺支持的指令是有些稍許不同的,所以每個平臺所支持的指令則被稱為對應平臺的指令集。比如X86架構平臺對應的X86指令集、ARM架構平臺對應的ARM指令集等。
1.3、匯編語言
前面雖然通過了指令和指令集的方式替代了之前由0和1序列組成的機器碼,但指令的可讀性相對來說還是比較差的,所以人們又發明了匯編語言。在匯編語言中,用助記符(Mnemonics)代替機器指令的操作碼,用地址符號(Symbol)以及標號(Label)代替指令或操作數的地址。在不同的平臺,匯編代碼對應不同的指令集,但由于計算機只認機器碼,所以通過匯編語言編寫的程序必須還要經過匯編階段,變為計算機可識別的機器指令碼才可執行。
1.4、高級語言
為了使得開發人員編寫程序更為簡易一些,后面就涌現了各種高級語言,如Java、Python、Go、Rust等。高級語言對比之前的機器碼、指令、匯編等方式,可讀性更高,代碼編寫的難度更低。但通過高級語言編寫出的程序,則需要先經過解釋或編譯過程,先翻譯成匯編指令,然后再經過匯編過程,轉換為計算機可識別的機器指令碼才能執行。
OK~,簡單的敘述了一下機器碼、指令集與匯編語言、高級語言的關系,從這段闡述中可以得知,Java屬于一門高級語言,在執行的時候需要將它編寫的代碼先編譯成匯編指令,再轉換為機械指令才能被計算機識別。但似乎我們在使用Java過程中,好像沒有這個過程呀?這樣因為什么原因呢?
這是因為Java存在JVM這個虛擬平臺,JVM的主要任務是負責將javac編譯后生成的字節碼文件裝載到其內部,但字節碼并不能夠直接運行在操作系統之上,因為字節碼指令并非等價于本地機器指令,它內部包含的僅僅只是一些能夠被JVM所識別的字節碼指令、符號表和其他輔助信息,這些Java字節碼指令是無法直接被OS識別的。那么一個Java程序可以在操作系統上跑起來的根本原因在于什么呢?答案是:依靠于JVM的執行引擎子系統。
二、初窺JVM執行引擎與源碼編譯原理
Java的執行引擎子系統的主要任務是將字節碼指令解釋/編譯成對應平臺上的本地機器指令,簡單來說,JVM執行引擎是充當Java虛擬機與操作系統平臺之間的“翻譯官”的角色。
而目前主要的執行技術有:解釋執行、靜態編譯、即時編譯、自適應優化、芯片級直接執行,釋義如下:
- 解釋執行:程序在運行過程中,只有當每次用到某處代碼時,才會將某處代碼轉換為機器碼交給計算機執行。
- 靜態編譯:所謂的靜態編譯是指程序在啟動前,先根據對應的硬件/平臺,將所有代碼全部編譯成對應平臺的機器碼。
- 即時編譯:程序運行過程中,通過相關技術(如HotSpot中的熱點探測)動態的探測出運行比較頻繁的代碼,然后在運行過程中,將這些執行比較頻繁的代碼轉換機械碼并存儲下來,下次執行時則直接執行機器碼。
- 自適應優化:開始對所有的代碼都采取解釋執行的方式,并監視代碼執行情況,然后對那些經常調用的方法啟動一個后臺線程,將其編譯為本地代碼,并進行仔細優化。若方法不再頻繁使用,則取消編譯過的代碼,仍對其進行解釋執行。
- 芯片級直接執行:也就是直接編寫機器碼的方式,編寫出的代碼可以直接被CPU識別,讀取后可以直接執行。
如上便是現有的一些執行技術,在其中解釋執行屬于第一代JVM,即時編譯JIT屬于第二代JVM,自適應優化(目前Sun的Hotspot采用這種技術)則吸取第一代JVM和第二代JVM的經驗,采用兩者結合的方式。而靜態編譯的技術在BEA公司的JRockit虛擬機以及JDK9的AOT編譯器中都實現了,這樣做的好處在于:執行性能堪稱最佳,但缺點在于:啟動的時間會很長,同時也打破了Java“一次編譯,到處運行”的原則。
其實在Java剛誕生時,JDK1.0的時候,Java的定位是一門解釋型語言,也就是將Java程序編寫好之后,先通過javac將源碼編譯為字節碼,再對生成的字節碼進行逐行 解釋執行 。但這樣就導致了程序執行速度比較緩慢,啟動速度也并不樂觀,因為啟動時需對于未編譯的.java文件進行編譯,而且編譯之后生成的字節碼指令也不能被計算機識別,還需要在執行時再經過一次 解釋 后,才能變為計算機可識別的機器碼指令,從而才能使得代碼被機器執行。
經過如上分析,JDK1.0時的這種解釋執行的缺點非常明顯,Java為了做到“一次編譯,到處運行”這個準則,將程序的綜合性能大大拉低了,為什么呢?因為對比其他語言多了一個步驟。一般來說,一個Java程序想要運行,必須要經過 先編譯,再解釋 的過程才可以真正的執行。而我們此時再來看看其他語言的執行。純編譯型語言:在程序啟動時,將編寫好的源碼全部編譯為所處平臺的機械碼指令。
特點:執行性能最佳,啟動時間較長,移植性差,不同平臺需要重新發包。
純解釋型語言:在程序運行過程中,需要執行某處代碼時,再將該代碼解釋為平臺對應的機械碼指令,然后交由計算機執行。
特點:啟動速度快,執行性能較差,移植性較好。
OK~,簡單的看了一下解釋型和編譯型的語言特點之后,再回過頭來想想1.0版本的Java,是不是發現Java因為虛擬機的存在,搞的不上不下的,卡在了中間。因為在Java程序運行時,既要編譯源碼,又要解釋執行,所以最終導致執行性能一般,啟動速度也一般。
再到后來,Java為了解決這個問題,在1.2的時候推出了一款后端編譯器,也就是JIT即時編譯器(后面分析),它可以支持在Java在執行過程中動態生成本地的機械碼。現代的高性能JVM都是采用解釋器與即使編譯器共存的模式工作,所以Java也被稱為“半解釋半編譯型語言”。
而本篇則會基于目前的HotSpot虛擬機對JVM的執行引擎進行分析,它的執行引擎中也采用解釋器與即使編譯器共存的模型工作,但這款虛擬機的執行模式采用的是 自適應優化 方案執行。
2.1、執行引擎工作過程
對于執行引擎而言,在《虛擬機規范》中曾提到了,要求所有廠商在實現時,輸入輸出都必須一致,也就是執行引擎接受的輸入內容必須為字節碼的二進制流數據,而輸出的則必須為程序的執行結果。而執行引擎到底需要執行什么操作,完全是依賴與PC寄存器(程序計數器)的,每當執行引擎處理完一項指令操作后,程序計數器就需要更新下一條需要被執行的指令地址。
在執行Java方法過程中,執行引擎也有可能會根據棧幀中操作數棧的引用信息,直接去訪問存儲在堆中的Java對象實例數據,也有可能會通過實例對象的對象頭中記錄的元數據指針(KlassWord)去定位對象的類型信息,也就是會通過元數據指針去訪問元數據空間(方法區)中的數據。如下圖:
2.1.1、Java源碼編譯過程
在之前提及過,JVM只識別字節碼文件,所以當編寫好.java
后綴的Java源碼時,我們往往還需要通過javac
這樣的源碼編譯器(前端編譯器),對Java代碼進行編譯生成.class
后才能被JVM裝載進內存,源碼編譯過程如下:
編譯是指將一種語言規范轉化成另外一種語言規范,通常編譯器都是將便于人理解的語言規范(編程語言)轉化成機器容易理解的語言規范(由二進制序列組成的機械碼)。比如C/C++或匯編語言都是將源代碼直接編譯成目標機器碼。
javac作為Java語言的源碼編譯器,它編譯的目的卻不是為了針對于某個硬件平臺進行編譯的,而是為JVM進行編譯,javac的任務就是將Java源代碼轉換為JVM可識別的字節碼,也就是.java
文件到.class
文件的過程。對于怎么消除不同種類,不同平臺之間的差異這個任務就交由JVM來處理,由JVM中的執行引擎來負責將字節碼指令翻譯成當前程序所在平臺可識別的機械碼指令。
javac編譯過程具體釋義如下:
- ①詞法分析:先讀取源代碼的字節流數據,然后根據源碼語言的語法規則找出源代碼中的定義的語言關鍵字,如
if、else、while、for
等,然后判斷這些關鍵字的定義是否合法,對于合法的關鍵字生成用于語法分析的記號序列,同時創建符號表,將
所有的標識符記錄在符號表中,這個過程就被稱為詞法分析。- 符號表的作用:記錄源代碼中使用的標識符,收集每個表示符的各種屬性信息。
- 詞法分析的結果:從源代碼中找出一些合法的
Token
流,生成記號序列。
- ②語法分析:對詞法分析后得到的
Token
流進行語法分析,就是依據源程序的語法規則,檢查這些關鍵詞組合在一起是否符合Java語言規范,比如if的后面是不是緊跟著一個布爾型判斷表達式、else是否寫在if后面等。對于符合規范的,組織上一步產生的記號序列生成語法樹。- 語法分析的結果:形成一顆符合Java語言規定的抽象語法樹。抽象語法樹是一個結構化的語法表達形式,它的作用是把語言的主要詞法用一個結構化的形式組織在一起,這棵語法樹可以被后面按照新的規則再重新組織。
- ③語義分析:經過語法分析后就不存在語法錯誤這些問題了,語義分析主要任務有兩個,一個是對上步產生的語法樹進行檢查,其中包括類型檢查、控制流檢查、唯一性檢查等,第二個則是將一些復雜的語法轉換為更簡單的語法,相當于把一些文言文、古詩、成語翻譯成大白話的意思。比如將
foreach
轉化為for
循環、循環標志位替換為break
等。- 語義分析的結果:簡化語法后會生成一棵語法樹,這棵語法樹也就更接近目標語言的語法規則。
- ④字節碼生成:將簡化后的語法樹轉換為
Class
文件的格式,也就是在該階段會根據簡化后的語法樹生成字節碼。- 字節碼生成的結果:生成符合虛擬機規范的字節碼數據。
經過如上過程后,編寫程序時的.java
源代碼文件會被轉換.class
字節碼文件,然后這些字節碼會在啟動時,被虛擬機的類加載機制裝載進內存,當程序運行過程中,調用某個方法時,就會將對應的字節碼指令交由執行引擎處理。
總的來說,Java代碼執行的過程會主要分為三個階段,分別為:源碼編譯階段、類加載階段以及類代碼(字節碼)執行階段,接著我們再來分析一下執行階段的過程。
2.1.2、執行引擎執行過程
被加載進內存的字節碼最終執行是由執行引擎來負責的,但JVM的執行引擎并不能真正的執行字節碼指令,而是將字節碼指令翻譯成本地機械指令交由物理機的執行引擎來真正的執行的。整體流程如下:
一般而言,在字節碼被加載進內存之后,都會經過如上幾個步驟才會被翻譯成本地的機械指令執行,但這幾個優化步驟卻并不是必須的,如果不需要也可以在程序啟動時通過JVM參數關閉。但綜合而言,雖然優化的過程會耗費一些時間,但這樣卻能夠大大的提升程序在執行時的速度,所以總歸而言利大于弊。
OK~,從上圖中可以看出,執行引擎的入口的數據是字節碼文件,而在HotSpot虛擬機中對于Class文件結構的定義如下:
struct ClassFile {
u4 magic; // 識別Class文件格式,具體值為0xCAFEBABE
u2 minor_version; // Class文件格式副版本號
u2 major_version; // Class文件格式主版本號
u2 constant_pool_count; // 常量表項個數
cp_info **constant_pool; // 常量表,又稱變長符號表
u2 access_flags; // Class的聲明中使用的修飾符掩碼
u2 this_class; // 常數表索引,索引內保存類名或接口名
u2 super_class; // 常數表索引,索引內保存父類名
u2 interfaces_count; // 超接口個數
u2 *interfaces; // 常數表索引,各超接口名稱
u2 fields_count; // 類的域個數
field_info **fields; // 域數據,包括屬性名稱索引
u2 methods_count; // 方法個數
method_info **methods; // 方法表:包括方法名稱索引/方法修飾符掩碼等
u2 attributes_count; // 類附加屬性個數
attribute_info **attributes; // 類附加屬性數據,包括源文件名等
};
任何.java
后綴的Java源碼經過編譯后都會生成為符合如上格式的class
字節碼文件。執行引擎接收的輸入格式也為如上格式的class
文件,不過值得注意一提的是:JVM不僅僅只接收.java
文件編譯成的.class
文件,對于所有符合如上格式規范的字節碼文件都可以被JVM接收執行。
HotSpot虛擬機是基于棧式的,也就代表著執行引擎在執行方法時,執行的是一個個的棧幀,棧幀中包含局部變量表、操作數棧、動態鏈接以及方法返回地址等描述方法的相關信息。但執行引擎在虛擬機運行時,只會執行最頂層的棧幀,因為最頂層的棧幀是當前需要執行的方法,執行完當前方法后會彈出頂部的棧幀,然后將下一個棧幀(新的頂部棧幀)拿出繼續執行。
剛剛提到了方法的相關信息被存儲在棧幀中,而棧幀的方法信息是從class
字節碼文件中讀出來的,每個方法通過結構體method_info
來描述,如下:
struct method_info
{
u2 access_flags; //方法修飾符掩碼
u2 name_index; //方法名在常數表內的索引
u2 descriptor_index; //方法描述符,其值是常數表內的索引
u2 attributes_count; //方法的屬性個數
attribute_info **attributes; //方法的屬性表(局部變量表)
};
在method_info
中存在一個attribute_info
類型的成員attributes
,該成員就是平時所說的局部變量表,其內也存放著方法參數和方法內的局部變量,當方法是實例方法時,局部變量表的第0位會被用來傳遞方法所屬對象的引用,即this
。Java虛擬機執行引擎是基于棧式的,棧就是操作數棧,操作數棧的深度也是記錄在方法屬性集合的Code
屬性中,同時attributes
成員中也記錄著局部變量表所需的空間大小。
下面來個簡單的例子感受一下執行引擎執行的過程:
/* ------Java代碼------ */
public int add(){
int a = 3;
int b = 2;
int c = a + b;
return c;
}
/* ------javap -c -v -p 查看到的字節碼(省略描述方法的字節碼)------ */
0: iconst_3 // 將3放入操作數棧頂
1: istore_1 // 寫出操作數棧頂部元素,并將其放在局部變量表中索引為1的位置
2: iconst_2 // 將2放入操作數棧頂
3: istore_2 // 寫出操作數棧頂部元素,并將其放在局部變量表中索引為2的位置
4: iload_1 // 從局部變量表中加載索引位置=1的數據值
5: iload_2 // 從局部變量表中加載索引位置=2的數據值
6: iadd // 彈出操作棧頂的兩個元素并進行 加 操作(3 + 2)
7: istore_3 // 將加之后的結果刷寫到局部變量表中索引為3的位置
8:iload_3 // 從局部變量表中加載索引位置=3的數據值
8: ireturn // 將加載的c返回
對于如上過程中,前四條分配指令就不分析了,重點分析一下后面的運算過程,也就是c=a+b
這個過程,具體執行如下:
- ①數據
a
從局部變量表經過總線傳輸到操作數棧 - ②數據
b
從局部變量表經過總線傳輸到操作數棧 - ③數據
a
從操作數棧經過總線傳輸給CPU
- ④數據
b
從操作數棧經過總線傳輸給CPU
- ⑤
CPU
計算完成后,將結果通過數據總線傳輸到操作數棧 - ⑥運算結果從操作數棧經過總線傳輸到
CPU
- ⑦
CPU
將數據經過總線傳輸到局部變量表賦值給c
- ⑧將計算后的結果從局部變量表索引為3的位置加載到操作數棧
- ⑨最后使用
ireturn
指令將計算后的結果c
返回給方法的調用者
如上便是棧式虛擬機的執行過程,其中所提到的局部變量表會在編譯器確定長度,也就是等于一個
this
加上三個局部變量,長度最終為4。當程序執行到方法定義的那行代碼時,局部變量表中會被依次填入數據:this、3、2
,同時程序計數器會跟著代碼的執行位置不斷更新,當執行完add
操作后,會將數據a+b
的結果5
再填入局部變量表。
三、詳解JVM執行引擎子系統
在第二階段,咱們簡單的分析了一下Java代碼的編譯過程以及執行過程,同時在前面也提到了,Java是使用解釋器+編譯器共存的模式工作的,也就代表著JVM執行引擎子系統中,是包含了解釋器和編譯器的,如下圖:
Java虛擬機的執行引擎子系統中包含兩種執行器,分別為解釋器和即時編譯器。當執行引擎獲取到由javac編譯后的
.class
字節碼文件后,在運行時是通過解釋器(Interpreter)轉換成最終的機械碼執行。另外為了提升效率,JVM加入了一種名為 JIT即時編譯 的技術,即時編譯器的目的是為了避免一些經常執行的代碼被解釋執行,JIT會將整個函數編譯為平臺本地的機械碼,從而在很大程度上提升了執行的效率。
3.1、解釋器(Interpreter)
當Java程序運行時,在執行一個方法或某處代碼時,會找到.class
文件中對應的字節碼,然后會根據定義的規范,對每條需執行的字節碼指令逐行解釋,將其翻譯成平臺對應對應的本地機械碼執行。當一條字節碼指令被解釋執行完成后,緊接著會再根據PC寄存器(程序計數器)中記錄的下一條需被執行指令,讀取并再次進行解釋執行操作。
在HotSpot虛擬機中,解釋器主要由Interpreter模塊和Code模塊構成,Interpreter模塊實現了解釋執行的核心功能,Code模塊主要用于管理解釋器運行時生成的本地機械指令。
3.2、JIT即時編譯器(Just In Time Compiler)
由于解釋器實現簡單,并且具備非常優異的跨平臺性,所以現在的很多高級語言都采用解釋器的方式執行,比如Python、Rust、JavaScript
等,但對于編譯型語言,如C/C++、Go
等語言來說,執行的性能肯定是差一籌的,而前面不止一次提到過:Java為了解決性能問題,所以采用了一種叫做JIT即時編譯的技術,也就是直接將執行比較頻繁的整個方法或代碼塊直接編譯成本地機器碼,然后以后執行這些方法或代碼時,直接執行生成的機器碼即可。
OK~,那么對于上述中 執行次數比較頻繁的代碼 判斷基準又是什么呢?答案是:熱點探測技術。
3.3、熱點代碼探測技術
HotSpot VM的名字就可以看出這是一款具備熱點代碼探測能力的虛擬機,所謂的熱點代碼也就是指調用次數比較多、執行比較頻繁的代碼,當某個方法的執行次數在一定時間內達到了規定的閾值,那么JIT則會對于該代碼進行深度優化并將該方法直接編譯成當前平臺對應的機器碼,以此提升Java程序執行時的性能。
一個被多次調用執行的方法或一處代碼中循環次數比較多的循環體都可以被稱為 熱點代碼 ,因此都可以通過JIT編譯為本地機器指令。
3.3.1、棧上替換
縱觀所有編程語言,類似于C/C++、GO
等編譯型語言,都屬于靜態編譯型,也就是指在程序啟動時就會將所有源代碼編譯為平臺對應的機器碼,但JVM中的JIT卻屬于動態編譯器,因為對于熱點代碼的編譯是發生在運行過程中的,所以這種方式也被稱之為 棧上替換(On Stack Replacement),在有的地方也被稱為OSR替換。
3.3.2、方法調用計數器與回邊計數器
前面提到過:“一個被多次調用執行的方法或一處代碼中循環次數比較多的循環體都可以被稱為 熱點代碼”,那么一個方法究竟要被調用多少次或一個循環體到底要循環多少遍才可被稱為熱點代碼呢?必然會存在一個閾值,而JIT又是如何判斷一段代碼的執行次數是否達到了這個閾值的呢?主要依賴于熱點代碼探測技術。
在HotSpotVM中,熱點代碼探測技術主要是基于計數器實現的。HotSpot中會為每個方法創建兩個不同類型的計數器,分別為方法調用計數器(Invocation Counter)和回邊計數器(BackEdge Counter),方法調用計數器主要用于統計方法被調用的次數,回邊計數器主要用于統計一個方法體中循環體的循環次數。
方法調用計數器
方法調用計數器的閾值在Client
模式下默認是1500次,在Server
模式下默認是10000次,當一段代碼的執行次數達到這個閾值則會觸發JIT即時編譯。當然,如果你對這些缺省(默認)的數值不滿意,也可以通過JVM參數-XX :CompileThreshold
來自己指定。
如上,當一個方法被調用執行時,會首先檢查該方法是否已經被JIT編譯過了,如果是的話,則直接執行上次編譯后生成的本地機器碼。反之,如果還沒有編譯,則先對方法調用計數器+1,然后判斷計數器是否達到了規定的閾值,如果還未達到閾值標準則采用解釋器的模式執行代碼。如果達到了規定閾值則提交編譯請求,由JIT負責后臺編譯,后臺線程編譯完成后會生成本地的機器碼指令,這些指令會被放入
Code Cache
中緩存起來(熱點代碼緩存,存放在方法區/元數據空間中),當下次執行該方法時,直接從緩存中讀取對應的機械碼執行即可。
回邊計數器
回邊計數器的作用是統計一個方法中循環體的執行次數,在字節碼中遇到控制流向后跳轉的指令稱為“回邊” (Back Edge)。與方法調用計數器一樣,當執行次數達到某個閾值后,也會觸發OSR編譯。如下圖:
OK~,回邊計數器的編譯過程和方法調用計數器的相差無幾,唯一值得一提的就是:不管是方法調用計數器還是回邊計數器,在提交OSR編譯請求的那次執行操作,還是依舊會采用解釋器執行,而不會等到編譯操作完成后去執行機器碼,因為這樣耗費的時間比較長,只有下次再執行該代碼時才會執行編譯后的機器碼。
3.3.3、熱度衰減
一般而言,如果以缺省參數啟動Java程序,那么方法調用計數器統計的執行次數并不是絕對次數,而是一個相對的執行頻率,也代表是指方法在一段時間內被執行的次數。當超過一定的時間,但計數器還是未達到編譯閾值無法提交給JIT即時編譯器編譯時,那此時就會對計數器進行減半,這個過程被稱為方法調用計數器的熱度衰減(Counter Decay),而這段時間則被稱為方法調用計數器的半衰周期(Counter Half Life Time)。
而發生熱度衰減的動作是在虛擬機GC進行垃圾回收時順帶進行的,可以通過參數-XX:-UseCounterDecay
關閉熱度衰減,這樣可以使得方法調用計數器的判斷基準變為絕對調用次數,而不是以相對執行頻率作為閾值判斷的標準。不過如果關閉了熱度衰減,就會導致一個Java程序只要在線上運行的時間足夠長,程序中的方法必然絕大部分都會被編譯為本地機器碼。
同時也可以通過
-XX:CounterHalfLifeTime
參數調整半衰周期的時間,單位為秒。
一般而言,如果項目規模不大,并且上線后很長一段時間不需要進行版本迭代的產品,都可以嘗試把熱度衰減關閉掉,這樣可以使得Java程序在線上運行的時間越久,執行性能會更佳。只要線上運行的時間足夠長,到后面可以與C編寫的程序性能相差無幾甚至超越(因為C/C++需要手動管理內存,管理內存是需要耗費時間的,但Java程序在執行程序時卻不需要擔心內存方面的問題,會有GC機制負責)。
3.3.4、其他的熱點探測技術
在前面分析中,我們得知了,在HotSpot中的熱點代碼探測是基于計數器模式實現的,但是除開計數器的方式探測之外,還可以基于采樣(sampling)以及蹤跡(Trace)模式對代碼進行熱點探測。
- 采樣探測:采用這種探測技術的虛擬機會周期性的檢查每個線程的虛擬機棧棧頂,如果一些在檢查時經常出現在棧頂的方法,那么就代表這個方法經常被調用執行,對于這類方法可以判定為熱點方法。
- 優點:實現簡單,可以很輕松的判定出熱度很高(調用次數頻繁)的方法。
- 缺點:無法實現精準探測,因為檢查是周期性的,并且有些方法中存在線程阻塞、休眠等因素,會導致有些方法無法被精準檢測。
- 蹤跡探測:采用這種方式的虛擬機是將一段頻繁執行的代碼作為一個編譯單元,并僅對該單元進行編譯,該單元由一個線性且連續的指令集組成,僅有一個入口,但有多個出口。也就代表著:基于蹤跡而編譯的熱點代碼不僅僅局限在一個單獨的方法或者代碼塊中,一條蹤跡可能對應多個方法,代碼中頻繁執行的路徑就可能被識別成不同的蹤跡。
- 優點:這種方式實現可以使得熱點探測擁有更高精度,可以避免將一塊代碼塊中所有的代碼都進行編譯的情況出現,能夠在很大程序上減少不必要的編譯開銷。因為無論是采樣探測還是計數器探測的方式,都是以方法體或循環體作為編譯的基本單元的。
- 缺點:蹤跡探測的實現過程非常復雜,難度非常高。
而HotSpot虛擬機采用的計數探測的方式,實現難度、編譯開銷與探測精準三者之間會有一個很好的權衡。三種探測技術比較如下:
- 實現難度:采樣探測 < 計數探測 < 蹤跡探測
- 探測精度:采樣探測 < 計數探測 < 蹤跡探測
- 編譯開銷:蹤跡探測 < 計數探測 < 采樣探測
3.4、JVM為何不移除解釋器?
在前面分析了JIT即時編譯器,可以很直觀的感受到,如果程序以純JIT編譯器的方式執行,性能方面絕對會超出解釋器+編譯器混合的模式,但為何虛擬機中至今也不移除解釋器,還要用解釋器來拖累Java程序的性能呢?就如在開篇中提到的JRockit虛擬機中,就移除了解釋器模塊,字節碼文件全部依靠即時編譯器執行。
主要有兩個原因,一個是為了保證Java的絕對跨平臺性,另一個則是為了保證啟動速度,考慮綜合性能。
①保證絕對的跨平臺性:如果將解釋器從虛擬機中移除就代表著:每到一個不同的平臺,比如從Windows遷移到Linux環境,那么JIT又要重新編譯,生成對應平臺的機器碼指令才能讓Java程序執行。但如果是解釋器+JIT編譯器混合的模式工作就不需要擔心這個問題,因為前期可以直接由解釋器將字節碼指令翻譯成當前所在的機械碼執行,解釋器會根據所在平臺的不同,翻譯出平臺對應的機器碼指令。這樣從而使得Java具備更強的跨平臺性。
②保證Java啟動速度,考慮綜合性能:因為如果移除了解釋器模塊,那么就代表著所有的字節碼指令需要在啟動時全部先編譯為本地的機械碼,這樣才能使得Java程序能夠正常執行。不過如果想在啟動時將整個程序中所有的字節碼指令全部編譯為機器碼指令,需要的時間開銷是非常巨大的,如果把解釋器從JVM中移除,那么會導致一些需要緊急上線的項目可能編譯都需要等半天的時間。
綜上所述,虛擬機移除解釋器有移除后的隱患,當然,如果移除了也有移除之后的好處,比如前面提到的JRockitVM中,就移除了解釋器模塊,從而使它獲取了一個“史上最快”虛擬機的稱號。
而HotSpot中采用的是解釋器+JIT即時編譯器混合的模式,這種模式的好處在于:在Java程序運行時,JVM可以快速啟動,前期先由解釋器發揮作用,不需要等到編譯器把所有字節碼指令編譯完之后才執行,這樣可以省去很大一部分的編譯時間。后續隨著程序在線上運行的時間越來越久,JIT發揮作用,慢慢的將一些程序中的熱點代碼替換為本地機器碼運行,這樣可以讓程序的執行效率更高。同時,因為HotSpotVM中存在熱度衰減的概念,所以當一段代碼的熱度下降時,JIT會取消對它的編譯,重新更換為解釋器執行的模式工作,所以HotSpot的這種執行模式也被成為“自適應優化”執行。
當然,我們在程序啟動時也可以通過JVM參數自己指定執行模式:
①-Xint:完全采用解釋器模式執行程序。
②-Xcomp:完全采用即時編譯器模式執行程序。如果即時編譯器出現問題,解釋器會介入執行。
③-Xmixed:采用解釋器+JIT即時編譯器的混合模式共同執行(默認的執行方式)。
3.5、熱冷機流量遷移注意事項
通過上述的分析之后,我們可以得到一個結論:
編譯執行的方式性能會遠遠超出解釋執行。
這句話聽起來好像是廢話,因為是個明眼人就能看出這個結論,但實則不然。此時我們可以從系統架構的角度思考一下這個結論,對于系統整體而言,這個結論有什么不同嗎?是有的,如下:
既然編譯執行比解釋執行的效率要高,那么就代表著程序如果處于編譯執行的周期內,系統的吞吐量要比解釋執行期間高很多。而Java現在默認的虛擬機HotSpot并不是一開始就是編譯執行的,而是在運行過程中通過JIT即時編譯器進行動態編譯的。
所以現在又可以得到一個簡單的結論,Java程序的機器可以簡單分為兩種狀態:
- 熱機:長時間在線上運行Java程序的機器,程序中很多代碼都已經被JIT編譯為了本地機器碼指令。
- 冷機:剛剛啟動的Java程序的機器,所有代碼還是處于解釋執行的階段。
從上面的分析中可以得知:機器在熱機狀態可以承受的流量負載會遠遠超出冷機狀態。如果程序以熱機狀態切換流量到冷機狀態的機器時,可能會導致冷機狀態的服務器因無法承載流量而假死。
之前我在開發過程中也曾遇到過這樣的問題,某個服務因為要擴容,原本按照之前的集群規模計算,再擴容1/4之一左右的機器是可以承載新的流量的,但后面啟動之后出現了問題,新啟動的機器網關那邊分配轉發流量之后,立馬就宕機了,最開始因為是第一次碰到這樣的問題,以為是機器或者程序中代碼的問題,最后排查發現都沒問題,后來嘗試將擴容的機器數量從原本計劃的1/4增加到1/3之后,流量平滑的被遷移到了新的機器,沒有再出現宕機的故障。
從上述這個案例中可以得知,如果直接將熱機狀態的流量遷移到冷機狀態的機器是不可行的,所以一般在計劃擴容時,想要流量平滑的切換到新的機器,一般有軟硬件兩種層面的解決方案,如下:
第一種方案是和上述案例中一樣,采用更多的機器承載熱機狀態過來的流量,等后續這些剛啟動的冷機變成熱機狀態了,可以再把多余的機器停掉。
第二種方案則是網關這邊控制流量,先將一部分流量轉發給剛啟動的冷機,讓剛啟動的冷機先做預熱,等運行一段時間之后再將原本計劃的所有流量遷移到這些機器。
四、全面剖析JIT即時編譯器
在Java的編譯器中,大體可以分為三類:
- ①前端編譯器:類似于javac、JDT中的ECJ增量編譯器等。就是指將
.java
的源代碼編譯成.class
字節碼指令的編譯器。 - ②后端編譯器:也就是指JIT即時編譯器,指把字節碼指令編譯成機器碼指令的編譯器。
- 靜態編譯器:類似于Java9中的AOT編譯器,是指把
.java
源代碼直接編譯為機器碼指令的編譯器。
在JVM運行過程中采用的解釋器+編譯器混合執行的模式,一般是指JIT編譯器,在Java中對于靜態編譯器的應用還是比較少的。在HotSpot虛擬機中內嵌著兩個JIT即時編譯器,分別為Client Compiler
與Server Compiler
,也就是通常所說的C1和C2編譯器,JVM在64位的系統中默認采用的C2編譯器,也就是Server Compiler
編譯器。不過同樣的,在程序啟動的時候也可以通過參數顯式指定運行時到底采用哪種編譯器,如下:
- -client:指定JVM運行時采用C1編譯器。
- C1編譯器會對字節碼進行簡單和可靠的優化,耗時比較短,追求編譯速度。
- -server:指定JVM運行時采用C2編譯器。
- C2編譯器會對字節碼進行激進優化,耗時比較長,追求編譯后的執行性能。
兩種編譯器因為追求的方向不同,所以在優化時的過程也存在差異,下面來簡單分析一下C1和C2編譯器。
4.1、C1編譯器(Client Compiler)
C1編譯器主要追求穩定和編譯速度,屬于保守派,C1中常見的優化方案有幾種:公共子表達式消除、方法內聯、去虛擬化以及冗余消除等。
- 公共子表達式消除:如果一個表達式E已經計算過了,并且從先前的計算到現在E中所有變量的值都沒有發生變化,那E的這次出現就成公共子表達式,可以用原先的表達式進行消除,直接使用上次的計算結果,無需再次計算。
- 方法內聯:將引用的方法代碼編譯到引用點處,這樣可以減少棧幀的生成,減少參數傳遞以及跳轉過程。
- 去虛擬化:對唯一的實現類進行內聯。
- 冗余消除:通過對字節碼指令進行流分析,將一些運行過程中不會執行的代碼消除。
- 空檢測消除:將顯式調用的NullCheck(空指針判斷)擦除,改成ImplicitNullCheck異常信號機制處理。
- 自動裝箱消除:對于一些不必要的裝箱操作會被消除,比如剛裝箱的數據又在后面立馬被拆箱,這種無用操作就會被消除。
- 安全點消除:對于線程無法抵達或不會停留的安全點會進行消除。
- 反射消除:對于一些可以正常訪問無需通過反射機制獲取的數據,會被改為直接訪問,消除反射操作。
4.2、C2編譯器(Server Compiler)
C2編譯器則主要是追求編譯后的執行性能,屬于激進派,C2編譯器建立在C1編譯器的基礎優化之上,除開使用了C1中的優化手段之外,還有幾種基于逃逸分析的激進優化手段:標量替換、棧上分配以及同步消除等。
- 逃逸分析:逃逸分析是建立在方法為單位之上的,判斷變量作用域是否存在于其他棧幀或者線程中,如果一個成員在方法體中產生,但是直至方法結束也沒有走出方法體的作用域,那么該成員就可以被理解為未逃逸。反之,如果一個成員在方法最后被
return
出去了或在方法體的邏輯中被賦值給了外部成員,那么則代表著該成員逃逸了,判斷逃逸的方法被稱為逃逸分析。- 也可以換個說法,建立在線程的角度來看:如果一條線程中的對象無法被另一條線程訪問到,就代表該對象未逃逸。
- 逃逸的作用域:
- ①棧幀逃逸:當前方法內定義了一個局部變量逃出了當前方法/棧幀。
- ②線程逃逸:當前方法內定義了一個局部變量逃出了當前線程能夠被其他線程訪問。
- 逃逸類型:
- 全局變量賦值逃逸:當前對象被賦值給類屬性、靜態屬性
- 參數賦值逃逸:當前對象被當作參數傳遞給另一個方法
- 方法返回值逃逸:當前對象被當做返回值return
- 標量替換:建立在逃逸分析的基礎上使用基本量標量代替對象這種聚合量。
- 標量:reference與八大基本數據類型就是典型的標量,泛指不可再拆解的數據。
- 好處:
- ①能夠節省堆內存,因為進行標量替換之后的對象可以在棧上進行內存分配。
- ②相對運行而言省去了去堆中查找對象引用的過程,速度會更快一些。
- ③因為是分配在棧上,所以會隨著方法結束和線程棧的彈出自動銷毀,不需要GC的介入。
- 棧上分配:對于未逃逸的對象使用標量替換進行拆解,然后將拆解后的標量分配在局部變量表中,從而減少實例對象的產生,減少堆內存的使用以及GC次數。
- 決定一個對象能否在棧上分配的因素(兩個都必須滿足):
- ①對象能夠通過標量替換分解成一個個標量。
- ②對象在棧幀級作用域不可逃逸。
- 決定一個對象能否在棧上分配的因素(兩個都必須滿足):
- 同步消除:在出現
synchronized
嵌套的情況下,如一個同步方法中調用另一個同步方法,那么第二個同步方法的synchronized
鎖會被消除,因為第二個方法只有獲取到了第一個鎖的線程才能訪問,不存在線程并發安全問題。- 決定能否同步消除(滿足一個即可):
- ①當前對象被分配在棧上。
- ②當前對象的無法逃出線程作用域。
- 決定能否同步消除(滿足一個即可):
- 空檢查剪支:經過流分析后,對于一些不會執行的Null分支判斷會直接剪掉
- 如一個參數在外部方法傳遞前已經做了非空檢測了,但在內部方法中依舊又做了一次非空判斷,那么對于內部的這個非空判斷會被直接剪除掉。
逃逸的作用域:①棧幀逃逸:當前方法內定義了一個局部變量逃出了當前方法/棧幀。 ②線程逃逸:當前方法內定義了一個局部變量逃出了當前線程能夠被其他線程訪問。全局變量賦值逃逸:當前對象被賦值給類屬性、靜態屬性參數賦值逃逸:當前對象被當作參數傳遞給另一個方法方法返回值逃逸:當前對象被當做返回值return
前面提到了,64位的JVM中都是默認使用C2編譯器的,但實際上JDK1.6之后如果是64位的機器,默認情況下或顯式指定了-server模式運行時,JVM會開啟分層編譯策略,也就是通過C1+C2相互協作共同處理編譯任務。而分層編譯大體的邏輯為:Java程序剛啟動還處于冷機狀態時,采用C1編譯器進行簡單優化,追求編譯速度和穩定性,當JVM達到熱機狀態時,后面的編譯請求則通過C2編譯器進行全面激進優化,追求編譯后執行時的性能和效率。
PS:兩種不同的模式運行,熱點代碼緩存區大小也會不一樣,Server模式下CodeCache的初始大小為2496KB,Client模式下CodeCache的初始大小為160KB,可以通過
-XX:ReservedCacheSize
參數指定CodeCache的最大大小。
4.3、其他的編譯器
在JDK10的時,HotSpot加入了一種新的編譯器:Graal
編譯器,該編譯器的性能經過幾代的更新后很快就追上了老牌的C2編譯器,在JDK10中可以通過-XX: +UnlockExperimentalVMOptions -XX: +UseJVMCICompiler
參數使用它。
五、分派(Dispatch)調用
在學習JavaSE的時候大家應該都學到了OOP的基本特征,也就是封裝、繼承與多態。而關于多態性在運行時到底是如何找到具體方法的,如重寫和重載方法到底在運行時是如何確定具體調用那個方法的呢?也就是通過分派技術進行調用的。
5.1、方法調用
先來說說方法調用,方法調用和方法執行是不同的,方法調用階段的主要任務是確定被調用方法的版本,這個版本是指要具體調用重載、重寫情況下的哪一個方法,方法調用階段并不會涉及到方法體中邏輯的執行。一般來說,.java
文件經過前端編譯器編譯成.class
文件后,所有的方法調用存儲在class
文件都是 符號引用 ,而并不是 直接引用(運行時方法在內存中的入口) 。
一般而言,方法的直接引用需要等到類加載中的解析階段甚至運行時才可以確定,在類加載中的解析階段能夠被確認直接引用方法只有靜態方法、final方法以及私有方法,因為這幾類都是屬于“編譯器可知、運行期不可變”的方法,因為這幾種方式定義的方法要么與類直接關聯,要么外部不能訪問以及不可修改,這就決定了他們不能通過重寫的方法更改其方法版本,因此都可以直接在解析階段確認直接引用,可以在類加載階段進行解析。
在JVM虛擬機中提供了五條方法調用的指令:
-
invokestatic
:調用靜態方法 -
invokespecial
:調用構造<init>
構造方法、私有方法以及super()、super.xxx()
父類方法 -
invokevirtual
:調用所有的虛方法(靜態、私有、構造、父類、final方法都屬于非虛方法) -
invokeinterface
:調用接口方法,會在運行期間才能確定具體的實現類方法 -
invokedynamic
:現在運行時期動態解析出調用點限定符所引用的方法,然后再執行該方法,在此之前的4條指令,分派邏輯都是固化在JVM中的,而invokedynamic
指令的分派邏輯是由用戶所設定的引導方法決定的
一般而言,能夠被invokestatic
和invokespecial
指令調用的方法都可以在解析階段確定調用的具體版本信息,像靜態、私有、構造、父類、final方法都符合調用條件,所以這些方法在類加載階段就會把符號引用替換成直接引用。因為這些方法是一個靜態的過程,在編譯期間就能完全確定版本,無需將這些工作延遲到運行期間再去處理,而這類調用方式就被稱為靜態分派。但對于公開實例方法、非私有成員方法這些就無法在編譯期確定版本,所以這些方法的調用方式被稱為動態分派。同時根據方法的宗量數也可分為單分派和多分派。
方法宗量是指方法的所有者和參數,根據分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種。所以稍微總結一下,如下:
- 非虛方法:指在類加載階段可確定版本(可將符號引用轉換為直接引用)的方法
- 虛方法:指在類加載階段無法確定版本(無法確定符號引用可以解析為哪個直接引用)的方法
- 靜態分派:編譯期可以確定方法的版本,類加載階段可以通過解析階段完成版本判定
- 動態分派:運行期才可確定方法版本,由JVM來確定方法的具體版本
- 單分派:根據單個方法宗量進行方法版本選擇
- 動態分派的選擇是依據方法接收者來選擇版本的,所以動態分派屬于單分派
- 多分派:根據多個方法宗量進行方法版本選擇
- 靜態分派是依據方法的接收者和參數兩個宗量進行版本選擇,因此靜態分派屬于多分派
5.2、靜態分派
靜態分派是指所有依賴于靜態類型來定位方法執行版本的分派動作,靜態分派發生在編譯期,由編譯器執行分派動作,所以靜態分派并不是虛擬機來執行的。
靜態類型是指什么?
User u = new Admin();
如上代碼,User
是變量u
的靜態類型(外觀類型),而Admin
是變量的實際類型。
靜態分派的典型體現是方法重載(Overload),重載方法的特性是方法簽名不同(方法名相同、參數列表不同。下面上個案例理解一下:
public class User{
public void identity(VipUser vip){
System.out.println("我是VIP會員用戶....");
}
public void identity(AdminUser admin){
System.out.println("我是管理員....");
}
public static void main(String[] args) {
User user = new User();
VipUser vip = new VipUser();
user.identity(vip);
}
}
class VipUser{}
class AdminUser{}
如上源碼,User
中存在兩個方法,identity(VipUser)
重載了方法identity(AdminUser)
,編譯器在解析名為identity
的方法時,會根據其重載參數的靜態類型(即外觀類型或直接類型)來選擇方法版本。
輸出結果:我是VIP會員用戶....
這個結果不難理解,因為在調用方法的時候:user.identity(vip)
傳入的參數靜態類型為VipUser
,所以編譯器最終會找到identity(VipUser vip)
方法。
如果參數為無類型的字面量(基本數據類型),那么編譯器會在最大程度上去推導出字面量上最貼合的方法版本,如下:
public class User{
public void print(char arg){
System.out.println("char....");
}
public void print(long arg){
System.out.println("long....");
}
public void print(int arg){
System.out.println("int....");
}
// 省略其他方法.......
public void print(char... arg){
System.out.println("char... ....");
}
public static void main(String[] args) {
User user = new User();
user.print('a');
}
}
// 輸出結果:char....
觀察如上代碼,輸出結果為char....
,這很正常,但如果我們把print(char arg)
方法注釋掉,再次執行會發生什么情況?報錯?并不是,注釋掉后再執行,如下:
輸出結果:int....
從上面的執行結果中可以得知:雖然User
類中沒有了char
類型參數的方法,但實際上編譯器會通過參數自動轉型幫你找到了一個“合適”的方法調用,轉換路徑如下:
char → int → long → float → double
,經過如上過程還未找到符合要求的方法時,會自動將調用方法時傳遞的參數裝箱為Character
對象,如果還是未找到,會進一步查找Character
類實現的接口Serializable
,如果還未找到,會進一步查找Serializable
的父類Object
,如果還未找到則會再找到char...
,所以總體查找路徑如下:
char → int → long → float → double → Character → Serializable → Object → char...
實際上關于編譯器的類型轉換,推導最合適的方法調用這個點,大家了解一下有這個概念存在即可,實際開發過程中,代碼不會寫這么苛刻。
5.3、動態分派
動態分派是指在編譯期無法通過靜態類型判定出方法版本,需要在運行期間由虛擬機來判定方法調用的具體版本的方式。動態分派的典型體現是方法重寫(Override),重寫的概念是方法簽名相同(方法名相同、參數列表相同),上個案例理解。如下:
public class User{
public void identity(){
System.out.println("我是用戶....");
}
public static void main(String []args) {
User user = new VipUser();
user.identity();
}
}
class VipUser extends User {
public void identity(){
System.out.println("我是VIP會員用戶....");
}
}
class AdminUser extends User{
public void identity(){
System.out.println("我是管理員....");
}
}
// 輸出結果:我是VIP會員用戶....
對于這個結果相信不會出乎大家的意料,那虛擬機在運行時又是如何定位到VipUser.identity()
方法的呢?這里顯然不是通過變量的靜態類型進行的版本判定,因為靜態類型為User
的變量user
調用identity
執行后,最終執行的卻是VipUser.identity()
方法,這是什么原因呢?其實道理也非常簡單,就是因為user
變量的實際類型不同,那Java又是如何通過變量的實際類型來判定方法版本的?接下來進行逐步分析。
對于如上源碼使用javap
進行反編譯后,現在來觀察一下User.main()
的字節碼信息,如下:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class User$VipUser
3: dup
4: invokespecial #3 // Method User$VipUser."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method identity:()V
12: return
LineNumberTable:
line 7: 0
line 8: 8
line 9: 12
在字節碼指令的第七行,通過了invokevirtual
指令調用了VipUser.identity
虛方法,運行時JVM的執行引擎對于invokevirtual
指令進行解析,解析操作會分為如下幾個步驟:
- 找到操作數棧頂部的第一個元素,也就是指向變量
user
的實際類型,即VipUser
- 在
VipUser
的方法表中查找名稱和參數類型與invokevirtual
指令調用的方法符號引用相同的方法- 找到了:代表
VipUser
類中存在方法identity()
方法,判斷是否具備方法的訪問權限- 具備:將調用方法處的符號引用替換為該方法的直接引用
- 不具備:拋出
java.lang.IllegalAccessError
錯誤
- 沒找到:
- 繼續自下向上的方式查找
VipUser
父類的方法表- 找到了:代表父類中有
identity()
方法,,判斷是否具備方法的訪問權限- 具備:將調用方法處的符號引用替換為該方法的直接引用
- 不具備:拋出
java.lang.IllegalAccessError
錯誤
- 還是沒找到:代表調用的方法根本不存在,拋出
java.lang.AbstractMethodError
錯誤
- 找到了:代表父類中有
- 繼續自下向上的方式查找
- 找到了:代表
由于invokevirtual
指令執行的第一步就是在運行期確定接收者的實際類型,所以調用中的invokevirtual
指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。同時,這種在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
5.4、虛擬機中動態分派的實現
由于動態分派在運行時是頻繁執行的動作。而且相當來說,動態分派的方法版本判定需要在類的元數據中搜索出符合要求的合適版本,性能開銷也比較大,因此在虛擬機的實際實現中基于性能的考慮,大部分實現都不會真正的進行如此頻繁的搜索。
綜合考慮,一般的JVM實現中,都會為每個類在元數據空間(原方法區)中建立一個虛方法表,在解析
invokevirtual
指令時,使用方法表索引來代替查找元數據的開銷,以此提高性能。
虛方法表中存放著各個類方法的實際入口地址,如果某個方法在子類中沒有被重寫,那子類的虛方法表中該方法的地址入口和父類中相同的方法入口地址是一致的,都指向父類的實現入口。如果子類重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。比如xxx
類沒有重寫Object
類的toString()
方法,那么xxx
類的虛方法表中toString()
的入口地址則指向Object.toString()
方法。
在虛擬機中,具有相同簽名的方法,在父類、子類的虛方法表中都具有一樣的索引號,這樣當類型變換時,僅需要變更查找的方法表,就可以從不同的虛方法表中按照索引轉換出所需要的方法入口地址。
方法表一般在類加載中的連接階段進行初始化,準備了類變量初始值之后,虛擬機會把該類的方法表也初始化完成。
當然,在C2編譯器的執行模式下,也會存在一些不穩定的激進優化策略,比如內聯緩存,基于“類型繼承關系分析”技術的守護內聯。
對于方法分派調用這塊有些小伙伴看了可能會有些不理解,那么你只需要記住分派調用的目的是為了確定方法執行時的具體版本即可。同時,分派調用的過程實際上就是符號引用替換為直接飲用的過程,在有些地方也被稱為方法綁定的過程。靜態分派調用的方法也被稱為早期綁定,因為在編譯期間被調用的目標方法就已經知曉。動態分派調用的方法則被稱為晚期綁定,被調用的在編譯期是不可知的,必須要等到運行時才能與根據實際的類型進行綁定。