本文部分摘自《深入理解 Java 虛擬機第三版》
概述
Java 虛擬機把描述類的資料從 Class 檔案加載到記憶體,并對資料進行校驗、轉換決議和初始化,最終形成可以被虛擬機直接使用的 Java 型別,這個程序被稱作虛擬機的類加載機制
與那些在編譯時需要進行連接的語言不同,在 Java 語言里面,型別的加載、連接和初始化程序都是在程式運行期間完成的,這種做法雖然讓類加載時稍微增加了一些性能開銷,但也為 Java 應用提供了極高的擴展性和靈活性,Java 可動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實作的
類加載的時機
一個型別從被加載到虛擬機記憶體開始,到卸載出記憶體為止,它的整個生命周期將會經歷加載(Loading)、驗證(Verification)、準備(Prepartion)、決議(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、決議三個部分統稱為連接(Linking)

其中加載、驗證、準備、初始化和卸載這五個階段的順序是確定的,必須按這個順序開始,而決議階段則不一定,它在某些情況下可以在初始化階段之后再開始,這是為了支持Java 語言的運行時動態系結特性(也稱動態系結或晚期系結),注意這里寫的是按順序開始,而非進行,因為這些階段通常是互相互動地混合進行的,會在一個階段執行的程序中呼叫、激活另一個階段
關于在什么情況下開始類加載程序的第一個階段加載,Java 虛擬機規范并沒有強制約束,可以交給虛擬機的具體實作來自由把握,但對于初始化階段,Java 虛擬機則是嚴格規定了有且只有六種情況必須立即對類進行初始化(而加載、驗證、準備自然要在此之前開始):
- 遇到 new、getstatic、putstatic 或 invokestatic 這四條位元組碼指令時,如果型別沒有初始化,則先觸發初始化階段,能夠生產這四條指令的典型 Java 代碼場景有:
- 使用 new 關鍵字實體化物件
- 讀取或設定一個型別的靜態欄位(被 final 修飾、已在編譯期把結果放入常量池的靜態欄位除外)
- 呼叫一個型別的靜態方法
- 對型別進行反射呼叫時,如果型別沒有初始化,則先觸發初始化階段
- 初始化類時,其父類尚未初始化,則需先觸發其父類的初始化
- 虛擬機啟動時,用戶需要指定一個要執行的類(包含 main() 方法的類),會先初始化該主類
- 使用了 JDK7 新加入的動態語言支持
- 一個介面定義了 JDK8 新加入的默認方法,如果該介面的實作類發生了初始化,那介面要先被初始化
對于這六種會觸發型別進行初始化的場景,稱為對型別進行主動參考,其他所有參考型別的方式都不會觸發初始化,稱為被動參考,例如通過子類參考父類的靜態欄位不會導致子類初始化、通過陣列定義來參考類不會觸發類的初始化、參考常量等
介面的加載程序與類稍有不同,區別在于主動參考的第三種場景:當一個類在初始化時,要求其父類全部都已初始化,但一個介面在初始化時,并不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如參考介面中定義的常量)才會初始化
類加載的程序
接下來我們會詳細了解 Java 虛擬機中類加載的全程序,即加載、驗證、準備、決議和初始化這五個階段所執行的具體動作
1. 加載
在加載截斷,Java 虛擬機需要完成完成以下三件事情:
- 通過一個類的全限定名來獲取定義此類的二進制位元組流
- 將這個位元組流所代表的靜態存盤結構轉化為方法區的運行時資料結構
- 在記憶體中生成一個代表該類的 java.lang.Class 物件,作為方法區這個類的各種資料的訪問入口
Java 虛擬機規范對這三點要求并不是特別具體,因此實作十分靈活,例如第一點,它并沒有指明二進制位元組流必須從某個 Class 檔案中獲取,僅僅這一點空隙,充滿創造力的開發人員就玩出了各種花樣,許多日后舉足輕重的 Java 技術都基于這一基礎:
- 從 ZIP 壓縮包中讀取,這是日后 JAR、EAR、WAR 格式的基礎
- 從網路中獲取
- 運行時計算生成,最常見的就是動態代理技術
- 由其他檔案生成,如 JSP 檔案生成對應 Class 檔案
- 從資料庫讀取
- 從加密檔案讀取
相對于類加載的其他階段,非陣列型別的加載階段是開發人員可控性最強的階段,加載階段既可以使用 Java 虛擬機內置的引導類加載器完成,也可以由用戶自定義的類加載完成,陣列類本身不通過類加載器創建,它由 Java 虛擬機直接在記憶體中動態構造出來,但陣列類中元素的型別最侄訓是要靠類加載器來完成加載
加載階段結束后,Java 虛擬機外部的二進制位元組流按照虛擬機設定的格式存盤在方法區之中,存盤格式由虛擬機自行定義,型別資料在方法區安置完后,會在堆中實體化一個 java.lang.Class 類的物件,作為程式訪問方法區中型別資料的外部介面
2. 驗證
驗證是連接階段的第一步,目的是確保 Class 檔案的位元組流中包含的資訊符合 Java 虛擬機規范的要求,保證這些資訊被當作代碼運行后不會危害虛擬機自身的安全
驗證階段大致上會完成下面四個階段的檢驗動作:
- 檔案格式校驗:驗證位元組流是否符合 Class 檔案格式的規范,并能被當前版本的虛擬機所處理,例如是否以魔數開頭、主次版本號是否在當前虛擬機的接受范圍之內等等
- 元資料驗證:對位元組碼描述的資訊進行語意分析,以保證其描述的資訊符合 Java 語言規范的要求,例如該類是否有父類、是否實作其父類或介面所要求實作的所有方法等等
- 位元組碼驗證:通過資料流分析和控制流分析,確定程式語意是合法的、符合邏輯的,例如保證任何時刻運算元堆疊的資料與指令代碼序列都能配合作業、保證任何跳轉指令都不會跳到方法體以外的位元組碼指令上等等
- 符號參考驗證:該階段的校驗行為發生在虛擬機將符號參考轉化為直接參考的時候,這個轉化的動作發生在決議階段,符號參考驗證可以看作是對類自身以外(常量池中的各種符號參考)的各類資訊進行匹配性校驗,該類是否缺少或被禁止訪問它所依賴的某些外部類、方法、欄位等資源
驗證階段并不是一個必須要執行的階段,如果程式運行的所有代碼都已經被反復使用和驗證過,那么在生產環境的實驗階段可以考慮使用 -Xverify:none 引數來關閉大部分的類驗證,縮短虛擬機加載的時間
3. 準備
準備階段是整數為類中定義的變數(即靜態變數)分配記憶體并設定類變數初始值的階段,注意這里進行記憶體分配的僅包括類變數,而非實體變數,實體變數會在物件實體化時隨著物件一起分配在 Java 堆中,其次這里說的初始值通常情況下是資料型別的零值
4. 決議
決議階段是 Java 虛擬機將常量池內的符號參考替換為直接參考的程序,直接參考和符號參考之間的關系如下:
- 符號參考:以一組符號來描述所參考的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可
- 直接參考:可以是直接指向目標指標的指標、相對偏移量或者是一個能間接定位到目標的句柄
5. 初始化
在準備階段,變數已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程式員通程序式代碼制定的主觀計劃去初始化類變數和其他資源
初始化階段就是執行類構造器 <clinit> 方法的程序,該方法是 Javac 編譯器的自動生成物,由編譯器自動收集類中的所有類變數的賦值動作和靜態陳述句塊中的陳述句合并產生,陳述句順序按源檔案的順序決定
<clinit> 方法與類的構造方法不同,它不需要顯式地呼叫父類構造器,Java 虛擬機會保證子類的 <clinit> 方法執行前,父類的 <clinit> 方法已經執行完畢,由于父類 <clinit> 方法先執行,因此父類中定義的靜態陳述句塊要優于子類的變數賦值操作
介面中不能使用靜態陳述句塊,但仍有變數初始化的賦值操作,因此介面與類一樣會生成 <clinit> 方法,不同的是,執行介面的 <clinit> 方法不需要先執行父介面的 <clinit> 方法,只有當父介面中定義的變數被使用,父介面才會被初始化,此外,介面的實作類在初始化時也一樣不會執行介面的 <clinit> 方法
Java 虛擬機必須保證一個類的 <clinit> 方法在多執行緒環境下被正確地加鎖同步,如果有多個執行緒同時初始化一個類,那么只會有一個執行緒去執行 <clinit> 方法,其他執行緒阻塞等待,直至活動執行緒執行完 <clinit> 方法,但其他執行緒喚醒后不會再次進入 <clinit> 方法,同一個類加載器下,一個型別只會被初始化一次
類加載器
Java 虛擬機設計團隊有意把類加載階段中的“通過一個類的全限定名來獲取描述該類的二進制位元組流”這個動作放到 Java 虛擬機外部去實作,以便讓程式自己決定如何而去獲取所需的類,實作這個動作的代碼被稱為類加載器
對于任意一個類,都必須由加載它的類加載器和這個類本身一起確立其在 Java 虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間,這句話更通俗地表達是:比較兩個類是否相等,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個 Class 檔案,被同一個 Java 虛擬機加載,只要加載它們的類加載器不同,那這兩個兩位就必定不相等
1. 雙親委派模型
絕大多數 Java 程式會使用到以下三個系統提供的類加載器來進行加載
-
啟動類加載器(Bootstrap Class Loader)
啟動類加載器使用 C++ 語言實作,負責加載存放在 <JAVA_HOME>\lib 目錄,或者被 -Xbootclasspath 引數所指定的路徑中存放的,而且是 Java 虛擬機所能識別的類別庫加載到虛擬機的記憶體中,啟動類加載器無法被 Java 程式直接參考
-
擴展類加載器(Extension Class Loader)
擴展類加載器是在類 sun.misc.Launcher$ExtClassLoader 中以 Java 代碼的形式來實作,負責加載 <JAVA_HOME>\lib\ext 目錄中,或者被 java.ext.dirs 系統變數所指定的路徑中所有的類別庫,即用來擴展 Java SE 功能的類別庫,由于擴展類加載器是由 Java 代碼實作,開發者可以直接在程式中使用擴展類加載器來加載 Class 檔案
-
應用程式類加載器(Application Class Loader)
這個類加載器由 sun.misc.Launcher$AppClassLoader 來實作,由于它是 ClassLoader 類中的 getSystemClassLoader 方法的回傳值,所以有些場合也稱它為系統類加載器,它負責加載用戶類路徑(ClassPath)上所有的類別庫,開發者也可以在代碼中使用這個類加載器,如果應用程式沒有自定義自己的類加載器,一般情況下這個就是程式中默認的類加載器
圖示的各種類加載器之間的層次關系被稱為類加載器的雙親委派模型,雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器,不過這里的類加載器之間的父子關系一般不以繼承關系實作,而使用組合關系來復用父加載器的代碼

雙親委派模型的作業工程是:如果一個類加載器收到了類加載的請求,首先它不會自己嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該被傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求時,子加載器才會嘗試自己去完成加載
使用雙親委派模型的好處就是 Java 中的類隨著它的類加載器一起具備了一種帶有優先級的層次關系,例如 java.lang.Object 類,無論哪一個類加載器加載它,最終都是委派給處于模型最頂端的啟動類加載器進行加載,因此 Object 類在程式的各種類加載器環境中都能保證是同一個類,如果沒有雙親委派機制,那么用戶也可以自己撰寫一個 java.lang.Object 類,并放在程式的 ClassPath 中,那系統就會出現多個不同的 Object 類,導致程式出錯
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/249692.html
標籤:Java
