1、類檔案結構
Java虛擬機器只與Class檔案相關聯,它規定了Class檔案應該具有的格式,而不論該檔案是由什麼語言編寫並編譯而來。所以,任何語言只要能夠最終編譯成符合Java虛擬機器要求的Class檔案,就可以執行在Java虛擬機器上面。就是說,不論是使用Java, Scala, Kotlin, Groovy還是其他語言,只要編譯出的Class檔案符合虛擬機器規範,那麼都可以被虛擬機器執行。所以,實際上Java規範就是由Java語言規範和Java虛擬機器規範兩個獨立的部分組成。
Class類檔案是一種二進位制檔案,它包含了Java虛擬機器指令集和符號表以及若干其他輔助資訊。Class檔案格式採用類似於C的偽結構來儲存資料,這種結構只有兩種資料型別:無符號數和表。無符號數屬於基本資料型別,以u1
,u2
,u4
,u8
分別代表1位元組、2位元組、4位元組和8位元組的無符號數,無符號數可以用來描述數字、索引、數量值或者按照utf-8編碼構成的字串。
表是由多個無符號數或者其他作為表作為資料項構成的複合資料結構,所有表習慣性地以"_info"結尾,整個Class檔案本質上是一張表。而所謂的表就對應於C++中的一個結構體,比如整個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; // 類附加屬性資料,包括原始檔名等。
};
複製程式碼
上面結構體的各個變數的定義的順序與Class檔案中的儲存順序是一致的。下面我們用一個二進位制編輯器開啟Class檔案並簡單看下,Class檔案中的儲存如何與上面的結構體對應的。
根據結構體的定義,首先是magic
欄位,它是u4型別的,即4位元組,對應於上圖中的CAFEBABE
;緊接著兩個u2型別的欄位,minor_version
和major_version
,用來表示Class檔案的版本號,對應於上圖中的00000031
;然後是一個u2型別的constant_pool_count
,用來表示常數表項個數,這裡是0027
,也就是有38個常量,因此常量從1開始計數的;接著常量個數的是變長符號表,這個符號表的長度就是之前常量的長度,即38,然後我們要按照常量的規則找到38個常量之後就是u2型別的訪問標誌。
那這38個常量又如何尋找呢?實際上,Class檔案中總計規定了14種常量結構體,每種結構體都包含一個tag
欄位,它是u1型別的,即1個位元組,並且儲存在該結構體的首位。我們需要先根據該tag
欄位找到該常量的定義,然後才能確定接下來的幾個位元組是屬於該常量的。比如上圖中的第一個常量的tag
是0A
,我們可以查詢相關的表知道它是CONSTANT_Fleddef_info
型別的常量,該常量有3個欄位:u1型別的tag
, u2型別的index
, u2型別的index
,故我們可以確定常量長度之後的5個位元組是屬於第一個常量的。依次,分析下去我們可以得到第二個,第三個等常量的資訊。得到了常量的資訊之後,就可以獲取上面結構體中其他的欄位的資訊。
其實,按照上面的分析,我們可以看出分析Class檔案時一個非常機械的過程,因為它有固定的規則在裡面,所以我們可以使用命令列工具javap
來獲取以上的資訊。在實際開發過程中從位元組碼的角度分析問題的情形可能並不多,但是瞭解位元組碼中的一些指令,尤其是併發相關的指令,對學習和分析問題都大有裨益。
2、類載入機制
2.1 類載入過程
Java程式中的類載入是在執行期間完成的,我們可以使用Java預定義的載入器或者自定義載入器動態從各種渠道載入類並使用。最初將類載入器從虛擬機器中分離出來是為了Applet等的動態載入,但是後來隨著技術發展,動態載入被應用到各種場景中,比如移動端的外掛化、熱補丁、Tomcat的類載入等各種場景中,所以類載入是非常重要的一塊內容。
一個類從被載入到虛擬機器記憶體到解除安裝的整個生命週期包括:載入-驗證-準備-解析-初始化-使用-解除安裝
7個階段。其中驗證-準備-解析
3個階段稱為連線。
載入發生在類被使用的時候,如果一個類之前沒有被載入,那麼就會執行載入邏輯,比如當使用new建立類、呼叫靜態類物件和使用反射的時候等。載入過程主要工作包括:1).從磁碟或者網路中獲取類的二進位制位元組流;2).將該位元組流的靜態儲存結構轉換為方法取的執行時資料結構;3).在記憶體中生成表示這個類的Class物件,作為方法區訪問該類的各種資料結構的入口。
驗證階段會對載入的位元組流中的資訊進行各種校驗以確保它符合JVM的要求。
準備階段會正式為類變數分配記憶體並設定類變數的初始值。注意這裡分配記憶體的只包括類變數,也就是靜態的變數(例項變數會在物件例項化的時候分配在堆上),並且這裡的設定初始值是指‘零值’,比如int型別的會被初始化為0,引用型別的會被初始化為null,即使你在程式碼中為其賦了值。
解析階段是將常量池中的符號引用替換為直接引用的過程。符號引用與虛擬機器實現的佈局無關,引用的目標並不一定要已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須是一致的,只要能正確定位到它們在記憶體中的位置就行。直接引用可以是指向目標的指標,相對偏移量或是一個能間接定位到目標的控制程式碼。如果有了直接引用,那引用的目標必定已經在記憶體中存在。
初始化是執行類構造器<client>
方法的過程。<client>
方法是由編譯器自動收集類中的類變數的賦值操作和靜態語句塊中的語句合併而成的。虛擬機器會保證<client>
方法執行之前,父類的<client>
方法已經執行完畢。
2.2 類載入機制 雙親委派模型
類載入器用來根據類的全限定名獲取描述此類的二進位制位元組流。我們可以把類載入器分成以下4種:
- 啟動類載入器:存在於虛擬機器中,使用C++編寫,負責將
<Java_Home>/lib
下面的類庫載入到記憶體中(比如rt.jar); - 擴充類載入器:它負責將
<Java_Home>/lib/ext
或者由系統變數java.ext.dir
指定位置中的類庫載入到記憶體中; - 應用程式類載入器:它負責將使用者類路徑中指定的類庫載入到記憶體中;
- 自定義類載入器:就是指使用者自己定義的類載入器。
在Java種存在以上多種類載入器,它們之間通過一定的規則相互配合,這個規則就是雙親委派模型
:每個類載入器收到類載入請求時,都會先將請求委派給父類載入器去完成,所以,載入請求會一直傳遞到最頂層的類載入器。只有當類父類載入器無法完成載入請求的時候,該載入器才會自己去載入。當然,這不是強制的,我們也可以完全使用自己的一套邏輯。但雙清委派模型的好處就在於,假如你自定義了一個類java.lang.Object
,那麼當使用雙親委派模型來載入的時候,會由子載入器不斷向上傳遞載入請求到啟動類載入器進行載入,因此Object在各種類載入環境中都是同一個類。這保障了Java系統的穩定性。
3、虛擬機器位元組碼執行引擎
虛擬機器是相對於物理機而言的,它們的區別是物理機的執行引擎直接建立在處理器、硬體、指令集和作業系統層面上,而虛擬機器的執行引擎是自己實現的。所以,執行引擎也是Java虛擬機器最核心的組成部分之一。
執行引擎用來執行我們寫的業務邏輯,而業務邏輯就是指一些方法,所以虛擬機器執行引擎就是用來執行各個方法的。而方法是通過棧幀來描述的,方法的執行是用棧幀入棧和出棧來描述的,棧幀中儲存了方法的區域性變數表、運算元棧、動態連線和方法返回地址等資訊。所以說,執行引擎就是用來執行各個棧幀的。在虛擬機器執行的時候,只有最頂層的棧幀是有效的,與之關聯的方法就稱為當前方法,並且執行引擎執行的所有位元組碼都是針對當前棧幀的。
方法的資訊儲存在棧幀中,而棧幀中的方法資訊是從Class檔案中讀取來的。回到之前的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屬性中的。
執行引擎的執行過程
我們可以用下面的一個程式來說明以下在實際的執行過程中,Java執行引擎是如何工作的。
public int cal() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c; // 1
}
複製程式碼
首先會在編譯期確定方法的棧深度和區域性變數表的長度,棧深度是由計算的過程得到的,而區域性變數表的長度等於1個this
加3個區域性變數即4。當程式執行到1處時,區域性變數表內會被填充為this
、100
、 200
和300
。程式計數器會隨著程式碼當前執行到的位置而不斷更新。而此時因為沒有進行任何計算,所以棧還是空的。
接下來就開始進行計算:首先會把a壓入棧中(其實時把區域性變數表裡的值壓入棧中);然後把b壓入棧中;接著將棧頂的兩個元素先出棧,相加之後再入棧,此時棧中只有一個計算結果300;接下來再把c壓入棧中;然後,把棧頂的兩個元素出棧,執行完乘法之後再入棧,所以最終棧中只有一個90000;最後,使用ireturn
指令結束方法並將棧頂的元素返回給方法的呼叫者。
總結
以上就是虛擬機器執行子系統的一個過程,包含了從編譯出的Class檔案,到被載入到記憶體中、驗證、初始化等,到最終在虛擬機器中被執行等完整的過程。這裡只是總結和梳理了相關的基礎的知識點,在虛擬機器中實際的執行過程肯定遠比我們上述的內容更加複雜和精彩。