虛擬機的類加載和執行機制是虛擬機的最主要功能,在這裏簡單的對所知的內容進行一次溫習,並記錄以方便往後重溫。 java
本篇主要引用《深刻理解Java虛擬機——JVM高級特性與最佳實踐》一書。 數據結構
一、類文件結構 多線程
java虛擬機要對類文件進行加載和執行,那麼必需要可以理解類文件結構,而對於虛擬機而言,平臺無關性和語言無關性是其最重要的兩大特徵,那麼就勢必要對類文件結構進行規範化和結構化,這樣才能保證不管是什麼語言編譯成的字節碼文件,java虛擬機都可以正常加載和執行。所以,對於字節碼文件(即.class文件)的簡單理解是進一步理解虛擬機運行機制的基本步驟。 架構
Class類文件,亦稱字節碼文件,是由虛擬機規範規定了其結構形式的文件。Class文件是一組以8位爲基礎單位的二進制流,各個數據項目嚴格按照順序緊湊排列在Class文件中,中間沒有任何分隔符,以保證整個Class文件中存儲的內容所有是程序運行的必要數據,沒有空隙。當遇到須要佔用8位字節以上空間的數據時,則會按照高位在前的方式分割成若干上8位字節進行存儲。 ide
根據虛擬機規範的規定,Class文件格式中只有兩種數據類型:無符號數和表。無符號數屬於基本的數據類型,以u一、u二、u四、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虛擬機》這本書,也能夠直接翻看虛擬機規範。
二、類加載機制
瞭解了類的文件結構,接着咱們來了解虛擬機如何加載這些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)來實現類的加載模式。雙親委派模型要求除了頂層的啓動類加載器外,其他的類加載器都應當有本身的父類加載器,此處的父子關係不以繼承來實現,而是採用組合來利用父加載器。
雙親委派模型的工做過程:若是一個類加載器收到了類加載的請求,它首先不會本身去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此,所以全部的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父加載器反饋本身沒法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會本身去加載。其模型以下圖所示:
三、虛擬機字節碼執行引擎
瞭解了以上類文件結構和類加載機制後,咱們最後再來看看字節碼在虛擬機中是如何被執行的。
不一樣的虛擬機實現時碩,執行引擎在執行Java執行的時候可能有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼執行)兩種選擇,也可能二者兼備,甚至可能包含幾個不一樣級別的編譯器執行引擎。
在具體瞭解虛擬機是如何執行字節碼以前,咱們先來從概念上理解虛擬機是如何執行程序的。程序的執行能夠直接解釋爲是對方法的遞歸調用,經過一連串的方法鏈來最終得出執行結果,亦便是說虛擬機對程序的執行,根本上是對方法的調用和執行。
棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,是虛擬機運行時數據區中的虛擬機棧的棧元素。棧幀存儲了方法的局部變量表(最小單位爲變量槽Variable Slot)、操做數棧、動態鏈接和方法返回地址等信息,每個方法從調用開始到執行完成的過程,就對應着一個棧幀在虛擬機棧裏面從入棧到出棧的過程。棧幀的內容在編譯時就已經完成肯定,不受程序運行期變量數據的影響,僅取決於具體的虛擬機實現。
前面說了,對程序的執行就是對方法鏈的調用和執行,便可能會出現不少方法同時處於執行狀態,此時對於執行引擎來講,活動線程中,只有棧頂的棧幀是有效的,稱爲當前棧幀,其關聯的方法稱爲當前方法,執行引擎所運行的全部字節碼指令只針對當前棧幀進行操做。
方法調用包含兩種方法:解析和分派。解析調用必定是個靜態過程,在編譯期間徹底肯定,在類裝載的解析階段就會把涉及的符號引用所有轉變爲可肯定的直接引用,不會延遲到運行期再去完成。而分派調用則多是動態的也多是靜態的,根據分派依據的宗量數可分爲單分派和多分派。(具體情形請參考《深刻理解java虛擬機》這本書第8章)
方法執行便是指字節碼解釋執行引擎,包括解釋執行和編譯執行。而java編譯器輸出的指令流,基本上是一種基於棧的指令集架構。即Java虛擬機採用的是基於棧的字節碼執行引擎。(具體情形請參考《深刻理解java虛擬機》這本書第8章)