前言
我們都知道,java編譯的結果是字節碼,不是本地機器碼,這也是java跨平臺的一大表現。既然java編譯後是字節碼,那麼就不能實際地在本地(物理機器)運行。java字節碼運行在jvm虛擬機上面,既然這樣,那麼jvm虛擬機是如何加載讀取一個類的信息的呢?
我們平時寫完java代碼生成的是class文件。最後在運行的時候,虛擬機把描述類的信息從class文件加載到內存,然後再進行校驗、解析和初始化等過程,最後形成可以被java虛擬機“讀懂”的java類型。那麼從class——>java虛擬機能“讀懂”的java類型就是本文要講解的內容。
類加載的時機
既然說到了類加載,那麼到底什麼時候jvm纔會加載某個類呢?
其實這個問題簡單,肯定是運行時需要這個類的時候纔會去加載啦!
具體來說,類加載分爲幾個階段的:
- 加載
- 驗證
- 準備
- 解析
- 初始化
- 使用
- 卸載
那麼到底什麼時候纔開始加載類的第一個階段?加載?
其實這個問題在不同虛擬機上面實現是不一樣的,這個可以由虛擬機自己把握就行。不過java虛擬機規範中明確了當遇到下面5種情況的時候,必須初始化,此處的初始化是在加載,驗證,準備,解析後的初始化,所以這幾個階段應該在其之前完成:
- 遇到new,getstatic,putstatic,invokestatic這4條字節碼指令時,如果類沒有進行初始化,則先初始化。這4個字節碼常見的出現場景是:
- 使用new關鍵字實例化對象的時候
- 讀取或設置靜態字段(被final修飾,已在編譯期把結果放入常量池的靜態字段除外)的時候
- 調用一個類的靜態方法的時候。
- 反射調用時,如果類沒有初始化,得觸發初始化。
- 當初始化一個類的時候,如果它的父類沒有初始化,先觸發父類初始化。
- 虛擬機啓動的時候,包含main()方法的類(入口類)先初始化。
- JDK1.7動態語言支持的時候MethodHandle實例最後解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄對應的類沒有進行初始化的時候,需要先觸發其進行初始化。
對於上面的5種情況,java虛擬機規範中使用的詞語是“有且只有”,所以對一個類進行主動引用而進行的初始化就只有上面幾種情況。其餘的情況都是被動引用。
舉例被動引用的情況:
- 通過子類引用父類的靜態字段的時候,子類不會初始化
- 通過數組引用類的時候,不會觸發其初始化
- 常亮在編譯階段會存入調用類的常量池,所以也不會觸發定義類的初始化
其實除了類的加載過程,還有接口的加載過程,接口的加載過程和類的有一點不同,就是上面5種情況的第三種,接口應該是:
- 一個接口初始化的時候,並不會要求其父接口初始化
類加載的過程
加載
加載是類加載的第一個階段,主要做的工作是:
- 通過類的全限定名來獲取此類的二進制字節流
- 將這個字節流代表的靜態儲存結構轉化成方法區運行時的數據結構
- 在內存中生成一個代表這個類的class對象,作爲方法區這個類的各種數據訪問入口。
這裏的通過類的全限定名來獲取此類的二進制字節流並不一定通過class文件來獲得,也可以通過jar包或者網絡來獲得,還可以是動態代理通過運算時計算生成。
驗證
在類加載進內存後,第二件事做的是驗證這個類的合法性,並且不會危害到虛擬機自身的安全。
因爲class文件是可以“僞造”的,如果不對其加以驗證, 可以在運行的時候會危害到虛擬機導致系統崩潰。
在這一個階段,虛擬機做的事主要有:
-
文件格式驗證
- 是否以魔術0xCAFEBABE開頭
- 主次版本是否符合要求(是否在當前虛擬機能夠處理的範圍內)
- 常量池裏面是否有不被支持的常量類型
- 指向常量的索引是否有指向不存在的常量或不符合類型的常量
- CONSTANT_Utf8_info型的常亮是否有不符合UTF-8編碼的數據
- Class文件中的各個部分和文件本身是否有被刪除的或附加的其他信息
- and so on
- 元數據驗證
- 這個類是否有父類(除Object)
- 這個類是否繼承了不允許被繼承的類(final修飾)
- 如果這個類不是抽象類,是否實現了父類和接口中的要求的所有方法
- 類中的字段和父類是否產生矛盾(不符合規則的重載等)
- 字節碼驗證
- 保證操作數棧的數據類型是否與指令代碼序列都能配合工作。比如:在操作棧中放置一個int,使用的時候卻按照Long型來加載入本地變量表中。
- 保證跳轉指令不會跳轉到方法外的字節碼上
- 保證方法體中的類型轉換是有效的
- 符號引用驗證
- 符號引用中通過字符串描述的全限定名是否可以找到對應的類
- 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段
- 符號引用中的類、字段、方法的訪問性(private,public protected,default)是否可以被當前類訪問。
準備
這個階段正式爲類變量分配內存並設置初始值。這些變量都會在方法區中分配。因爲在方法區中分配,所以這個階段處理的都是一些static修飾的變量,不包括實例變量的。
還有要注意的是,這裏的初始化並不是直接把你在程序中所賦予的值賦予給變量,而是賦予的0值。
比如:
public static int a = 10;
上面這行代碼在這個階段只會爲a初始化爲0,並不會初始化爲10。
提示第二點:這個階段不會去管方法中的局部變量,這個階段處理的是類變量。
不過有個例外,我們都知道final修飾的變量是不可變的,所以你要是這樣定義變量:
public static final int a = 10;
那麼在準備階段就會爲它初始化爲10了。
解析
在這個階段,主要是虛擬機將常量池中的符號引用代替爲直接引用。
符號引用在CLASS文件中它以CONSTANT_CLASS_INFO, CONSTANT_FIELDREF_INTO, CONSTANT_METHODREF_INFO等類型的常量出現。
符號引用:(Symbolic References)符號引用以一組符號來描述所引用的目標,可以是任何形式的字面量,引用的目標並不一定已經加載到內存中,與虛擬機內存佈局無關。
直接引用:(Direct References)直接引用可以是直接指向目標的指針,相對偏移量,或是一個能間接定位到目標的句柄。與虛擬機內存佈局相關。
解析動作主要針對類/接口,字段,類方法,接口方法四類符號引用進行。分別對應於常量池的CONSTANT_CLASS_INFO
,CONSTANT_FIELDREF_INFO
,CONSTANT_METHODREF_INFO
,
CONSTANT_INTERFACEMETHODREF_INFO
四種類型。
其實我感覺,我們在寫代碼的時候,比如定義一個類,用A表示;那麼在class文件裏面會爲它生成一個符號引用,只是簡單地用來表示A類;在解析階段,由於上面的幾個步驟已經生成了A的class對象,那麼符號引用就沒有意義了,所以這裏就把它轉化成一種直接引用,比如指向A的class對象的引用。這是我的理解。
初始化
剛剛說了,上面在準備階段做的一件事是
public static int a = 10;
爲a初始化爲0對吧? 那麼我們在程序中是顯示爲a初始化爲10 的, 所以現在,我們得把這個a初始化爲10了。
在準備階段中,變量已經被賦過一次系統要求的零值;而在初始化階段,則是根據程序員通過程序制定的計劃來賦值。
在這個最後的階段,虛擬機主要執行了<clinit>()
方法,在這個方法裏面,主要收集了類中所有類變量的初始化動作(a = 10),和靜態代碼塊(static{})中的語句合併而成。
當然在執行<clinit>()
方法前必須保證其父類已經執行完<clinit>()
。
當然<clinit>()
也不是必須的,如果類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器是不會生成這個方法的。
當然在多線程的環境裏面,虛擬機會爲這個方法加鎖以保證只有一個線程能執行初始化動作。
卸載
有了上面的幾個過程,一個類基本就加載完成 了,最後剩下使用和卸載,使用就不說了。對於卸載,虛擬機會在代碼中當代表類的Class對象不再被引用時,即不可達時,Class對象就會結束生命週期,此類在方法區內的數據也會被卸載,從而結束此類的生命週期。
這裏注意一點:由Java虛擬機自帶的類加載器所加載的類,在虛擬機的生命週期中,始終不會被卸載。
Java虛擬機自帶的類加載器包括根類加載器、擴展類加載器和系統類加載器幾種。
Java虛擬機本身會始終引用這些類加載器,而這些類加載器則會始終引用它們所加載的類的Class對象,因此這些Class對象始終是可達的。
由用戶自定義的類加載器加載的類是可以被卸載的。
當用戶自定義的類加載器的引用被置爲null並且用戶自定義的類加載器加載的class對象引用也置爲null並且所有該類的實例都沒被引用時,那麼此時用戶自定義的類加載器也就結束生命週期了,那麼該類在方法區內的二進制數據被卸載。當程序再次需要該類的時候,就會重新加載,在Java虛擬機的堆區會生成一個新的代表這個類的class對象。
其實這裏應該可以通過查看他們的hash碼是否相等來判斷是不是同一個class實例。
參考資料
《深入理解java虛擬機》