1. 程式人生 > >Java虛擬機器 虛擬機器執行子系統

Java虛擬機器 虛擬機器執行子系統

程式碼編譯的結構從本地機器碼轉變為位元組碼,是儲存格式發展的一小步,卻是程式語言發展的一大步。

主要內容

類檔案結構

虛擬機器類載入機制

虛擬機器位元組碼執行引擎

類檔案結構

無關性基石

各種不同的虛擬機器都可以載入和執行一種平臺無關的位元組碼,從而實現“一次編寫,到處執行”。

語言無關性越來越受到開發者重視,java只是執行在java虛擬機器上的一種語言。

實現語言無關性的基礎是虛擬機器和位元組碼儲存格式,使用java編譯器可以把java編譯成儲存位元組碼的class檔案,其他語言也可以把程式碼編譯成class檔案,虛擬機器只是執行class檔案而不關心檔案來源。

Java中各種變數、關鍵字和運算子的最終語義都是由多條位元組碼組成的,因此位元組碼的語義描述能力強於java語言本身,這也為其他語言實現有別於java的語言特性提供了基礎。

class類檔案的結構

Class檔案是一組以8位位元組為基礎單位的二進位制流,各個資料專案嚴格按照順序儲存在class檔案中,中間沒有任何間隔,所以這些都是必要資料。

這種資料結構中只有兩種型別的資料:無符號數和表。

無符號數是基本資料型別,可以描述數字、索引引用、數量值或者utf-8編碼的字串。

表是由多個無符號數或者表組成的混合資料,整個class檔案本質上就是一張表。

魔數class檔案的版本

Class檔案前4個字元是魔數,唯一作用是用來確定這個檔案是否可以被虛擬機器接收。

魔數後的4個字元是class檔案版本號,5/6為次版本,7/8為主版本。高版本的JDK能相容低版本的class檔案,反之不可以。

常量池

主次版本號之後就是常量池入口,常量池是class檔案中與其他專案關聯最多的資料型別。

常量池主要存放字面量和符號引用。字面量比較像java語言層面的常量概念,比如字串,定義為final的變數;符號引用則屬於編譯原理方面的概念,包括下面3類常量:

類和介面的全限定名;欄位的定義和描述符;方法的定義和描述符。

Java程式碼進行javac編譯時不會儲存各個方法和欄位的最終佈局資訊,虛擬機器執行時,需要從常量池獲取對應的符號引用,再在類建立或執行時解析並翻譯到具體記憶體地址中。

由於class檔案中方法、欄位等需要引用CONSTANT_Utf8_info型常量描述,所以他的最大長度就是方法和欄位名的最大長度。最大長度為length的最大值,即u2型別最大值65535。

訪問標誌

常量池之後是2位元組的訪問標誌,表示類和介面層次的訪問資訊,比如是否為介面,是否public,是否抽象等。

類索引、父類索引和介面索引集合

類索引和父類索引是u2型別資料,介面索引集合是一組u2資料集合,class檔案由這三個資料確定繼承關係。

欄位表集合

欄位表用於描述介面或類中定義的變數。欄位包括了類級別變數或例項級變數,不包括方法內部的變數。描述一個欄位的資訊,比如作用域、資料型別等各個修飾符都是布林值。而欄位名等無法固定的,只能引用常量池中的常量描述。

方法表集合

與欄位表集合類似。

方法裡的程式碼,經過編輯器編譯成位元組碼指令之後存放在方法表集合屬性中“code”的屬性裡。

Java中過載一個方法,需要有一個與原來方法不同的特徵簽名,他就是一個方法中各個引數在常量池中的字元引用集合,但是返回值不包含在特徵簽名中,所以無法用返回值不同過載方法。但是在class檔案中特徵簽名包括了返回值,即方法返回值不同就可以同時存在,其他語言可以利用這個特性。

屬性表集合

Class檔案、欄位表、方法表都可以攜帶自己的屬性表集合,以描述某些場景專有資訊。

不像class檔案其他專案一樣有嚴格的資料限制。其他人實現的編譯器可以像屬性表中新增自定義屬性,虛擬機器會忽略不認識的屬性。

虛擬機器類載入機制

類載入時機

按照這個順序開始載入,各個階段可以重疊。解析和使用順序不固定。

1.遇到new、getstatic、putstatic或invokestatic這4個指令時,如果類沒初始化,則需要先觸發初始化;

2.使用java.lang.reflect包的方法對類進行發射呼叫時;

3.初始化類時,如果父類沒初始化則先初始化父類;

4.虛擬機器啟動時,需要先初始化執行主類(包含main()方法那個類);

這四種為主動引用,其餘都是被動引用。

通過子類引用父類中的靜態欄位,只會初始化定義這個欄位的類(父類)。

通過陣列定義引用類,不會觸發初始化。

常量在編譯階段會存入呼叫類的常量池中,本質上沒直接引用定義常量的類,所以不會載入定義常量的類。

介面中不能使用“static{}”語句塊,但是編譯器仍然會生成類構造器用於初始化成員變數。介面只有在真正用到父類介面時才會初始化父介面,其餘主動初始化與類一致。

類載入的過程

載入:

1.      通過類的全限定名獲取此類的二進位制位元組流。

2.      將這個位元組流的靜態儲存結構轉換為在方法區的執行時資料。

3.      在堆記憶體中生成代表這個類的java.lang.class檔案,作為訪問這些資料的入口。

驗證:

保證class檔案中的位元組流資料符合虛擬機器的要求,不會對虛擬機器造成安全問題。

驗證的四個階段:檔案格式、元資料、位元組碼和符號引用。

驗證機制非常重要但不是必要的,可以關閉驗證以縮短類載入時間。

準備:

正式為類變數分配記憶體並設定初始值,這些記憶體都在方法區中進行分配。

僅僅分配類變數(static)而不包括例項變數,例項變數將在初始化時在堆記憶體中分配。其次這裡說的初始化“通常情況下”是資料的零值。比如:

Public static int value = 123;

準備階段過後的初始值是零值(0),在初始化時才會賦值為123。而加上final後準備階段可直接賦值為123。

解析:

解析過程是虛擬機器將常量池中的符號引用替換為直接引用的過程。

初始化:

在類載入過程中,除了載入階段可以用自定義類載入器之外,其他過程都有虛擬機器主導和控制,這一階段才真正開始執行類中定義的java程式程式碼(位元組碼)。

初始化階段是執行類構造器<clinit>()方法的過程。

<clinit>()方法是由編譯器自動收集類中所有類變數的賦值動作和靜態語句塊中的語句合併產生的。與構造方法不同,他不需要顯示的呼叫父類構造器,並且虛擬機器保證子類<clinit>()方法執行前,父類<clinit>()方法已經執行完畢。

<clinit>()方法不是必須的,如果一個類沒有靜態語句塊也沒有賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。

虛擬機器會保證<clinit>()方法在多執行緒中正確的加鎖和同步。

類載入器

類載入階段中的“通過全限定名稱來獲取二進位制位元組流”動作放到虛擬機器外實現,以便讓程式自己決定如何去獲取所需要的類。實現這個動作的程式碼塊稱為類載入器。

類與類載入器

對於任意一個類,都需要這個類的載入器與類自身確定其在虛擬機器中的唯一性。比較兩個類是否“相等”,需要在同一個類載入器的前提下才有意義。

雙親委派模型

在虛擬機器的角度只存在兩種類載入器,一種是啟動類載入器,用C++實現並是虛擬機器的一部分。另一種就是其他類載入器,用java實現並且獨立於虛擬機器之外,都繼承自抽象類java.lang.ClassLoader。

雙親委派模型的工作過程:類載入器收到載入一個類的請求時,會先把請求委派給父類載入器,每層都是如此,最終都會傳到頂層的啟動類載入器,當父類無法完成載入請求(找不到類)時,子類載入器才會嘗試自己載入。

雙親委派模型的好處是,類隨著他的類載入器一同具有了優先順序的層次關係,對於保證程式穩定很重要。比如java.lang.Object類,他存放在rt.jar中,無論哪個載入器要載入他,最終都會委派為啟動類載入器,保證Object類在各種類載入器環境中都是同一個類。

雙親委派模型不是強制的,只是設計者們推薦的模型。

虛擬機器位元組碼執行引擎

執行時棧幀結構

棧幀是用於支援虛擬機器進行方法呼叫和執行的資料結構,是虛擬機器執行時資料區的虛擬機器棧中的棧元素。棧幀儲存了方法的區域性變量表,運算元棧,動態連結和方法返回地址等資訊。每一個方法從執行到完成,對應著一個棧幀從入棧到出棧的過程。

在程式編譯程式碼的時候,棧幀中需要多大的區域性變量表、多深的運算元棧已經確定並寫入到方法表的code屬性中,不會在執行期受資料變數的影響,僅僅取決於虛擬機器的實現。

區域性變量表

區域性變量表是一組變數值儲存空間,用於存放方法引數和方法內定義的區域性變數。

區域性變量表的容量以變數槽(variable slot)為最小單位,一個slot可以存下32位以下的資料型別,其中包括了reference(物件的引用)和returnAddress。虛擬機器至少(不明確)可以從此引用中查詢出物件在堆記憶體中的起始地址索引和方法區中物件的型別引數。ReturnAddress指向了一條位元組碼指令的地址。

Slot是可重用的,方法體中定義的變數不一定貫穿整個方法,如果當前位元組碼PC計數器的值已經超出了某個變數的作用域,slot可以交給其他變數使用,這樣不僅可以節省棧空間,還可以在一定程度上直接影響垃圾回收。比如:當離開了一個變數的作用域之後,沒有任何對區域性變數的讀寫操作,變數原先被佔用的slot沒有被其他變數複用,所以作為GC Roors的一部分的區域性變量表仍然保持著對它的關聯(不會被回收)。但是如果一個方法後面有一些很耗時的操作,而且定義了佔用大量記憶體、實際上不會再使用變數,手動將其設定為null就不是一個毫無意義的作用。不過不應當對賦null過多依賴,以恰當的作用域控制回收時間才是優雅的方案。

運算元棧

它是一個後入先出的棧,它的每一個元素可以是任意資料型別。當一個方法剛開始執行時,操作棧是空的,執行過程中會有各種位元組碼指令提取和寫入內容。

方法返回值

方法執行後有兩個方式退出:一個是遇到返回位元組碼指令,另一個是異常退出。退出後都需要返回到方法被呼叫的位置,並可能需要在棧幀中儲存一些資訊來幫助上層方法恢復執行狀態。退出過程相當於把當前棧幀出棧。

方法呼叫

不等同於方法執行,唯一任務就是確定被呼叫方法的版本(即呼叫哪個方法),暫時不涉及方法內部執行過程。

解析:

所有方法呼叫中的目標方法在class檔案中都是一個常量池的符號引用,在類載入的解析階段,會把一部分符號引用轉為直接引用,前提是方法在程式呼叫之前就有一個可用版本,並且在執行期是不可變的。

分派:

1.      靜態分派

Human man = new Man();

上面的Human稱為變數的靜態型別,Man稱為實際型別。兩種型別在程式中都可以發生變化,區別是靜態型別變化僅僅在使用時發生,變數本身的靜態型別不會改變,編譯期可知的;實際型別變化的結果在執行期才可確定,編譯器在編譯期並不知道一個物件的實際型別是什麼。

所有依賴靜態型別來定位方法執行版本的分派動作,都稱為靜態分派。最典型的應用是方法過載。發生在編譯階段,即不是虛擬機器來執行的。字面量沒有顯示的靜態型別,所以過載時往往只能確定一個“更適合的型別”。

2.      動態分派

和重寫有密切關聯。重寫方法的兩條呼叫指令,無論是指令(都是invokeVirtual)還是引數(常量池中的常量)都一樣,只是目標方法不同,原因是invokeVirtual指令第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中invokeVirtual指令把常量池中類方法符號引用解析到了不同的直接引用上,這就是重寫的本質。把執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。

3.      單分派與多分派

編譯階段編譯器的選擇過程,即靜態分派過程,是根據靜態型別和方法引數兩個宗量,過載方法的不同引數指向不同方法的符號引用,是根據多個宗量進行選擇即靜態分派屬於多分派型別。

執行階段虛擬機器的選擇,即動態分派過程,在執行程式碼對應的invokeVirtual指令時,由於編譯期已經決定了目標方法的簽名,引數的靜態型別、實際型別都不會對方法的選擇構成影響,唯一可以影響虛擬機器選擇的因素只有此方法的接收者實際型別。只有一個宗量作為選擇依據,即動態分派屬於單分派型別。

java1.6是靜態多分派、動態單分派的語言,但是不代表以後不會改變。

基於棧的位元組碼解釋執行引擎

許多java虛擬機器在執行java程式碼時都有解釋執行(直譯器執行)和編譯執行(通過即時編譯器編譯成原生代碼)兩種執行方式。

解釋執行

許多高階虛擬機器語言,大多都遵循這種基於現代經典編譯原理的思路,執行前先對原始碼進行詞法和語法分析處理,把原始碼轉化為抽象語法樹。

對於一門語言,可以把幾乎全部編譯過程獨立執行引擎,形成一個完整意義的編譯器去實現,比如C/C++語言;也可以把其中一部分(抽象語法樹之前)實現為一個半獨立編譯器,比如java;又可以把這些編譯步驟和執行引擎全部封裝在一個黑匣子裡,比如大多數JavaScript執行器。

基於棧的指令集與基於暫存器的指令集

Java編譯器輸出的指令流,基本上是一種基於棧的指令集架構。

兩者區別:

分別使用兩種指令集計算“1+1”。

基於棧的指令集:把兩個常量1壓入棧,然後取出棧頂2個常量相加,返回結果放回棧頂,最後把棧頂的值放入區域性變數第0個slot。

基於暫存器的指令集:把一個暫存器的值設為1,然後使值增加1,結果還儲存在這個暫存器中。

基於棧的指令集可移植性好,不像暫存器一樣由硬體直接提供,缺點是執行稍慢。