什么是類加載
虛擬機把描述類的數據從Class文件加載到內存,并對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。
《【JVM】類文件結構》講的是Class文件結構,即我們編寫的Java代碼(.java文件)經過編譯后生成Class文件(.class文件)。這一章講述的是如何將這個Class文件加載到內存并最終形成虛擬機直接使用Java類型的過程。
1、類加載的時機
- 類的生命周期
其中,加載、驗證、準備、初始化和卸載這5個順序是固定的,即類的加載過程按這個順序開始(但并不是按照這個順序進行或完成,而是各個階段交叉進行)。
解析階段在某些情況下會在初始化之后再開始:為了支持Java的運行時綁定(動態綁定或晚期綁定)。
概念補充:動態綁定
綁定指的是一個方法的調用與方法所在的類(方法主體)關聯起來。對Java來說,綁定分為靜態綁定和動態綁定。
靜態綁定是指在編譯過程中就已經知道這個方法到底是哪個類中的方法,Java當中的方法只有final,static,private和構造方法是靜態綁定。動態綁定是指在程序方法運行時根據具體對象的類型進行綁定。Java虛擬機可在運行期間判斷對象的類型,并調用適當的方法。
- 加載時機
虛擬機規范并沒有強制約束何時開始第一階段的加載,但規定了有且僅有5種情況必須立即對類進行初始化(而加載、驗證、準備自然需要在此之前完成)。這5種情況如下: - 遇到new、getstatic、putstatic、invokestatic這4條字節碼指令時,并且該類此前沒有進行過初始化。生成這4條指令的常見Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候、調用一個類的靜態方法的時候。
- 使用
java.lang.reflect
包中的方法對類進行反射調用的時候,如果類沒有被初始化過,則需要先觸發初始化。 - 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main方法的那個類),虛擬機會先初始化這個主類。
- 當使用JDK1.7的動態語言支持時,如果一個
java.lang.invoke.MethodHandle
實例最后的解析結果是REF_getStatic
、REF_putStatic
、REF_invokeStatic
方法句柄,并且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。
這5種場景中的行為稱為對一個類的主動引用,除此之外都不會觸發類的初始化,被稱為被動引用。被動引用的情況有以下幾種:①通過子類引用父類的靜態字段,不會導致子類初始化,只會導致父類的初始化;②通過數組定義來引用類,不會觸發類的初始化(但會觸發數組初始化);③常量在編譯階段會存入調用類的常量池中,本質上并沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
接口初始化的類的初始化的區別:類在初始化時,要求其父類已經初始化,但一個接口在初始化時,并不要求其父接口全部都完成初始化,只有在真正使用的父接口的時候才會初始化。
類加載的過程
1、加載
在加載階段,虛擬機需要完成3件事情:
-
獲取該類的二進制字節流
根據此類的全限定名獲取,二進制流來源主要有Class文件、ZIP包、網絡、運行時計算生成、其他文件生成如JSP、數據庫) - 將二進制字節流存儲在方法區(按照一定的格式存儲)。
-
在內存中生成一個
java.lang.Class
對象
該Class對象將作為方法區這個類的各種數據的訪問入口,在hotSpot虛擬機中該對象是存放在方法區的。
2、驗證
驗證的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。驗證階段大致會完成下面4個階段的檢驗動作:
文件格式校驗
該階段的驗證是基于二進制字節流進行的,只有通過了這一階段,字節流才會進入內存的方法區中進行存儲,后面的驗證階段都是基于方法區的存儲結構進行的,不會再直接操作二進制流。主要包括:是否以魔數0xCAFEBABE
開頭、主次版本號是否在當前虛擬機處理范圍之內等等。元數據校驗
目的是保證不存在不符合Java語言規范的元數據信息,主要包括:這個類是否有父類、是否繼承了不允許被繼承的類(final
修飾的類)、是否實現了父類或接口中要求實現的所有方法、字段與方法是否與父類矛盾等。字節碼驗證
目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的,即對類的方法體進行校驗分析。符號引用驗證
對類自身以外的信息進行匹配性校驗,發生在虛擬機將符號引用轉化為直接引用的時候(解析階段)。包括:①符號引用中通過字符串描述的全限定名是否能找到對應的類;②在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段;③符號引用中的類、方法、字段的訪問性是否可被當前類訪問到。
驗證階段是非常重要但不是一定必要的階段,如果所運行的代碼(自己編寫的以及第三方包中的代碼)已經被反復使用和驗證過,可以考慮使用-Xverify:none
來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
3、準備
準備階段的工作是:為類變量分配內存并設置類變量初始值。這些變量所使用的內存都將在方法區進行分配。要注意的是:①類變量是指被static修飾的變量,不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中;②初始值在通常情況下是指數據類型的零值,但如果是static final
類型變量,則會直接為該變量賦值為代碼中指定的值。
private int value = 123;//不會為該變量分配內存
private static int value = 123;//會為該變量分配內存并賦為零值(0)
private static final int value = 123;//會為該變量分配內存并賦值為123
4、解析
解析階段的工作:虛擬機將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符。
符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標接口。
直接引用:直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。
類或接口的解析
假設當前代碼所在的類為D,要將符號引用N解析為一個類或接口C的直接引用,整個過程分為三個過程:
①如果C不是一個數組類型,則虛擬機將代表N的全限定名傳給D的類加載器去加載這個類。在加載過程中,又可能會去加載這個類的父類等。
②如果C是數組類型,并且數組的元素類型為對象,則先按照①加載這個數組元素類型,接著虛擬機生成一個代表此數組維度和元素的數組對象。
③上述步驟均未出現異常,則C在虛擬機中實際上已經成為一個有效的類或接口了。但還要進行訪問權限檢查。字段解析
字段符號引用的解析,會先對字段所屬的類或接口的符號引用進行解析,假設解析正常,將這個字段所屬的類或接口用C表示,之后虛擬機按以下順序對C進行后續字段的搜索:C本身、C實現的各個接口或父接口(遞歸)、C繼承的父類(遞歸)。
如果一個同名字段同時出現在C的接口和父類中,或同時在自己和父類的多個接口中,那么編譯器將拒絕編譯。類方法解析
接口方法解析
5、初始化
初始化:真正開始執行類中定義的Java代碼(或者說字節碼)。
在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序指定的主觀計劃去初始化類變量和其他資源:即初始化階段是執行類構造器<clinit>
方法的過程。(<clinit>
可以看做class init的簡寫
)
關于<clinit>
方法:
①<clinit>
方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合并而來,收集順序是由語句在源文件中出現的順序決定的。注意:靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,對定義在它之后的變量,可以賦值,但不能訪問,如下:
public class Test{
static {
i = 0;//賦值可以通過編譯
System.out.print(i);//這句訪問則會導致編譯器報錯:非法向前引用
}
static int i = 1;
}
②<clinit>
方法與類的構造器(實例構造器<init>
)不同,它不需要顯式調用父類構造器,虛擬機會保證在子類的<clinit>
方法執行之前,父類的<clinit>
方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>
方法的類肯定是java.lang.object
。
③由于父類的<clinit>
方法先執行,所以父類中定義的靜態語句塊要優先于子類的變量賦值操作而執行。
④<clinit>
方法并不是類或接口必須的,如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那么編譯器可不為這個類生成<clinit>
方法。
⑤接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>
方法。不同的是,執行接口的<clinit>
方法不需要執行父類的<clinit>
方法,只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也不會執行接口的<clinit>
方法。
⑥虛擬機會保證一個類的<clinit>
方法在多線程環境中被正確地加鎖、同步。如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>
方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>
方法完畢(同一個類加載器下,一個類型只會初始化一次)。如果在一個類的<clinit>
方法中有很耗時的操作,就可能導致多個進程阻塞。
類加載器
類加載器的作用:通過一個類的全限定名來獲取描述此類的二進制字節流。這個動作是在Java虛擬機外部實現的,以便讓應用程序自己決定如何去獲取所需要的類。類加載器在類層次劃分、OSGi、熱部署、代碼加密等領域大放異彩。
類與類加載器
對于任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性:比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器的前提下才有意義,否則即使這兩個類來源于同一個Class文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。
這里所指的相等,包括代表類的Class類的對象的equals()
方法、isAssignableFrom()
方法、isInstance()
方法的返回結果,也包括使用instanceof
關鍵字做對象所屬關系判定等情況。
雙親委派模型
從Java虛擬機的角度來講,只存在2種不同的類加載器:
①啟動類加載器:Bootstrap ClassLoader
,虛擬機自身的一部分。
②其他類加載器:獨立于虛擬機外部,并且全部繼承自抽象類java.lang.ClassLoaer
。
從Java開發人員的角度看,類加載器可以劃分的更詳細,分為4種:
-
啟動類加載器
Bootstrap ClassLoader
負責將存放在<JAVA_HOME>\lib
目錄中的,或者被-Xbootclasspath
參數所指定的路徑中的,并且是虛擬機識別的類庫加載到虛擬機內存中。開發者不能直接引用啟動類加載器,如果需要將加載請求委派給啟動類加載器,直接使用null
代替即可。 -
擴展類加載器
Extention ClassLoader
負責加載<JAVA_HOME>\lib\ext
目錄中的,或者被java.ext.dirs
系統變量指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。 -
應用程序類加載器
也稱為系統類加載器,負載加載用戶類路徑classpath
上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是應用程序默認的類加載器。 - 自定義類加載器
應用程序均是由這4種類加載器互相配合進行加載的,他們之間的關系如下圖:
類加載器這種層次關系稱為類加載器的雙親委派模型:除了頂層的啟動類加載器外,其余的類加載器都應當有自己的父類加載器。這里的類加載器之間的父子關系一般不會以繼承的關系實現,而是都使用組合關系來復用父加載器的代碼。
雙親委派模型的工作過程:如果一個類收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求交給父類加載器去完成,每一層次的類加載器都是如此。因此所有的加載請求都是從頂層的啟動類加載器開始,只有當父加載器反饋自己無法完成這個加載請求,子類才會嘗試自己去加載。
使用雙親委派模型的好處:Java類隨著它的類加載器一起具備了一種帶有優先級的層次關系,比如保證不會出現兩個java.lang.Obejct
類,從而保證Java類型體系中最基礎的行為,保證了Java程序的穩定運作。
雙親委派模型的實現代碼(java.lang.ClassLoader
的loadClass()
方法):
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// 首先檢查該類有沒有被加載過
Class<?> c = findLoadedClass(name);//
if (c == null) {//說明之前該類沒加載過
long t0 = System.nanoTime();
try {
if (parent != null) {
//讓父類加載器取加載
c = parent.loadClass(name, false);
} else {
//讓啟動類加載器去加載
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//發生ClassNotFoundException異常,則說明父類加載器沒有加載成功
}
if (c == null) {//為空,說明父類加載器沒有加載成功
long t1 = System.nanoTime();
//調用自身findClass(String name)方法去進行類加載
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
破壞雙親委派模型(//todo)
- 第一次
發生在雙親委派模型出現之前,即JDK1.2發布之前。用戶在自定義類加載器時,重寫loadClass()
方法來實現自己的類加載邏輯?,F在,JDK1.2之后已不再提倡用戶重寫該方法,因為此方法是雙親委派模型實現的關鍵,而是讓用戶重寫findClass()
方法。 - 第二次
模型自身的缺陷導致,比如無法解決基礎類中調用用戶的代碼的問題,如JNDI服務(Java Naming and Directory Interface
)。 - 第三次
代碼熱替換、模塊熱部署等。
內容來自《深入理解Java虛擬機》:虛擬機類加載機制