深入理解Java虚拟机——JVM类加载机制(类加载过程和类加载器)
一、什么是類加載機制?
虛擬機把描述類的數據從Class文件加載到內存,并對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。
二、類加載的時機
類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期包括:加載(Loading)、驗證(Verification)、準(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個部分統稱為連接(Linking),這7個階段的發生順序如圖:
加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持Java語言的運行時綁定(也稱為動態綁定或晚期綁定)。
什么情況下需要開始類加載過程的第一個階段:加載?Java虛擬機規范中并沒有進行強制約束,這點可以交給虛擬機的具體實現來自由把握。但是對于初始化階段,虛擬機規范則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):
1)遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
4)當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
5)當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。
三、類加載過程
1、加載
“加載”是“類加載”(Class Loading)過程的一個階段。在加載階段,虛擬機需要完成以下3件事情:
1)通過一個類的全限定名來獲取定義此類的二進制字節流。
2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
3)在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
相對于類加載過程的其他階段,一個非數組類的加載階段(準確地說,是加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的,因為加載階段既可以使用系統提供的引導類加載器來完成,也可以由用戶自定義的類加載器去完成,開發人員可以通過定義自己的類加載器去控制字節流的獲取方式(即重寫一個類加載器的loadClass()方法)。
加載階段完成后,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機實現自行定義,虛擬機規范未規定此區域的具體數據結構。然后在內存中實例化一個java.lang.Class類的對象(并沒有明確規定是在Java堆中,對于HotSpot虛擬機而言,Class對象比較特殊,它雖然是對象,但是存放在方法區里面),這個對象將作為程序訪問方法區中的這些類型數據的外部接口。
2、驗證
驗證是鏈接階段的第一步,這一步主要的目的是確保class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身安全。?
驗證階段主要包括四個檢驗過程:文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證。?
1.文件格式驗證
驗證class文件格式規范,例如: class文件是否已魔術0xCAFEBABE開頭 , 主、次版本號是否在當前虛擬機處理范圍之內等
2.元數據驗證
這個階段是對字節碼描述的信息進行語義分析,以保證起描述的信息符合java語言規范要求。驗證點可能包括:這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)、這個類是否繼承了不允許被繼承的類(被final修飾的)、如果這個類的父類是抽象類,是否實現了起父類或接口中要求實現的所有方法。
3.字節碼驗證
進行數據流和控制流分析,這個階段對類的方法體進行校驗分析,這個階段的任務是保證被校驗類的方法在運行時不會做出危害虛擬機安全的行為。如:保證訪法體中的類型轉換有效,例如可以把一個子類對象賦值給父類數據類型,這是安全的,但不能把一個父類對象賦值給子類數據類型、保證跳轉命令不會跳轉到方法體以外的字節碼命令上。
4.符號引用驗證
3、準備
準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些內存都將在方法區中進行分配。這個階段中有兩個容易產生混淆的知識點,首先是這時候進行內存分配的僅包括類變量(static 修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在java堆中。其次是這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量定義為:?
public static int value = 12;
那么變量value在準備階段過后的初始值為0而不是12,因為這時候尚未開始執行任何java方法,而把value賦值為123的putstatic指令是程序被編譯后,存放于類構造器()方法之中,所以把value賦值為12的動作將在初始化階段才會被執行。
上面所說的“通常情況”下初始值是零值,那相對于一些特殊的情況,如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量value就會被初始化為ConstantValue屬性所指定的值,建設上面類變量value定義為:?
public static final int value = 123;
編譯時javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value設置為123。
4、解析
解析階段是虛擬機常量池內的符號引用替換為直接引用的過程。?
符號引用:符號引用是一組符號來描述所引用的目標對象,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標對象并不一定已經加載到內存中。?
直接引用:直接引用可以是直接指向目標對象的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機內存布局實現相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同,如果有了直接引用,那引用的目標必定已經在內存中存在。
虛擬機規范并沒有規定解析階段發生的具體時間,只要求了在執行anewarry、checkcast、getfield、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic這13個用于操作符號引用的字節碼指令之前,先對它們使用的符號引用進行解析,所以虛擬機實現會根據需要來判斷,到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。
解析的動作主要針對類或接口、字段、類方法、接口方法四類符號引用進行。分別對應編譯后常量池內的CONSTANT_Class_Info、CONSTANT_Fieldref_Info、CONSTANT_Methodef_Info、CONSTANT_InterfaceMethoder_Info四種常量類型。
1.類、接口的解析?
2.字段解析?
3.類方法解析?
4.接口方法解析
5、初始化
類的初始化階段是類加載過程的最后一步,在準備階段,類變量已賦過一次系統要求的初始值,而在初始化階段,則是根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器< clinit >()方法的過程。在以下四種情況下初始化過程會被觸發執行:
1.遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需先觸發其初始化。生成這4條指令的最常見的java代碼場景是:使用new關鍵字實例化對象、讀取或設置一個類的靜態字段(被final修飾、已在編譯器把結果放入常量池的靜態字段除外)的時候,以及調用類的靜態方法的時候。
2.使用java.lang.reflect包的方法對類進行反射調用的時候
3.當初始化一個類的時候,如果發現其父類還沒有進行過初始化、則需要先出發其父類的初始化
4.jvm啟動時,用戶指定一個執行的主類(包含main方法的那個類),虛擬機會先初始化這個類
在上面準備階段 public static int value = 12; 在準備階段完成后 value的值為0,而在初始化階調用了類構造器< clinit >()方法,這個階段完成后value的值為12。
*類構造器< clinit >()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static塊)中的語句合并產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句快可以賦值,但是不能訪問。
*類構造器< clinit >()方法與類的構造函數(實例構造函數< init >()方法)不同,它不需要顯式調用父類構造,虛擬機會保證在子類< clinit >()方法執行之前,父類的< clinit >()方法已經執行完畢。因此在虛擬機中的第一個執行的< clinit >()方法的類肯定是java.lang.Object。
*由于父類的< clinit >()方法先執行,也就意味著父類中定義的靜態語句快要優先于子類的變量賦值操作。
*< clinit >()方法對于類或接口來說并不是必須的,如果一個類中沒有靜態語句,也沒有變量賦值的操作,那么編譯器可以不為這個類生成< clinit >()方法。
*接口中不能使用靜態語句塊,但接口與類不太能夠的是,執行接口的< clinit >()方法不需要先執行父接口的< clinit >()方法。只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現類在初始化時也一樣不會執行接口的< clinit >()方法。
*虛擬機會保證一個類的< clinit >()方法在多線程環境中被正確加鎖和同步,如果多個線程同時去初始化一個類,那么只會有一個線程執行這個類的< clinit >()方法,其他線程都需要阻塞等待,直到活動線程執行< clinit >()方法完畢。如果一個類的< clinit >()方法中有耗時很長的操作,那就可能造成多個進程阻塞。
四、類加載器
虛擬機設計團隊把類加載階段中的“通過一個類的全限定名來獲取描述此類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需要的類。實現這個動作的代碼模塊稱為“類加載器”。
1、類與類加載器
對于任何一個類,都需要由加載它的類加載器和這個類來確立其在JVM中的唯一性。也就是說,兩個類來源于同一個Class文件,并且被同一個類加載器加載,這兩個類才相等。
2、雙親委派模型
從虛擬機的角度來說,只存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap ClassLoader),該類加載器使用C++語言實現,屬于虛擬機自身的一部分。另外一種就是所有其它的類加載器,這些類加載器是由Java語言實現,獨立于JVM外部,并且全部繼承自抽象類java.lang.ClassLoader。
從Java開發人員的角度來看,大部分Java程序一般會使用到以下三種系統提供的類加載器:
1)啟動類加載器(Bootstrap ClassLoader):負責加載JAVA_HOME\lib目錄中并且能被虛擬機識別的類庫到JVM內存中,如果名稱不符合的類庫即使放在lib目錄中也不會被加載。該類加載器無法被Java程序直接引用。
2)擴展類加載器(Extension ClassLoader):該加載器主要是負責加載JAVA_HOME\lib\,該加載器可以被開發者直接使用。
3)應用程序類加載器(Application ClassLoader):該類加載器也稱為系統類加載器,它負責加載用戶類路徑(Classpath)上所指定的類庫,開發者可以直接使用該類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。?
我們的應用程序都是由這三類加載器互相配合進行加載的。?
另外還有自定義類加載器。?
4)自定義類加載器(必須繼承 ClassLoader)。?
這些類加載器之間的關系如下圖所示:
如上圖所示的類加載器之間的這種層次關系,就稱為類加載器的雙親委派模型(Parent Delegation Model)。該模型要求除了頂層的啟動類加載器外,其余的類加載器都應當有自己的父類加載器。子類加載器和父類加載器不是以繼承(Inheritance)的關系來實現,而是通過組合(Composition)關系來復用父加載器的代碼。
雙親委派模型的工作過程為:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的加載器都是如此,因此所有的類加載請求都會傳給頂層的啟動類加載器,只有當父加載器反饋自己無法完成該加載請求(該加載器的搜索范圍中沒有找到對應的類)時,子加載器才會嘗試自己去加載。
使用這種模型來組織類加載器之間的關系的好處是Java類隨著它的類加載器一起具備了一種帶有優先級的層次關系。例如java.lang.Object類,無論哪個類加載器去加載該類,最終都是由啟動類加載器進行加載,因此Object類在程序的各種類加載器環境中都是同一個類。否則的話,如果不使用該模型的話,如果用戶自定義一個java.lang.Object類且存放在classpath中,那么系統中將會出現多個Object類,應用程序也會變得很混亂。如果我們自定義一個rt.jar中已有類的同名Java類,會發現JVM可以正常編譯,但該類永遠無法被加載運行。
雙親委派模型的實現:
protected synchronized Class<?>loadClass(String name,boolean resolve)throws ClassNotFoundException
{
//首先,檢查請求的類是否已經被加載過了
Class c=findLoadedClass(name);
if(c==null){
try{
if(parent!=null){
c=parent.loadClass(name,false);
}else{
c=findBootstrapClassOrNull(name);
}
}catch(ClassNotFoundException e){
//如果父類加載器拋出ClassNotFoundException
//說明父類加載器無法完成加載請求
}
if(c==null){
//在父類加載器無法加載的時候
//再調用本身的findClass方法來進行類加載
c=findClass(name);
}
}
if(resolve){
resolveClass(c);
}
return c;
}
?
雙親委派模型對于保證Java程序的穩定運作很重要,但它的實現卻非常簡單,實現雙親委派的代碼都集中在java.lang.ClassLoader的loadClass()方法之中,如上代碼所示,邏輯清晰易懂:先檢查是否已經被加載過,若沒有加載則調用父加載器的loadClass()方法,若父加載器為空則默認使用啟動類加載器作為父加載器。如果父類加載失敗,拋出ClassNotFoundException異常后,再調用自己的findClass()方法進行加載。
內容參考《深入理解Java虛擬機:JVM高級特性與最佳實踐》(第2版)周志明
總結
以上是生活随笔為你收集整理的深入理解Java虚拟机——JVM类加载机制(类加载过程和类加载器)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: FastDFS 学习笔记
- 下一篇: Java queue总结