Java虛擬機類加載器與虛擬機字節碼執行引擎

1.類加載器

對於類加載器,java虛擬機規範中有這麼一句,“通過一個類的全限定名來獲取描述此類的二進制字節流”,正因爲這個看似寬泛的約束使得java虛擬機的實現有了很多的種類,各自有自己的特點並佔有自己的領域。雖然類加載器是由Applet技術開發出來的,但是Applet技術基本上已經掛了,但是因類加載思想卻在類的層次劃分,OSGI,熱部署,代碼加密等領域大放異彩。

對於任意一個類,都需要由加載他的類加載器和這個類本身一同確立在java虛擬機上的唯一性,這也就是package包的一個側面引證吧,每一個類加載器都有自己的獨立命名空間,和前面的包的概念不謀而合,判斷兩個類是否是相等,只有在這兩個類是由同一個類加載器加載的前提下才有意義,即使兩個來源於同一個Class 文件,被同一個虛擬機加載,只要兩者的類加載器不同,這兩個類就不等。(相等是指,Class對象的equals方法,isAssignableFrom方法,isInstance方法的返回結果相同)。

雙親委派模型:

對於java類加載器來說,總體上可分爲兩類,一類是由C++等語言實現的(如HotSpot)的啓動類加載器,另一類是剩餘的其它的加載器,都由java語言實現,獨立於虛擬機外部,都繼承於java.lang.ClassLoader。如下圖:

這是雙親委派模型的一個示意圖,在此模型中,除了頂層的啓動類加載器外,其餘的類加載器都應當由自己的父類加載器,這裏的繼承方式不是面向對象中的繼承(Inheritance)而是使用組合的方式實現父類加載器的代碼的複用。
工作過程:

如果一個類加載器受到了類加載的請求,他首先不會自己區嘗試加載這個類,而是把這個請求爲派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都會傳送到頂層的啓動類加載器中,只有當父類的加載器反饋自己無法完成這個請求時,子加載器纔回去嘗試加載。這種方式的好處就是能夠有一種優先的層級關係,例如Object類,他放在rt.jar文件中,無論哪一個類加載器都要加載這個類,因爲他是所有類的父類,最終都是爲派給啓動類加載器去加載Object。因此所有的Object類是唯一的。如果都自己去編寫,那麼可能會產生多個Object。實現的代碼都在Java.lang.ClassLoader的loadClass方法中。

雙親委派模型的破壞:

上提到的模型並不是一個強制性的約束,而是給開發者的一個推薦的模型,這裏說明3個雙親委派模型達不到,或者是不太適合的情形:

1. 時間節點,JDK1.2; 在1.2發佈之前,模型還沒有被引入到Java的類加載中,之前的類加載器和抽象類java.lang.ClassLoader都在JDK1.0時就已經存在了,面對已經存在的用戶自定義類加載器的實現代碼,Java設計者引入此模型時不得不做出一定的妥協,爲了能夠很好的向前兼容,JDK1.2後加入了一個新的protected方法,findClass,在此之前用戶繼承java.lang.CalssLoader就是爲了能夠重寫loadClass方法,因爲虛擬機在執行的時候會調用loadClassInternal方法,而這個方法的唯一的邏輯就是調用自己的loadClass方法。在1,.2之後就不再提倡用戶覆蓋loadClass方法,而是把自己的邏輯寫到findClass方法中,如果loadClass方法父類加載失敗則可以調用自己的findClass方法完成加載,這樣就可以保證符合模型的規則了。

2.由於自身的模型的缺陷導致的無法解決某些問題而造成的破壞。模型很好的解決了各個類加載器的基礎類的統一問題,這是因爲他們總是作爲用戶代碼調用的API,但是如果我們需要調用用戶代碼該怎麼辦?一個最典型的例子就是JNDI服務,他的代碼由啓動類加載器去加載,但是JNDI的目的就是對資源進行集中管理和查找,她需要調用獨立廠商實現並部署在應用程序上的ClassPath路徑下面的JNDI 接口提供者 SPI,但是啓動類不可能加載這些類。 此時引入了一個不太優雅的設計,線程上下文加載器。這個加載器可以通過java.lang.Thread類的setContextClassLoader方法進行設置,如果創建線程沒有設置,將會從父類線程中繼承一個,如果在應用程序的全局範圍內都沒有設置的話,那麼這個類加載器默認就是應用程序類加載器,實際上這已經違背了雙親委派模型的一般性原則,其它如JDBC,JCE,JAXB等,都有類似的實現。

3.程序的動態性的支持以及一些熱代碼替換,熱模塊部署,若進行某個重要的硬件如鼠標等替換,如果個人計算機,重啓一次沒多大問題(當然現在都已經實現了熱拔插技術),但是對於大型的軟件企業就會造成很大的損失。對此OSGi實現了模塊熱部署,關鍵點在於他自己定義了類加載機制。每一個程序模塊(OSGi中稱爲Bundle)都有一個自己的類加載器,當更換需要一個Baundle時候就把這個Bundle和類加載器一同換掉,實現代碼的熱替換。

2.虛擬機字節碼執行引擎

執行引擎是java虛擬機核心組成部分之一,虛擬機中的執行引擎是相對於物理機的概念,區別就是物理機與硬件、指令集和操作系統層面上的相關,而java虛擬機的執行引擎是自由實現的,java虛擬機規範中並沒有給出或者是要求用何種方式去實現一個執行引擎,所以java自己定義了自己的執行引擎和指令集(以HotSpot虛擬機爲例),從而這些指令不被硬件直接執行。

java的class文件以格式緊湊而聞名,在java 的指令集設計上就已經有了體現,他的指令大多數是單個的操作碼,或者是單個指令跟一個操作數,操作碼儘量設計成一個字節。而普通的基於硬件的指令集,如常見的X86指令集,則會有多個操作碼。java的class文件的特點和java面向網路的特性是分不開的,緊湊的文件更利於網絡的傳輸,但是,最大的問題是相對於基於硬件的指令集性能上是有一定落後的。

運行時棧幀結構

棧幀結構是用於虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區域中虛擬機棧的元素,存儲了局部變量表,操作數棧,動態連接和方法返回地址等信息,每一方法的調用都會經歷出棧和入棧的過程。在編譯代碼時,棧的最大深度和局部變量數量已經被寫入方法屬性中的Code屬性中,max_stacks和max_locals分別表示最大棧深度和最大局部變量數量,因此一個棧需要分配多少內存是不會受到運行時變量的數據的影響,而取決與虛擬機的實現。一般說來,只有位於棧頂的棧幀是有效的,稱爲當前棧幀,其關聯的方法叫做當前方法,執行引擎的所有字節碼指令都只針對當前的棧幀操作,概念如下圖所示:

局部變量表:

局部變量表是以變量槽slot爲最最小單位,虛擬機中並沒有明確指出一個slot應該佔用內存的大小,只是導向性的說明每個slot都應該能存放一個boolean,char,byte,short,int,float.reference或returnAddress數據,returnAddress數據現在使用的比較少了,在早期的java虛擬機中的jsw以及jsw_r等指令用於返回調用的地址。對於其他的7個,首先應先看最小的slot使用多大的物理內存來表示的,32位或64位是隨着處理器,操作系統以及虛擬機的不同而變化的。例如32位,則需要保證使用32位的物理內存空間去實現一個slot,虛擬機仍然需要對其和補白,對於64位的來說,只不過是爲了讓64位的和32位的在外觀上看起來一致。然而對於reference類型,並沒有規範說明其長度,也沒有明確的說明這種引用應該又怎樣的結構,但是一般說來需要支持兩點:首先,從此引用能夠直接後者是間接的找到對象在java堆中的數據存放的起始索引,而是此引用中直接或者簡介的找到對象所屬數據類型在方法區中存儲的類型信息,否則無法實現java語言規範中定義的語法約束。

不過對於long和double數據來說,由於局部變量表建立在線程的堆上,是線程私有的數據,關於讀寫long或者是double的數據類型的原子性操作需要要論一下,因爲線程私有不會共享,所以此種情況心下是不會出現數據安全問題的,然而在局部變量表中,加入n位置爲一個32位的數據,n+1位一個long類型的整數,那麼n+1表示的數據需要兩個slot來進行存放。不允許以任何方法訪問兩個之中的任何一個,否則會拋出異常。

slot默認0位置爲傳遞方法所屬對象的實例引用,可以通過this關鍵字來方法這個隱形的參數,其餘的從1開始,參數表分配完畢後再根據方法體內部定義的變量的順序和作用域分配其它的slot。爲了儘可能的節省棧的空間,局部變量表slot是可以重用的,方法體中定義的變量,其作用於不一定會覆蓋整個方法體,如果當前字節碼pc計數器已經超過了某個變量的作用域,那麼這個變量對應的slot就可以交個其他變量使用,也會帶來一定的副作用,就是某些情況下slot的複用會引起系統垃圾收集行爲的不同,如下:

public static void main(String[] args){
    byte[] placeholder = new byte[ 64 * 1024 * 1024 ];
    System.gc();
}

如果運行查看GC日誌發現placeholder並沒有別回收,因爲在執行gc時,placeholder還處於作用域之內,虛擬機並不會回收。如修改爲如下代碼:

public static void main(String[] args){
    {
        byte[] placeholder = new byte[ 64 * 1024 * 1024 ];
    }
    //int a = 0;
    System.gc();
}

按道理說,placeholder已經被限制在內存的作用域之內了,但是查看gc日誌,還是沒有回收placeholder,然而加上註釋掉的語句就又可以實現垃圾回收了,這又是爲什麼呢?這是因爲局部變量表中的slot是否還存在有關於placeholder數組對象的引用,第一次修改,雖然代碼已經離開了其作用域,但是沒有 任何的局部變量表的讀寫操作,placeholder原本所佔用的slot還沒有被其它的變量所複用,所以作爲GC Roots 一部分的變量表仍然保持着對於他的關聯,這種關聯還沒有及時打斷。如果將其設置爲null同樣也可以將起打斷。局部變量表不同於類變量,類變量可以在準備階段實現系統初始值的設置,在初始化階段可以初始爲自定義的初始值,但是,局部變量就不一樣了,如果一個局部變量定義了但是沒有設置初始值是不能夠使用的。

操作數棧:

這是一個先入後出的數據結構,32位數據其數據類型所佔的棧容量爲1,64位爲2,都不會超過Code屬性值中記錄的最大棧深度和最大局部變量個數。當一個方法開始i執行的時候操作數棧是空的,在執行時會進行出棧和入棧的行爲。操作數棧中的字節碼指令必須和數據類型完全匹配,這也是強類型的一個側面證明,在類型校驗階段的數據流分析中還要再次驗證。

動態連接:

每個棧幀都包含一個執行運行時常量中該棧幀所屬方法的引用,吃用這個引用是爲了支持方法調用過程中的動態連接,這些符號引用一部分會在類加載節點就轉化爲了直接引用,這種稱爲靜態解析,另一部分會在每一次運行期間轉化爲直接引用,這部分稱爲動態連接,具體的動態連接及相關的內容會在組織一個博客來說明下。

方法返回地址

當一個方法開始i執行後,只有兩種方式可以讓這個方法退出。第一種方式就是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層方法的調用者,是否會有返回值和返回值的類型將根據遇到何種方法指令來決定,這種推出方法的方式稱爲正常完成出口。另一種退出方式是,方法執行過程中遇到了異常。代碼如果在本方法的異常表中沒有搜索到匹配的異常處理器就會導致方法的退出,這種退出方式稱爲異常完成退出,其不會給上層調用者返回任何值的。

方法的繼續執行需要在方法退出時返回到被調用的位置,可能會在棧幀中保存一部分的信息,來幫助恢復它上層方法的執行狀態。方法的退出等於當前方法的出棧,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值壓入調用者棧幀的操作數棧,調整pc計數器中的值可移植性方法調用命令指令後面的一條指令。

附加信息:

虛擬機規範中允許具體的虛擬機實現增加一些規範裏沒有描述的信息到棧幀中,如調試相關信息,這部分完全取決於虛擬機的實現,一般會把動態連接,方法返回地址與其它的附加信息全部歸爲棧幀信息一類。

基於棧的字節碼解釋執行引擎

java虛擬機的執行引擎在執行java代碼時候都有解釋執行和編譯執行的選擇,解釋執行,例如早期的java虛擬機通過字節碼解釋的方式運行,編譯執行如當下的JIT編譯,將本地某些熱點代碼便以爲本機執行的二進制代碼,提高代碼的運行效率,在這裏扯一下Java虛擬機兩種模式,Client模式能夠較快的啓動,進行一些必要的優化,但是不會在後臺進行深度的優化。Server模式則啓動較慢,但是長時間運行性能要比Client模式高。這裏其是最大的區別就在於後臺編譯執行的區別,Java虛擬機有三個編譯的層次,第一次純的字節碼解釋,在古老的java版本中使用較多,普通情況下默認c1級別的編譯,解釋字節碼以及進行一些必要的一定程度的優化,而C2模式則會對代碼進行深度的優化,比較適合於Server模式。

解釋執行:

java出生的JDK1.0時代這種定義時比較準確的,當虛擬機中包含了即時編譯器之後,Class文件中的代碼到底會被解釋執行還是編譯執行這就成了虛擬機自己做選擇的事情。再後來也有了直接生成本地代碼的編譯器,如GCJ,而C/C++也有了解釋執行器的版本,如CINT,這時候籠統的說解釋執行則對整個Java來說幾乎成了沒有意義的概念,只有確定了談論對象是某種具體的Java實現版本和執行引擎的運行模式後,編譯執行和解釋執行的討論纔有意義。大部分的程序代碼在物理機的目標代碼或者是虛擬機能執行的指令集之前,都需要經過下圖的過程:

大多數物理機或者是java虛擬機都會遵循現代經典編譯原理的思路,在執行前都會對程序源碼進行詞法分析和語法分析,把源碼抽象稱爲抽象的語法樹。而對於c/c++而言,詞法分析和語法分析以至於後面的優化器和目標代碼生成器都可以選擇獨立與執行引擎。當然也可以把其中的一部分實現爲一個半獨立的編譯器,這類的代表就是Java語言,又或者將這些都全部集中封裝,如JavaScript執行器。對於Java而言,詞法分析,語法分析然後抽象稱爲語法樹這些工作都是在虛擬機之外進行的,二姐時期在虛擬機的內部,所以Java程序的編譯就是半獨立的實現。

基於棧的指令集和基於寄存器的指令集:

java基本上是一種基於棧的指令集架構,指令流中的指令大部分爲零地址指令,他們依賴操作數棧進行工作。而另一種基於寄存器的指令集如上面提到的典型的X86的二地址指令集,舉個1+1計算的例子看下不一樣的地方:

#Java的單字節指令
iconst_1
iconst_1
iadd
istore_0

#x86的而地址指令集
mov eax, 1
add eax, 1

可以看到,java是進行出棧和入棧的操作,遇到iadd指令則計算然後將結果進行放到局部變量表的第0個slot中。而x86指令則使用mov指令將eax寄存器設置爲1,然後利用add進行加法計算,然後將結果保存在eax寄存器中。基於棧的指令集優勢在於有較好的可移植性,不太受到硬件的約束,編譯器實現簡單。而基於寄存器的指令則相比基於棧的指令中頻繁的入棧出棧性能損失要小的多。相對於處理器來說,內存始終是執行速度的瓶頸。

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