前言
java虛擬機器是java跨平臺的基石,本文的描述以jdk7.0為準,其他版本可能會有一些微調。java程式碼本身並不能為jvm識別,實際上在jvm中的表現形式為Class物件,一個java類從位元組碼到能夠在jvm中正常執行,需要經過載入-》連結-》初始化三個步驟。
引用
虛擬機器的啟動
- java虛擬機器的啟動是通過引導類載入器(Bootstrap Class Loader)建立一個初始類來完成,這個類是由虛擬機器的具體實現指定。緊接著,JAVA虛擬機器連結這個初始類,初始化並呼叫它的main方法。之後整個執行過程都是由對此方法的呼叫開始。
- 啟動過程如圖所示:
載入
類載入器層次結構圖
- 在java中,所有的類都是對其第一次使用時,動態載入到JVM中。當程式建立第一個對類的靜態成員(方法、變數)的引用時,就會載入這個類。這個證明了構造器也是類的靜態方法,即使在構造器之前並沒有使用static關鍵字,使用new操作符建立類的新物件也會被當做對類的靜態成員的引用。
定義
- 注意
載入
只是類載入中的一個階段,在載入階段虛擬機器主要做以下三件事情:- 通過一個類的全限定名來獲取此類的二進位制位元組流,(例如class檔案中的部分資料、zip包、applet等,執行該步驟的模組稱為
類載入器
) - 將該位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構
- 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口
- 通過一個類的全限定名來獲取此類的二進位制位元組流,(例如class檔案中的部分資料、zip包、applet等,執行該步驟的模組稱為
- 在載入階段完成之後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需要的格式儲存在
方法區
中,然後在記憶體中例項化一個java.lang.class物件,這個物件將作為程式訪問方法區中的這些型別資料的外部介面。 - java類的載入是由類載入器來完成的。類載入器分為兩類:
- 啟動類載入器(bootstrap),JVM原生提供,使用C++實現(注意這裡特指hotspot虛擬機器,有一些不是)
- 使用者自定義類載入器(user-defined),使用者自定義實現,繼承自java.lang.ClassLoader類。
- 類的載入方式分為兩種:顯式載入和隱式載入,這兩種方式都是呼叫classloader類中的loadClass方法來完成類的實際載入工作的。直接呼叫Classloader中的loadClass方法是另外一種不常用的顯式載入類的技術。
- 顯式載入:使用Class.forname的方式就是顯式載入
- 隱式載入:使用new建立例項就是隱式載入。
- 類載入器有很多用途,例如java熱替換技術,jvm中相同類的隔離等。
連結
- 連結類或介面包括驗證、準備、解析。其中解析是可選的部分。
- java虛擬機器規範允許靈活的選擇連結發生的時機,但是必須符合以下規範:
- 在類或者介面被連結之前,它必須被成功的載入過
- 在類或者介面初始化之前,它必須被成功的驗證及準備過
- 程式的直接或者間接行為可能會導致連結發生,連結過程中檢查到的錯誤應該在請求連結的程式處被丟擲。
驗證
- 驗證(verification)階段用於確保類或者介面的二進位制表示結構是正確的。驗證過程中可能會導致某些額外的類或者介面被載入進來,但是不應該導致它們也需要驗證或者準備。
準備
- 準備(preparation)階段的任務是為類或者介面的靜態欄位分配空間,並用預設值初始化這些欄位,這個階段不會執行任何的虛擬機器位元組碼指令。(注意在初始化階段會有顯式的初始化器來初始化這些靜態欄位,所以準備階段不做這些事情),注意以下幾點:
- 準備階段進行記憶體分配的僅包括類變數(被static修飾),不包括例項變數。例項變數會在物件例項化時隨著物件一起分配到java堆中。
- 示例:
- 這裡的初始值“通常情況下”是資料型別的零值,例如
public static int val=23;
,那麼在準備階段過後,val的值將設定為0,而不是23。而把val值賦值為23的putstatic指令是程式被編譯後,存放於類的構造器方法之中,所以把val值賦值為23的動作將在初始化階段才會執行,但是如果類的欄位屬性表中存在ConstantValue屬性(final修飾符),那麼在準備階段就會初始化完成,例如public static final int val=23
,那麼在準備階段的val值就為23.
- 這裡的初始值“通常情況下”是資料型別的零值,例如
- 具體的程式碼和位元組碼參加下圖:
解析
解析(Resolution)是根據執行時常量池的符號引用來動態決定具體值的過程
,java虛擬機器指令(anewarray,checkcast,getfield,getstatic,instanceof,putstatic,new,invokespecial,invokevirutal)等將符號引用指向執行時常量池。執行上述任何一條指令都需要對它的符號引用進行解析。- 本步驟是符號引用,它與直接引用的區別在於:
- 符號引用只能告訴你怎麼無歧義的定位到目標,該值明確規定在class檔案中
- 直接引用是直接指向目標,(指標、偏移量、控制程式碼)。直接引用和虛擬機器實現的記憶體佈局相關,
同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般都不相同
。
初始化
- 初始化是類載入的最後一步,在前面的類載入過程中,基本上動作都是由虛擬機器主導和控制(除了使用者自定義類載入器)。到了初始化階段才開始真正的執行類定義中的JAVA程式程式碼(或者說是位元組碼)。
初始化階段可以看做是執行類構造器<cinit()>方法的過程
:- 該方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static)合併而成,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,
靜態語句塊中只能訪問到之前定義的變數,但是可以賦值。
比如下面的這段程式碼: - 虛擬機器會保證父類的方法一定在自雷之前執行,因此第一個執行該方法的類一定是java.lang.Object。因此
父類的賦值操作一定是在子類之前,即必須把父類中的所有賦值操作執行完畢後才會執行子類的賦值操作
。 - 如果一個介面/類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器就不會為該類生成方法。
- 多個執行緒去初始化同一個類,那麼只有一個執行緒去執行該類的方法,其他執行緒都需要阻塞等待,注意同一個類載入器中,一個型別只會被初始化一次。
- 該方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static)合併而成,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,
案例
案例一-關於非法向前引用
- 仍然以上面的程式碼為例,
非法向前引用
這種編譯檢查,是未了防止靜態欄位在被初始化之前就被獨走預設值,這會導致問題難以診斷。可能有細心的讀者會發現在方法體中不會出現該問題,比如下面的這段程式碼: - 這段程式碼不會拋錯,最後執行結果為0.
為什麼方法和類可以消除向前引用,而變數不可以呢?
這個是因為java執行時為了實現向前引用,在初始化所有欄位之前會把所有的欄位新增到符號表中,以便可以呼叫這些欄位。不過由於還沒有初始化這些欄位,所以符號表中所有欄位都使用預設的值。上圖中的程式碼最後返回的額結果也是0.
案例二
- 上述這段程式碼其實是有問題的,真正執行的時候會拋NPE:
- 在java虛擬機器啟動的過程前期,載入和連結過程都是沒有問題的。但是在初始化的步驟(執行方法)中,由於編譯器收集順序是語句在原始檔中出現的順序,而res變數的賦值操作在add靜態語句塊之後。因此在執行add方法的時候,res變數只有一個預設值
null
。因此會導致該段程式碼拋NPE
- 在java虛擬機器啟動的過程前期,載入和連結過程都是沒有問題的。但是在初始化的步驟(執行方法)中,由於編譯器收集順序是語句在原始檔中出現的順序,而res變數的賦值操作在add靜態語句塊之後。因此在執行add方法的時候,res變數只有一個預設值
碼字不易,如有建議請掃碼