1. 程式人生 > >JVM系列之三:型別的生命週期

JVM系列之三:型別的生命週期

  此篇文章主要介紹從一個Java型別(類或者介面)的生命週期(從它進入虛擬機器到退出)開始階段的裝載、連線與初始化,以及佔Java型別宣告週期絕大部分時間的物件例項化、垃圾收集和物件終結,然後是Java型別生命週期的結束,也就是從虛擬機器中解除安裝。

型別裝載、連線與初始化

  Java虛擬機器通過裝載、連線和初始化三個步驟,使一個型別可以被正在執行的Java程式所使用。其中裝載就是把二進位制形式的Java型別讀入到Java虛擬機器中;連線就是把這種已經讀入虛擬機器的二進位制形式的型別資料合併到虛擬機器的執行時狀態中去。

  連線階段分為三個子步驟:

    驗證:驗證被載入的型別資料格式是否正確且適於Java虛擬機器使用

    準備:為該型別分配它所需要的記憶體,比如為它的類變數分配記憶體

    解析:負責把常量池中的符號引用轉換為直接引用,虛擬機器可以推遲解析這一步。它可以在當執行中的程式真正使用某個符號引用時候再去解析它(把該符號引用轉換為直接引用)

  當以上步驟都完成後(解析步驟可選),該型別就為初始化做好了準備,在初始化期間,將給類變數賦以適當的初始值。

  裝載、連線和初始化這三個步驟必須按順序執行,解析這一步驟可以在初始化之後執行。

  在裝載和連線的步驟中,Java虛擬機器規範給實現提供了一定的靈活性。但是它嚴格的定義了初始化的時機。所有的Java虛擬機器必須在每個類或者介面主動使用時初始化,下面這六種情況符合主動使用的要求。

    當建立某個類的新例項時(在位元組碼中執行new指令;或者通過不明確的建立、反射、克隆、反序列化)

    當呼叫某個類的靜態方法時

    當使用某個類或介面的靜態欄位,或者對該欄位賦值時(用final修飾的靜態變數除外,它被初始化為一個編譯時的常量表達式)

    當呼叫Java API中的某些反射方法時

    當初始化某個類的子類時(某個類初始化時,要求它的超類已經被初始化了)

    當虛擬機器啟動時某個被標明為啟動類的類(即含有main()方法的那個類)

  除上述這六種情況外,所有其他使用Java型別的方式都是被動使用,它們都不會導致Java型別的初始化。

   裝載

    裝載階段由三個基本動作組成,要裝載一個型別,Java虛擬機器必須經過三個步驟:

      通過該類的完全限定名,產生一個代表該型別的二進位制資料流(找到class檔案)

      解析這個二進位制資料流為方法區的內部資料結構(解析這個class檔案)

      建立一個表示該型別的java.lang.Class類的例項(建立Class例項)

    那麼二進位制資料流是怎麼產生的呢?

    Java虛擬機器規範並沒用說Java型別的二進位制是應該怎樣產生,一般來說可能有以下幾種產生方式

      從本地檔案系統裝載,或是通過網路下載,或者通過某種歸檔檔案中解壓等等

      Java原始檔動態編譯為Class檔案

      動態為某個型別計算其class檔案格式

    總而言之,有了二進位制資料之後,Java虛擬機器才能夠建立java.lang.Class的例項物件。而裝載步驟的最終產品就是這個Class例項物件

    類裝載器並不需要一直等到此型別“首次使用”時再去裝載它,Java虛擬機器規範允許快取Java型別的二進位制表現形式,在預料到某個型別將要使用時就去裝載它,或者把這些型別裝載到一些相關的分組裡面。但如果一個類裝載器在預裝載時候遇到問題,無論如何,它應該在型別被首次使用時報告該問題(通過丟擲一個LinkageError異常的子類)。

   驗證

     當型別被裝載後,下一步就準備進行連線了。連線過程第一步是驗證-------確定型別符合Java語言的語義,並且它不會危及虛擬機器的完整性。

     驗證上,不同虛擬機器實現擁有一定的靈活性,虛擬機器設計者可以決定如何以及何時驗證型別。Java虛擬機器規範則列出虛擬機器可以丟擲的異常以及在何時丟擲他們。一般情況,規範會明確的說明異常或者錯誤在何種條件下應該被丟擲,但是通長沒有嚴格規定如何或者在何時檢查錯誤條件。

     不管怎樣,在大多數Java虛擬機器實現中特定型別的檢查一般都在特定的時間發生。比如在裝載過程中,虛擬機器必須解析代表型別的二進位制資料流,並且構造內部結構。在此期間,必須做一些待定的檢查,以保障解析二進位制檔案的過程中不會導致虛擬機器崩潰。一般檢查會包括,確保二進位制資料全部是預期的格式。另一個可能的裝載時檢查是,確保除了Object之外的類都有一個超類。在裝載時檢查一個類時,它必須確保該類的所有超類都已經被裝載,而得到超類名字的唯一方法時觀察類的二進位制資料。

     那麼在正式驗證階段都會做哪些檢查呢?任何在此之前沒有進行的檢查已經在此之後不會被檢查的專案都包含在內。首先列出確保各個類之間二進位制相容的檢查:

     檢查final的類不能擁有子類

     檢查final的方法不能被覆蓋

     確保類和超類之間不存在不相容的方法(比如兩個方法擁有同樣的名字,入參,且入參再順序上,數量和型別都相同,但是返回型別不同)

     檢查所有的常量池入口相互之間一致

     檢查常量池中所有的特殊型別字串(類名,欄位名和方法名、欄位描述符和方法描述符)是否符合格式

     檢查位元組碼的完整性

   準備  

    隨著Java虛擬機器裝載一個類,並執行了一些它選擇進行的驗證之後,類就可以進入準備階段了。在此階段Java虛擬機器為類變數分配記憶體,設定預設初始值。但在初始化階段之前,類變數都沒有初始化為真正的初始值。(準備階段是不會執行Java程式碼的),在準備階段,虛擬機器把類變數新分配的記憶體根據型別設定為預設值。   

      在Java虛擬機器內部,boolean一般為實現為一個int,也總是初始化為false。

      在準備階段,Java虛擬機器實現可能也為一些資料結構分配記憶體,目的是提高程式的效能。例如方法表。

    解析

      型別經過驗證和準備之後,它就可以進入第三個也就是最後一個連線階段了------解析。解析的過程就是在型別的常量池中尋找類、介面、欄位和方法的符號引用,然後把這些符號引用替換成直接引用的過程。

    初始化

      為了讓一個類或者介面被首次主動使用,最後一個步驟就是初始化,也就是為類變數賦予正確的初始值。這些正確的初始值是根據程式設計師指定的主觀計劃而生成的。

      在Java程式碼中,一個正確的初始值是通過類變數初始化語句或者靜態變數初始化語句給出的。一個類變數初始化語句是變數聲明後面的等號和表示式:

    靜態初始化語句是一個以static關鍵字開頭的程式塊

    所有的類變數初始化語句和型別的靜態初始化器都被Java編譯器收集在一起,放到了一個特殊的方法中。對於類來說,此方法被稱為初始化方法;對於介面來說,這個方法叫做介面初始化方法。在類和介面的Java class檔案中,這個方法被稱為“<clinit>”。此方法只能被Java虛擬機器呼叫,專門用於把靜態變數設定為它們的正確初始值。

    初始化一個類包含兩個步驟:

      1,如果類存在直接超類的話,且直接超類還沒有被初始化,就先初始化直接超類。

      2,如果類存在初始化方法,則執行此方法。

    當初始化一個類的直接超類的時候,也需要包含這兩個步驟。超類總是在子類之前就被初始化。

    <clinit>() 方法:<clinit>() 方法的程式碼並不顯式的呼叫超類<clinit>()方法。在Java虛擬機器呼叫類的<clinit>()方法之前,它必須確認超類<clinit>()方法以及被執行。

    Java虛擬機器必須保持初始化過程被正確的同步。如果多個執行緒需要初始化一個類,僅僅允許一個執行緒來執行初始化,其他執行緒需要等待。

      並非所有的類都需要在它們的Class檔案中擁有一個<clinit>()方法。下面幾種情況不會出現<clinit>()方法:

      此類沒有宣告任何類變數,也沒有靜態初始化語句。

      此類聲明瞭類變數,但沒有明確使用類變數初始化語句或者靜態初始化語句。

      此類僅包含靜態final變數的類變數初始化語句,而且這些類變數初始化語句採用編譯時常量表達式。

    下面這個例子,Java編譯器不會為它產生<clinit>()方法

  

    介面也可能在Class檔案中包含一個<clinit>()方法,所有在介面中宣告的隱式公開(public)、靜態(static)和最終(final)欄位都必須在欄位初始化語句中初始化。如果介面包含任何不能再編譯時被解析稱為一個常量的欄位初始化語句,介面就會有一個<clinit>()方法。

    主動使用和被動使用:前面講過,Java虛擬機器在首次主動使用型別時初始化他們。只有6種情況被認為是主動使用:建立類的新例項,呼叫類中宣告的靜態方法,操作類或者介面中宣告的非常量靜態欄位,呼叫Java API中特定的反射方法,初始化一個類的子類,已經指定一個類作為Java虛擬機器啟動時的初始化類。

    使用一個非常量的靜態欄位只有當類或者介面的確聲明瞭這個欄位時才是主動使用。比如,類中宣告的欄位可能會被子類引用;介面中宣告的欄位可能會被子介面或者實現了這個介面的類引用。對於子類,子介面,實現類來說,這就是被動引用,也就是說被動引用並不會觸發他們的初始化。

 1 class Test_0{
 2     static String str_0 = "test_0";
 3 
 4     static {
 5         System.out.println("init Test_0");
 6     }
 7 }
 8 
 9 class Test_1 extends Test_0{
10 
11     static {
12         System.out.println("init Test_1");
13     }
14 }
15 
16 public class Test {
17     static {
18         System.out.println("init Test");
19     }
20 
21     public static void main(String[] args) {
22         String sout = Test_1.str_0;
23         System.out.println(sout);
24     }
25 }
輸出:
  init Test
  init Test_0
  test_0

      可見Test_1並沒有被初始化。

    如果說一個欄位既是靜態的(static)又是最終的(final),並且使用一個編譯時常量表達式初始化,使用這樣的欄位,就不是對宣告該欄位的類主動引用。Java編譯器把這樣的欄位解析成本地拷貝(存於引用者類的常量池中或者位元組碼流中,或者二者都有)。

  垃圾收集和物件的終結:Java虛擬機器必須實現具有某種自動堆儲存管理策略------大部分是採用垃圾收集器。程式可以明確或隱含的為物件分配記憶體,但是卻不能明確的釋放記憶體,當一個物件不再為程式使用,虛擬機器必須回收那部分記憶體。實現可以決定何時垃圾收集不再被引用的物件,或者選擇不收集它們。

  如果類聲明瞭finalize(),垃圾收集器會在釋放這個例項所佔據的記憶體空間之前執行這個方法,因為一個終結方法是一個普通方法,它可以被程式所呼叫。垃圾收集器(最多)只會呼叫一個物件的終結方法一次,如果終結方法程式碼執行後,物件被重新引用,隨後再次變為不被引用,垃圾收集器不會第二次呼叫終結方法。

  解除安裝型別

    Java虛擬機器通過什麼方法來確定一個動態裝載的型別是否任何被程式需要呢,其判斷方式與判斷物件是否仍然被程式需要的方式很型別。如果程式不再引用某型別,那麼這個型別就無法再對未來計算過程產生影響。型別變成不可觸及的,而且可以被垃圾回收。

    判斷動態裝載型別的Class例項在正常的垃圾回收過程中是否可以觸及有兩種方式。第一種,如果程式保持對Class例項型別的明確引用,它就是可觸及的。其次如果在堆中還存在一個可觸及的物件,在方法區中它的型別資料指向一個Class例項,那麼這個Class例項就是可觸及的。僅僅給出一個物件的引用,實現必須能夠在方法區找到物件的類的型別資料。因此,通過型別資料,虛擬機器可以確定物件的類,已經它的所有超類已經所有超介面的Class例項。

     

    上圖表示僅通過MyThread的例項,垃圾收集器可以“觸及”MyThread和它的所有超型別(包括Cloneable、Thread、Runnble和Object)的Class例項。

    參考:深入理解Java虛擬機器第二版