1. 程式人生 > 程式設計 >《深入理解Java虛擬機器器》--類載入與物件的探究

《深入理解Java虛擬機器器》--類載入與物件的探究

類的載入

Java虛擬機器器規範中,沒有強制約束什麼時候要開始載入,但是,卻嚴格規定了幾種情況

必須進行初始化(載入,驗證,準備則需要在初始化之前開始):

  • 遇到 new、getstatic、putstatic、或者invokestatic 這4條位元組碼指令,如果沒有類沒有進行過初始化,則觸發初始化。
  • 使用java.lang.reflect包的方法,對壘進行反射呼叫的時候,如果沒有初始化,則先觸發初始化
  • 初始化一個類時候,如果發現父類沒有初始化,則先觸發父類的初始化。


類從被載入到虛擬機器器記憶體開始,直到解除安裝出記憶體為止,它的整個生命週期包括:

載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段。其中,驗證、準備和解析統稱為連線(Linking)。過程如下圖所示:


載入

載入階段會做3件事情:

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
  • 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
  • 在Java堆中生成一個代表這個類的java.lang.Class物件,作為對方法區中這些資料的訪問入口。

相對於類載入的其他階段而言,載入階段(準確地說,是載入階段獲取類的二進位制位元組流的動作)是可控性最強的階段,因為開發人員既可以使用系統提供的類載入器來完成載入,也可以自定義自己的類載入器來完成載入。

載入階段完成後,虛擬機器器外部的二進位制位元組流就按照虛擬機器器所需的格式儲存在方法區之中,而且在Java堆中也建立一個java.lang.Class類的物件,這樣便可以通過該物件訪問方法區中的這些資料。

驗證

驗證類資料資訊是否符合JVM規範,是否是一個有效的位元組碼檔案,驗證內容涵蓋了類資料資訊的格式驗證、語義分析、操作驗證等。

準備

為類的靜態變數分配記憶體,並初始化預設值,這些記憶體是在方法區中分配,需要注意以下幾點:

  • 此處記憶體分配的變數僅包含類變數(static),而不包括例項變數,例項變數會隨著物件例項化被分配在java堆中。
  • 這裡預設值是資料型別的預設值(如0、0L、null、false),而不是程式碼中被顯示的賦予的值。
  • 如果類欄位的欄位屬性表中存在ConstatntValue屬性,即同時被final和static修飾,那麼在準備階段變數value就會被初始化為ConstValue屬性所指定的值。

解析

解析階段是虛擬機器器將常量池內的符號引用替換為直接引用的過程,解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用進行。符號引用就是一組符號來描述目標,可以是任何字面量。

直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制程式碼。

初始化

初始化算是類載入過程的最後一個階段,在這個階段在是真正的開始有java程式碼主導。將一個類中所有被static關鍵字標識的程式碼統一執行一遍,如果執行的是靜態變數,那麼就會使用使用者指定的值覆蓋之前在準備階段設定的初始值;如果執行的是static程式碼塊,那麼在初始化階段,JVM就會執行static程式碼塊中定義的所有操作


類載入器

類載入器除了能用來載入類,還能用來作為類的層次劃分。Java自身提供了3種類載入器

  • 啟動類載入器(Bootstrap ClassLoader),它是屬於虛擬機器器自身的一部分,用C++實現的,主要負責載入

<JAVA_HOME>\lib目錄中或被-Xbootclasspath指定的路徑中的並且檔名是被虛擬機器器識別的檔案。它等於是所有類載入器的爸爸。

  • 擴充套件類載入器(Extension ClassLoader),它是Java實現的,獨立於虛擬機器器,主要負責載入<JAVA_HOME>\lib\ext目錄中或被java.ext.dirs系統變數所指定的路徑的類庫。
  • 應用程式類載入器(Application ClassLoader),它是Java實現的,獨立於虛擬機器器。主要負責載入使用者類路徑(classPath)上的類庫,如果我們沒有實現自定義的類載入器那這玩意就是我們程式中的預設載入器。

對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在Java虛擬機器器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。

比較兩個類是否“相等”,只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個Class檔案,被同一個虛擬機器器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等。


雙親委派模型

                              

上面圖片所展示的類載入器之間的這種層次關係,稱為類載入器的雙親委派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應當有自己的父類載入器。這裡類載入器之間的父子關係一般不會以繼承(Inheritance)的關係來實現,而是都使用組合(Composition)關係來複用父載入器的程式碼。

雙親委派模型的工作過程

如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求(它的搜尋範圍中沒有找到所需的類)時,子載入器才會嘗試自 己去載入。 

使用雙親委派模型來組織類載入器之間的關係,有一個顯而易見的好處就是Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。

例如類java.lang.Object,它存放在 rt.jar之中,無論哪一個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此Object類在程式的各種類載入器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類載入器自行去載入的話,如果使用者自己編寫了一個稱為 java.lang.Object的類,並放在程式的ClassPath中,那系統中將會出現多個不同的Object 類,Java型別體系中最基礎的行為也就無法保證,應用程式也將會變得一片混亂。

破壞雙親委派模型:

你先得知道SPI(Service Provider Interface),它和API不一樣,它是面向拓展的,也就是我定義了這個SPI,具體如何實現由擴充套件者實現。我就是定了個規矩。

Java弄了個執行緒上下文類載入器,通過setContextClassLoader()預設情況就是應用程式類載入器然後Thread.current.currentThread().getContextClassLoader()獲得類載入器來載入。

Java中所有涉及SPI(Service Provider Interface)的載入動作基本上都採用這種方式(執行緒上下文類載入器,可以做一些“舞弊”的事情了,JNDI服務使用這個執行緒上下文類載入器去載入所需要的SPI程式碼,也就是父類載入器請求子類載入器去完成類載入的動 作,),例如JNDI、JDBC、JCE、JAXB和JBI等。


物件的建立

建立物件(克隆、反序列化)一般是一個newkeyword而已,而在虛擬機器器中,物件的建立步驟例如以下: 

①當虛擬機器器遇到new指令時。首先將去檢查這個指令引數能否在常量池中定位到一個類的引用符號,而且檢查這個符號引用代表的類是否被載入、解析和初始化過。假設沒有。那必須先執行相應的類載入過程

②在類載入檢查通過以後。接下來虛擬機器器將為新生物件分配記憶體。物件所需的記憶體大小在類載入後便確定。為物件分配空間的任務等同於把一塊確定大小的記憶體從Java堆劃分出來

  ②.①建立物件的過程其實也是一個非執行緒安全的過程,所以也需要考慮執行緒安全的問題。可能出現正在給物件A分配記憶體,指標還沒來得及改動,物件B又同一時候使用了原來的指標來分配記憶體的情況。解決這一問題的方案是: 

  •   方案一、對分配記憶體空間的動作進行同步處理--實際上虛擬機器器採用CAS配上失敗重試的方式,保證更新操作原子性 。
  • 方案二、把記憶體分配的動作依照執行緒劃分在不同空間之中進行。即每一個執行緒在Java堆中預先分配一小塊記憶體。稱為本地執行緒分配快取(TLAB)。哪個執行緒要分配記憶體,就在哪個執行緒的TLAB上分配,僅僅有TLAB用完並分配新的TLAB時,才須要同步鎖定。虛擬機器器是否使用TLAB,能夠通過-XX:+/-UseTLAB引數來設定。

③記憶體分配完畢以後。虛擬機器器會將分配到的記憶體空間都初始化為零值(不包括物件頭),假設使用TLAB,這一工作過程也能夠提前至TLAB分配時進行,這一步操作保證了物件例項欄位在Java程式碼中能夠不賦初始值就能直接使用,程式能訪問到這些欄位的資料型別所相應的零值

④接下來虛擬機器器要對物件進行必要的設定,比如:這個物件是哪個類的例項、怎樣才幹找到類的元資料資訊、物件的雜湊碼、物件GC分代年齡資訊等。這些資訊存放在物件的資訊頭之中。依據虛擬機器器執行狀態的不同。如是否使用偏向鎖等,物件頭會有不同的設定方式。 


上述工作完畢以後,從虛擬機器器角度來看,一個新的物件已經產生了,可是從Java程式來看,物件才剛剛開始——(init)方法還沒有執行。全部的欄位都還為零,所以,一般來說。執行new命令後。會接著執行init方法。把物件依照程式猿的意願進行初始化,這樣一個真正可用的物件才算全然產生出來。 


物件的記憶體佈局

在HotSpot虛擬機器器中,物件在記憶體中儲存的佈局可以分為3塊區域:

  • 物件頭(Header)
  • 例項資料(Instance Data)
  • 對齊填充(Padding)

HotSpot虛擬機器器的物件頭包括兩部分資訊:

第一部分用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、 GC分代年齡、 鎖狀態標誌、 執行緒持有的鎖、 偏向執行緒ID、 偏向時間戳等,這部分資料稱為Mark Word。  

物件頭的另外一部分是型別指標,即物件指向它的類元資料的指標,虛擬機器器通過這個指標來確定這個物件是哪個類的例項。

例項資料:物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種類 型的欄位內容。

對齊填充:對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。


物件的訪問定位

使用物件時Java程式需要通過棧上的reference資料來操作堆上的具體物件。

目前主流的訪問方式有使用控制程式碼直接指標兩種

  • 如果使用控制程式碼訪問的話,Java堆中將會劃分出一塊記憶體來作為控制程式碼池,reference中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與型別資料各自的具體地址資訊。   如圖所示:


  • 如果使用直接指標訪問,Java堆物件的佈局中就必須考慮如何放置訪問型別資料的相關資訊,而reference中儲存的直接就是物件地址,如圖所示:



各自優勢:

使用控制程式碼來訪問的最大好處就是reference中儲存的是穩定的控制程式碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制程式碼中的例項資料指標,而reference本身不需要修改。

使用直接指標訪問方式的最大好處就是速度更快,它節省了一次指標定位的時間開銷, 由於物件的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成 本。就本書討論的主要虛擬機器器Sun HotSpot而言,它是使用第二種方式進行物件訪問的,但從整個軟體開發的範圍來看,各種語言和框架使用控制程式碼來訪問的情況也十分常見。

物件是否“已死”

引用計數演演算法:

給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。

至少主流的Java虛擬機器器裡面沒有選用引用計數演演算法來管理記憶體,其中最主要的原因是它很難解決物件之間相互迴圈引用的問題。(一般面試問和教科書上的解釋的都是這個。)

可達性分析演演算法:

在主流程式語言(Java、C#)的主流實現中,都是稱通過可達性分析(Reachability Analysis)來判定物件是否存活的。這個演演算法的基本思路就是通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。

即使在可達性分析演演算法中不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個物件死亡,至少要經歷兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法或者finalize()方法已經被虛擬機器器呼叫過,那麼它們就會被行刑(清除)。