關於Java類加載,主要弄清楚三個問題 :
- 為什么需要類加載
- 什么時候進行類加載
- 怎么進行類加載
一、為什么需要類加載
我們編寫好的程序經過編譯之后,會形成Class文件,Class文件描述了類的各種信息,而Java虛擬機想要運行程序,就必須把Class文件加載進入虛擬機內部,才能供其所用。
在JVM中,類的各種信息一般都存儲在方法區中,所以需要將類信息加載進入方法區,才能在需要類信息時,比如實例化對象時找到對應的類信息。
二、什么時候進行類加載
一個類加載進內存一般需要經歷 加載(Loading)→驗證(Verification)→准備(Preparation)→解析(Resolution)→初始化(Initialization) 五個階段,才能供程序調用。
什么時候開始加載階段,《Java虛擬機規范》沒有嚴格規定,但是存在有六種情況必須對立即對類立即進行初始化,而在初始化之前,必須進行加載、驗證、和准備工作。為什么解析不是必須的,因為解析在某些情況下可以在初始化之后在開始,這和Java的動態綁定有關。
以下六種情況必須立即對類進行初始化:
1.遇到new、getstatic、putstatic、invokestatic這四條字節碼指令時,典型場景為:new實例化對象、讀取或設置類的靜態變量(final修飾的)、調用一個類的靜態方法等。
2.使用java.lang.reflect包方法對類進行反射調用時
3.當初始化類的時候,發現其父類沒有初始化,則先觸發父類的初始化
4.當虛擬機啟動時,主類(包含main()方法類)必須先初始化
5.當使用動態語言支持,java.lang.invoke.MethodHandle實例最后解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種方法句柄,而這個方法句柄對應的類沒有進行初始化,則需要對其進行初始化
6.當一個接口定義了JDK8新加入的默認方法(default關鍵字修飾),如果該接口實現類發生初始化,那么該接口需要在其之前進行初始化
以上六種為對類型的主動引用,除此之外,所有引用類型都不會觸發初始化,稱為被動引用
被動引用舉例:
1.通過子類引用父類的靜態字段,不會導致子類初始化,但是會加載子類
2.通過數組定義來引用類,不會觸發此類的初始化
3.常量在編譯階段會存入調用類的常量池,本質上沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化
接口的加載與類加載稍有不同,最主要區別是類初始化要求父類全部初始化,但是接口並不要求所有父接口都初始化,只有用到父接口的時候,才會進行初始化
三、怎么進行類加載
3.1 加載
類加載的第一階段就是“加載”,此時JVM會做以下三件事:
1.通過類的全限定名獲取此類的二進制字節流
2.將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構
3.在內存種生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口
加載階段既可以由JVM內置的引導類加載器完成,也可以由用戶自定義的類加載器完成(重寫加載器的findClass()或loadClass()方法)
3.2 驗證
類加載的第二階段,確保上一步加載的Class文件字節流全部符合《Java虛擬機規范的》約束
大致會完成四個方面的驗證:
1.文件格式驗證:驗證字節流是否符合Class文件規范,能否被當前版本虛擬機處理,如是否以魔數0xCAFEBABE開頭,主次版本號是否受當前JVM支持等
只有通過了文件格式驗證,字節流才會存入方法區,后面三個驗證都是基於方法區的存儲結構的,不會再操作字節流
2.元數據驗證: 對字節碼描述的信息進行語義分析 如:這個類是否有父類(除了java.lang.Object之外,所有的類都應該父類,沒有顯式繼承父類的類,都默認繼承java.lang.Object類),這個類是否繼承了不允許被繼承的類,非抽象類是否實現了父類中或接口中的所有抽象方法,類中的字段、方法是否與父類產生矛盾等
3.字節碼驗證:這是整個驗證階段過程中最復雜的一個階段,主要是通過數據流分析和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段的基礎上,此階段對類的方法體進行校驗分析,保證被校驗類不會在運行時作出危害虛擬機的行為,如:保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,保證任何跳轉指令都不會跳轉到方法體以外的字節碼指令上等
4.符號引用驗證:這個驗證發生在虛擬機將符號引用化為直接引用的時候,這個轉化發生在解析階段。符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類信息進行匹配校驗。如:符號引用種通過字符串描述的全限定名是否能找到對應的類,符號引用中的類、字段、方法、的可訪問性是否可被當前類訪問。
符號引用驗證主要是為了解析行為能正常進行,如果不能通過符號引用驗證,虛擬機將拋出Java.lang.IncompatibleClassChangeError的子類異常。
如果程序運行的全部代碼都已經反復的使用和驗證過,后期部署階段可以考慮關閉類驗證階段,縮短類加載時間。
3.3 准備
准備階段為類中定義的變量,即靜態變量分配內存並設置變量的初始值,通常情況為該數據類型的“零值”。如果是final類靜態變量,也可能在准備階段直接賦值。注意此階段內存分配僅包括類變量,而實例變量會在對象實例化時隨着對象一起分配在堆中。
3.4 解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,此過程伴隨着第二階段驗證中符號引用驗證。
解析過程主要針對類或接口、類方法、接口方法、方法類型、方法句柄、調用限定符引用,對應着常量池中8種常量類型。
類或接口解析:
如果當前處於類D代碼中,要把一個未解析符號引用N解析為一個類或接口的直接引用C,大致需要三步:
1.如果C不是數組類型,那么將由D的類加載器根據N的全限定名去加載類C。在加載過程中可能觸發其他相關類的加載,如父類或實現的接口,一旦失敗, 解析過程就失敗了
2.如果C是一個數組類型,如Integer數組類型,則N的描述符會是“[Ljava/lang/Integer”的形式,則會加載java.lang.Interger,接着由JVM生成一個代表該數組維度和元素的數組對象。
3.如果上兩步通過,則C即成為一個實際有效的類或接口了,解析完成前,還需要驗證D對C的訪問權限,如果D不具備,拋出java.lang.IllegalAccessError異常。
字段解析:
首先對字段所屬的類C或接口的符號引用進行解析,一旦失敗,則解析失敗,如果解析成功,將會按照以下步驟解析類C的后續字段:
類C自身范圍查找→失敗則從下往上遞歸在實現的接口中查找→失敗則從下往上遞歸在繼承的父類中查找,仍然失敗則拋出java.lang.NoSuchFieldError異常。如果查找成功返回了引用,則進行權限驗證,驗證失敗則拋出java.lang.IllegalAccessError異常。
在實際情況中,雖然查找的范圍是依次進行,但是如果一個字段在父類或實現的接口中多次出現,按照規則依然可以保證唯一性,但是多數編譯器會拒絕編譯代碼。
方法解析:
方法解析同樣需要先解析方法所屬的類C和接口的符號引用,如果解析成功,則按照以下步驟對后續方法進行搜索:
類C中查找→失敗則在C的父類中遞歸查找→失敗則在C實現的接口列表和他們的父接口中遞歸查找(此步若成功,說明C是一個抽象類,拋出java.lang.AbstractMethodError異常,查找結束)→ 否則,查找失敗,拋出java.lang.NoSuchMethodError異常。
如果查找成功返回了引用,則進行權限驗證,驗證失敗則拋出java.lang.IllegalAccessError異常。
接口方法解析:
接口方法解析一樣需要解析接口所屬接口的符號引用,如果解析成功,則按照以下步驟對后續接口方法進行搜索:
接口C中查找→失敗則在C的父接口中查找→失敗則查找失敗,拋出java.lang.AbstractMethodError異常,查找結束
在JDK9以前,所有接口方法都是public的,也沒有模塊化訪問約束,所以不存在訪問權限問題,不可能拋出java.lang.IllegalAccessError異常。
3.5 初始化
初始化是類加載的最后一步,在之前的幾步里,只有“加載”階段用戶可以自定義類加載器參與其中,后面都是由JVM主導控制的,直到初始化開始,JVM才開始真正執行Java程序代碼,將主導權移交給應用程序。
大體來說,初始化就是執行類構造器的<clinit>()方法,此方法並不是coder直接編寫的,而是由編譯器自動收集類中所有類變量賦值動作和靜態語句塊(static塊)合並產生,收集的順序是其在源文件中出現的順序。
靜態語句塊只能訪問到定義在它之前的變量,對於定義在其后的邊量,只能進行賦值,但不能訪問。
public class Test { static { i = 0; //給變量賦值可以正常編譯 System.out.println(i); //這句話會提示非法向前引用 } static int i = 1; }
JVM會保證在子類<clinit>()方法執行前,父類的<clinit>()方法先執行完畢,所以第一個被執行的<clinit>()方法是java.lang.Object類的<clinit>()方法,因此,父類中的靜態代碼塊會優於子類的靜態代碼塊執行。
如過一個類沒有靜態代碼塊,也沒有變量賦值操作,則編譯器可以不生成這個類的<clinit>()方法
接口中不能使用靜態代碼塊,但可以由變量賦值,但執行接口的<clinit>()方法前不需要先執行父接口的<clinit>()方法,只有在父接口被使用時,父接口才會初始化。
接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法
JVM會對一個類的<clinit>()方法進行加鎖同步,保證如果由多個線程初始化一個類,那么只有一個線程會執行<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。