類加載機制詳解(有條理)

虛擬機的類加載和執行機制是虛擬機的最主要功能

本篇主要引用《深入理解Java虛擬機——JVM高級特性與最佳實踐》一書。

1、類文件結構

    java虛擬機要對類文件進行加載和執行,那麼必須要能夠理解類文件結構,而對於虛擬機而言,平臺無關性和語言無關性是其最重要的兩大特徵,那麼就勢必要對類文件結構進行規範化和結構化,這樣才能保證無論是什麼語言編譯成的字節碼文件,java虛擬機都能夠正常加載和執行。因此,對於字節碼文件(即.class文件)的簡單理解是進一步理解虛擬機運行機制的基本步驟。

    Class類文件,亦稱字節碼文件,是由虛擬機規範規定了其結構形式的文件。Class文件是一組以8位爲基礎單位的二進制流,各個數據項目嚴格按照順序緊湊排列在Class文件中,中間沒有任何分隔符,以保證整個Class文件中存儲的內容全部是程序運行的必要數據,沒有空隙。當遇到需要佔用8位字節以上空間的數據時,則會按照高位在前的方式分割成若干上8位字節進行存儲。

    根據虛擬機規範的規定,Class文件格式中只有兩種數據類型:無符號數和表。無符號數屬於基本的數據類型,以u1、u2、u4、u8來分別代表1個字節,2個字節,4個字節和8個字節的無符號數,無符號數可以用來描述數字、索引引用、數量值、或者按照UTF-8編碼構成字符串值。表是多個無符號數或其它表作爲數據項構成的複合數據類型,所有表都習慣性地以"_info"結尾。無論是無符號數還是表,當需要描述同一類型但數量不定的多個數據時,經常會使用一個前置的容量計數器加若干個連續的數據項的形式,這時候稱這一系列連續的某一類型的數據爲某一類型的集合。

    說完了虛擬機規範對Class文件的基本約定後,我們來關注一下Class文件都有些啥。

    既然虛擬機是語言無關,那我們可以以java語言作爲範本進行學習。回顧一下,我們在定義一個類的時候,都需要或者說可以定義些什麼內容。首先,類的修飾符,是abstract,或public、protected、private,然後是類名,再接着,是否有繼承或是實現父類或接口,這些是類的基本約束。再接着來看類的內容,我們可以定義類成員變量(static)和實例成員變量,接着是定義類的行爲——類方法,類方法又有方法名,返回值,參數值,還有異常列表等。

    由上面這些定義的內容,我們可以猜到,當這個定義的類被編譯成Class文件時,Class文件中應該要包含些什麼內容了。我們再從虛擬機的角度來完整地瞭解Class文件的結構。

    首先,最簡單的一個問題,虛擬機必須判定輸入的文件是不是一個Class文件,java虛擬機通過識別輸入文件的首4個字節的魔數(0xCAFEBABE)來確定其是否Class文件。接着虛擬機由於一直在不斷地改進和更新,所以不斷有新的版本出現,新的版本能兼容舊的版本,但舊的版本可能就完全無法讀取新的虛擬機編譯而成的Class文件了,因此,虛擬機就必須對Class文件進行版本的識別和檢查,也就是說,Class文件必須要有版本號的數據(Class文件的第5到第8個字節)。

    緊接着是Class文件的常量池入口,常量池是Class文件結構中與其它項目關聯最多的數據類型,也是佔用Class文件空間最大的數據項目之一,同時也是在Class文件中第一個出現的表類型數據項目。由於常量池中常量的數量不固定,因此在常量池的入口之前有一個u2類型的容量計數值。常量池之中主要存放兩大常量:字面量(Literal)和符號引用(Symbolic Reference)。字面量即是java語言層面中的常量,如文本字符串(如"adb"等字面量),被聲明成final的常量值。符號引用則屬於編譯原理方面的概念,包括三類常量:類和接口的全限定名,字段的名稱和描述符,方法的名稱和描述符。這些符號引用在虛擬機中如果不經過轉換則無法與實際內存相連接,即無法被虛擬機直接使用,在虛擬機運行時,需要從常量池獲得對應的符號引用,再在類創建時或運行時解析並翻譯到具體的內存地址中。每項常量都是一個表,而由於各個常量的類型不一,大小也不相同,所以同樣需要一個u1類型的數據來標記常量的類型,以確定其後的常量表的格式。

    在常量池之後,緊接着的2個字節代表訪問標誌,即在前面說到的,這個Class是類還是接口,是用哪個修飾符來修飾,abstract,public等,還有,如果是類的話,是否被聲明爲final,等等。

    訪問標誌之後,則是類索引、父索引與接口索引的集合。類索引和父類索引都是一個u2類型的數據,而接口索引集合是一組u2類型的數據的集合,Class文件中由這三項數據來確定這個類的繼承關係。類索引用來確定這個類的全限定名,父類索引用於確定這個類的父類的全限定名,接口索引集合用來描述這個類實現了哪些接口,這些被實現的接口將按實現或繼承的順序從左到右的順序排列在接口的索引集合中。類索引、父類索引和接口索引都按順序排列在訪問標誌之後。

    接下來就是字段表了,此處字段表存的就是前文說的類成員變量或實例成員變量,但不包括方法內部聲明的變量。如果類存在父類,則除非子類覆蓋了父類的字段定義,否則在子類中不會列出從超類或父接口中繼承而來的字段,但有可能列出原來java代碼中不存在的字段,譬如在內部類爲了保持對外部類的訪問性,會自動添加指向外部類實例的字段。另外,java中是不允許出現相同的字段名的,但對於字節碼來說,如果兩個字段的描述符不一致,則字段重名是合法的。

    字段表之後就是方法表集全了。方法表集合與字段表集合的結構形式幾乎完全一致。此處,方法中的代碼的存放位置則是方法表的屬性表中的一項名爲"Code"的屬性裏面。與字段表集合相對應的,如果父類方法在子類中沒有被重寫(Override),則方法表集合中就不會出現來自父類的方法信息。

    最後來對上面說到的屬性表作個解釋。屬性表是Class文件格式中最具擴展性的一種數據項目,在Class文件,字段表,方法表中都可以攜帶自己的屬性表集合,以用於描述某些場景專有的信息(如方法表中專有的代碼信息),具體的屬性表的各個屬性項目若有興趣可以翻看《深入理解java虛擬機》這本書,也可以直接翻看虛擬機規範。

2、類加載機制

    瞭解了類的文件結構,接着我們來了解虛擬機如何加載這些Class文件。

    虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被直接使用的java類型,這就是虛擬機的類加載機制。

    類的生命週期包括加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸載(Unloading)等七個階段,其中驗證、準備和解析三個部分統稱爲連接(Linking)。而類的加載指的就是從加載到初始化這五個階段。

    這七個階段的順序除了解析階段和使用階段外,其它幾個階段的開始順序是確定的,必須按這種順序按部就班的開始,但不要求按這種順序按部就班的完成,這些階段通常是互相交叉地混合式進行的,通常會在一個階段執行的過程中調用或激活另外一個階段。解析階段在某些情況下可以在初始化階段之後再開始,以支持java的運行時綁定(RTTI),而使用階段則是按類文件內容的定義的不同而在不同的階段進行。

    虛擬機規範對於何時進行加載這一階段並沒有強制約束,但對於初始化階段,虛擬機規範是嚴格規定了有且只有四種情況必須立即對類進行初始化:

    a、遇到new,getstatic,putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指定的場景是:使用new關鍵字實例化對象,讀取或設置一個類的靜態字段以及調用一個類的靜態方法的時候。當然,被final修飾並在編譯期就把結果放入常量池的靜態字段不屬於這些場景,這類靜態字段的值在編譯期時就會被編譯器優化而直接放入常量池,其引用直接指向其在常量池的入口。

    b、使用java.lang.reflect包的方法對類進行反射調用時,如果類沒有進行過初始化,則需要先觸發其初始化。

    c、當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

    d、當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的類),虛擬機會先初始化這個主類。

    以上四種場景中的行爲稱爲對一個類進行主動引用,除此之外所有引用類的方式都不會觸發初始化,稱爲被動引用。

    接口的加載過程與類加載過程最主要的區別在於第三點,即當初始化一個接口時,並不需要先初始化其父接口,而是隻有真正使用到父接口中的字段的時候纔會初始化。

    以下對類加載的各個階段進行簡單的說明。

    加載階段,虛擬機需要完成三件事:通過一個類的全限定名來獲取定義此類的二進制字節流,將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構,在java堆中生成一個代表這個類的java.lang.Class對象,作爲方法區這些數據的訪問入口。

    驗證階段,不同虛擬機會進行不同類驗證的實現,但大致都會完成以下四個階段的檢驗過程:文件格式驗證(驗證字節流是否符合Class文件格式的規範,並能被當前版本的虛擬機處理),元數據驗證(對字節碼描述信息進行語義分析,保證其描述信息符合java語言規範),字節碼驗證(對類方法體進行數據流和控制流分析,保證類的方法在運行時不會做出危害虛擬機的行爲)和符號引用驗證(發生在將符號引用轉化爲直接引用的時候,在解析階段中發生)。

    準備階段,正式爲類成員變量(注意,不是實例成員變量,實例變量會在對象實例化時隨着對象一起分配在java堆上)分配內存並設置類變量初始值(通常情況下是數據類型的零值,不進行賦值操作)的階段,這些內存都將在方法區中進行分配。

    例:public static int value=123;則在準備階段過後,value的初始值爲0而不是123,賦值指令是在初始化階段通過構造方法來執行的。

    解析階段,虛擬機將常量池內的符號引用替換爲直接引用的過程。符號引用與內存佈局無關,而直接引用的目標必定已經在內存中存在。解析動作主要針對類或接口、字段、類方法、接口方法四類符號引用進行。

    初始化階段,真正開始執行類中定義的java程序代碼(字節碼),是執行類構造器<clinit>()方法的過程。

    <clinit>()方法的一些特點:

    <clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{})中的語句合併產生的,編譯器收集順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量。

    <clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不同,它不需要顯式地調用父類構造器,虛擬機會在子類的<clinit>()方法執行之前完成父類<clinit>()方法的執行。

    由於父類的<clinit>() 方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作

    <clinit>()方法對於類或接口來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,則編譯器可以不爲這個類生成<clinit>()方法。

    接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法,不同於類的地方是執行接口的<clinit>()方法時不坱要先執行父類的<clinit>()方法。

    虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖和同步,如果多個線程同時去初始化一個類,則只有一個線程去執行這個類的<clinit>()方法,其它線程阻塞等待,直到活動線程執行<clinit>()方法完畢。

    瞭解完各個類加載機制的階段後,我們需要進一步瞭解類加載器這個概念。類加載器只用於實現類的加載動作,即實現通過一個類的全限定名來獲取描述此類的二進制字節流。但對於類來說,要判斷兩個類是否相等(instanceof,equal),其前提是兩個類是由同一個類加載器所加載,否則,無論兩個類是否來源於同一個Class文件,這兩個類都必定不等,亦即是說,對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在java虛擬機的唯一性。

    在Java開發人員看來,類加載器可劃分爲以下三類系統提供的類加載器:啓動類加載器(Boostrap ClassLoader,負責將存放在<JAVA_HOME>\lib目錄中的類庫加載到虛擬機內存中,其無法被Java程序直接引用),擴展類加載器(Extension ClassLoader,由sun.misc.Launcher$ExtClassLoader實現,負責加載<JAVA_HOME>\lib\ext目錄中的類庫,可被開發者直接使用),應用程序類加載器(由sun.misc.Launcher$AppClassLoader來實現,負責加載用戶類路徑(ClassPath)上指定的類庫,可被開發者直接使用,且爲默認的類加載器)。

    java中採用雙親委派模型(Parents Delegation Model)來實現類的加載模式。雙親委派模型要求除了頂層的啓動類加載器外,其餘的類加載器都應當有自己的父類加載器,此處的父子關係不以繼承來實現,而是採用組合來利用父加載器。

    雙親委派模型的工作過程:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會自己去加載。其模型如下圖所示:

3、虛擬機字節碼執行引擎

    瞭解了以上類文件結構和類加載機制後,我們最後再來看看字節碼在虛擬機中是如何被執行的。

    不同的虛擬機實現時碩,執行引擎在執行Java執行的時候可能有解釋執行(通過解釋器執行)和編譯執行(通過即時編譯器產生本地代碼執行)兩種選擇,也可能兩者兼備,甚至可能包含幾個不同級別的編譯器執行引擎。

    在具體瞭解虛擬機是如何執行字節碼之前,我們先來從概念上理解虛擬機是如何執行程序的。程序的執行可以直接解釋爲是對方法的遞歸調用,通過一連串的方法鏈來最終得出執行結果,亦即是說虛擬機對程序的執行,根本上是對方法的調用和執行。

    棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,是虛擬機運行時數據區中的虛擬機棧的棧元素。棧幀存儲了方法的局部變量表(最小單位爲變量槽Variable Slot)、操作數棧、動態連接和方法返回地址等信息,每一個方法從調用開始到執行完成的過程,就對應着一個棧幀在虛擬機棧裏面從入棧到出棧的過程。棧幀的內容在編譯時就已經完成確定,不受程序運行期變量數據的影響,僅取決於具體的虛擬機實現。

    前面說了,對程序的執行就是對方法鏈的調用和執行,即可能會出現很多方法同時處於執行狀態,此時對於執行引擎來說,活動線程中,只有棧頂的棧幀是有效的,稱爲當前棧幀,其關聯的方法稱爲當前方法,執行引擎所運行的所有字節碼指令只針對當前棧幀進行操作。

    方法調用包含兩種方法:解析和分派。解析調用一定是個靜態過程,在編譯期間完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變爲可確定的直接引用,不會延遲到運行期再去完成。而分派調用則可能是動態的也可能是靜態的,根據分派依據的宗量數可分爲單分派和多分派。(具體情形請參考《深入理解java虛擬機》這本書第8章)

    方法執行即是指字節碼解釋執行引擎,包括解釋執行和編譯執行。而java編譯器輸出的指令流,基本上是一種基於棧的指令集架構。即Java虛擬機採用的是基於棧的字節碼執行引擎。(具體情形請參考《深入理解java虛擬機》這本書第8章)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章