《深入理解java虛擬機》-虛擬機類加載機制

代碼編譯的結果從本地機械碼轉變為字節碼,是存儲格式發展的一小步,卻是編程語言發展的一大步

類加載的時機

類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期包括:加載(Loading)、驗證(Verificatio)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段

類的生命周期

上述階段通常都是互相交叉地混合式進行的,會在一個階段執行地過程中調用、激活另一個階段。下面5種情況必須立即對類進行“初始化”(加載、驗證、準備在這之前開始):

  1. 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令
  2. 使用java.lang.reflect包地方法對類進行反射調用
  3. 初始化子類前要先初始化父類(接口不要求其父接口全部完成初始化,只有在使用父接口的時候才會初始化)
  4. 虛擬機啟動時,需要指定執行地主類(包含main()方法的類)
  5. 當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個句柄所對應的類沒有進行過初始化

類加載的過程

加載

在加載階段,虛擬機需要完成下列3件事:

  1. 通過一個類的全限定名來獲取定義此類的二進制字節流
  2. 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構
  3. 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口
  • 非數組類:可控性最強,加載階段既可以使用系統提供的引導類加載器,也可以使用自定義的類加載器
  • 數組類:由java虛擬機直接創建,數組類的元素類型(Element Type)最終是靠類加載器去創建
    • 如果數組類的組件類型(Component Type,即數組去掉一個維度的類型)是引用類型,那就遞歸加載這個組件類型,數組將在加載該組件類型的類加載器的類名稱空間上被標識
    • 如果數組的組件類型不是引用類型,java虛擬機將會把數組標記為與引導類加載器關聯
    • 數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認為public

加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始

驗證

驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。驗證階段大致上會完成下面4個階段的檢驗動作:

  1. 文件格式驗證:第一階段要驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理。主要目的是保證輸入的字節流能正確解析并存儲于方法區內,格式上符合描述一個java類型信息的要求。這個階段的驗證是基于二進制字節流進行的,只有通過了這個階段的驗證后,字節流才會存儲到方法區中,所以后面的3個驗證階段全部是基于方法區的存儲結構進行的,不會再直接操作字節流
  2. 元數據驗證:第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合java語言規范的要求。主要目的是對元數據信息進行語義校驗,保證不存在不符合java語言規范的元數據信息
  3. 字節碼驗證:第三階段是整個驗證過程中最復雜的一個階段,主要目的是通過數據流控制流分析,確定程序語義是合法的、符合邏輯的。如果一個類方法體的字節碼沒有通過字節碼驗證,那肯定是有問題的;但如果一個方法體通過了字節碼驗證,也不能說明其一定就是安全的
  4. 符號引用驗證:最后一個階段的驗證發生在虛擬機符號引用轉化為直接引用的時候,這個轉化動作將在連接的解析階段中發生,可以看做是對類自身以外的信息進行匹配性校驗。目的是確保解析動作能正常執行

準備

準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。有兩個要點:

  • 類變量是指被static修飾的變量,不包括實例變量
  • 初始值在通常情況下是數據類型的零值。ConnstantValue屬性(final)存在時,變量就會被初始化為ConstantValue屬性所指定的值

解析

解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用是能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用地目標不一定已經加載到內存中。各種虛擬機實現的內存布局可以各不相同,但是它們能接受地符號引用必須是一致的,因為符號引用地字面量形式明確定義在java虛擬機規范地Class文件格式中
  • 直接引用(Direct References):直接引用可以是直接指向目標的指針、相對偏移量或是一個能直接定位到目標的句柄。直接引用是和虛擬機實現的內存布局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在

符號引用更普適,直接引用更迅速。java虛擬機會對除invokedynamic指令外的其它指令的解析結果進行緩存,從而避免解析動作重復進行。如果一個符號引用之前已經被成功解析過,那么后續的引用解析就應當一直成功;如果第一次解析失敗,那么其它指令對這個符號的解析也應該收到相同的異常。invokedynamic指令是等到程序實際運行到這條指令的時候,解析動作才能進行

  1. 類或接口的解析
  2. 如果類或接口不是數組類型,那虛擬機將會把代表符號引用的全限定名交給類加載器加載
  3. 如果類或接口是一個數組類型,并且數組的元素類型為對象,就按第1點加載數組元素類型,接著由虛擬機生成一個代表此數組維度和元素的數組對象
  4. 如果上面的步驟沒有出現任何異常,還需進行符號引用驗證,確認類是否具備對該類或接口的訪問權限
  5. 字段解析
  6. 需要先對字段表的class_index項索引的CONSTANT_Class_info符號引用進行解析
  7. 如果類本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,就返回這個字段的直接引用,查找結束
  8. 如果類實現了接口,就按照繼承關系從下往上遞歸搜索各個接口,匹配到就返回直接引用,查找結束
  9. 如果類不是Object類,就按照繼承關系從下往上遞歸搜索父類,匹配到就返回直接引用,查找結束
  10. 否則,查找失敗,拋出java.lang.NoSuchFieldError異常
  11. 如果查找過程成功返回了引用,將會對這個字段進行權限驗證,如果發現不具備對字段的訪問權限,就拋出java.lang.IllegalAccessError異常
  12. 類方法解析 ==> 實現方法重寫
  13. 需要先對方法表的class_index項索引的方法所屬的類或接口的符號引用進行解析
  14. 類方法和接口方法符號引用的常量類型定義是分開的,如果類方法表中發現class_index中索引的類是個接口,就拋出java.lang.IncompatibleClassChangeError異常
  15. 如果在類中具有簡單名稱與描述符匹配的方法,就返回這個方法的直接引用,查找結束
  16. 在類的父類中查找,匹配到就返回方法的直接引用,查找結束
  17. 在類的接口列表及它們的父接口中查找,匹配到說明類是個抽象類,拋出java.lang.AbstractMethodError異常
  18. 否則,方法查找失敗,拋出java.lang.NoSuchMethodError異常
  19. 如果查找過程成功返回了引用,將會對這個方法進行權限驗證,如果發現不具備此方法的訪問權限,就拋出java.lang.IllegalAccessError異常
  20. 接口方法解析
  21. 需要先對接口方法表的class_index項索引的方法所屬的類或接口的符號引用進行解析
  22. 如果接口方法表中發現class_index中索引的接口是類,就拋出java.lang.IncompatibleClassChangeError異常
  23. 如果接口中存在簡單名稱與描述符都匹配的方法,就返回這個方法的直接引用,查找結束
  24. 在接口的父接口中遞歸查找,匹配到就返回方法的直接引用,查找結束
  25. 否則,查找失敗,拋出java.lang.NoSuchMethodError異常

初始化

類初始化是類加載過程的最后一步,在這個階段才真正開始執行類中的字節碼。初始化階段是執行類構造器<clinit>()方法的過程。

  • <clinit>()方法與類的構造函數(<init>()方法)不同,它不需要顯式調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢
  • 由于父類的<clinit>()方法先執行,因此父類中定義的靜態語句塊要先于子類執行
  • <clinit>()方法對于類或接口來說不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量賦值操作,那么編譯器可以不為這個類生成<clinit>()方法
  • 接口中不能使用靜態語句塊,但仍然由變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法,但與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法,只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法
  • 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步

類加載器

類與類加載器

類加載器雖然只用于實現類的加載動作,但在java程序中起到的作用卻遠不止類加載階段。對于任意一個類,都需要由加載它的類加載器和這個本身一同確立其在java虛擬機中的唯一性,每個類加載器,都擁有一個獨立的類命名空間。當一個Class文件被不同的類加載器加載時,加載生成的兩個類必定不相等(equals()、isAssignableFrom()、isInstance()、instanceof關鍵字的結果為false)

雙親委派機制

從java虛擬機的角度來看,只存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用c++實現,是虛擬機的一部分;另一種是所有其他的類加載器,這些類加載器都由java實現,獨立于虛擬機外部,并且全部繼承自抽象類java.lang.ClassLoader

從java開發人員的角度看,絕大部分java程序都會使用到以下3中系統提供得加載器:

  • 啟動類加載器(Bootstrap ClassLoader):這個類負責將存放在<JAVA_HOME>\lib目錄中,或者被-Xbootclasspath參數所指定的路徑中的類庫加載到虛擬機內存中
  • 擴展類加載器(Extension ClassLoader):這個加載器由sun.misc.Launcher$ExtClassLoader實現,它負責加載<JAVA_HOME>\lib\ext目錄中或者被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器
  • 應用程序類加載器(Application ClassLoader):這個類加載器由sun.misc.Launcher$AppClassLoader實現。由于這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱為系統類加載器,負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器
類加載器雙親委派機制模型

雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載

雙親委派機制之外

雙親委派模型并不是一個強制性的約束模型,而是java設計者推薦給開發者的類加載器實現方式。到目前為止,雙親委派模型主要出現過3次較大規模的“被破壞”情況

  1. 第一次是為了jdk1.2向上兼容,添加了一個新的findClass()方法
  2. 第二次是由于這個模型本身的缺陷導致,當基礎類需要回調用戶代碼時,例如JNDI、JDBC、JCE、JAXB、JBI等,為此在Thread類中添加了線程上下文類加載器(Thread Context ClassLoader)
  3. 第三次是由于“熱加載”,即追求即插即用的效果,為此出現了OSGi環境,在這個環境下類加載器發展為網狀結構,OSGi的搜索順序:
  4. 將以java.* 開頭的類委派給父類加載器加載
  5. 將委派列表名單內的類委派給父類加載器加載
  6. 將Import列表中的類委派給Export這個類的Bundle的類加載器加載
  7. 查找當前Bundle的ClassPath,使用自己的類加載加載
  8. 查找類是否在自己的Fragment Bundle中,如果在,則委派給Fragment Bundle的類加載器加載
  9. 查找Dynamic Import列表的Bundle,委派給對應的Bundle的類加載器加載
  10. 加載失敗
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容