虛擬機類加載機制

1、類加載時機

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

graph LR
A[加載]-->B[驗證]
B-->C[準備]
C-->D[解析]
D-->E[初始化]
E-->F[使用]
F-->G[卸載]

加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,只有解析階段在某些情況下可以在初始化階段之后開始,這是為了支持Java語言的運行時綁定(也稱為動態綁定或晚期綁定)。

虛擬機加載階段在Java虛擬機規范中并沒有進行強制約束,由虛擬機的具體實現來自由把握。虛擬機規范嚴格規定了有且只有5種情況必須立即對類進行“初始化”(加載、驗證、準備自然在此之前開始):

  • 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行初始化過,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象化的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
  • 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  • 當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

有且只有這5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發其初始化,稱為被動引用。例下面三種引用不會導致初始化:

  • 1、通過子類引用父類的靜態字段,不會導致子類初始化
  • 2、通過數組定義來引用類,不會觸發此類初始化
  • 3、常量在編譯階段會存入調用類的常量池中,本質上并沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化

接口和類初始化有所區別的地方在于:當一個類初始化時,要求其父類全部都已經初始化過了,但是接口初始化時,并不要求其父接口全部完成初始化,只有在使用到父接口時(引用父接口中的常量)才會初始化

2、類加載過程

2.1、加載

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

  • 通過一個類的全限定名來獲取定義此類的二進制字節流
  • 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構
  • 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口

獲取二進制字節流的途徑:

  • 1、從zip包中獲取,最終成為日后JAR、EAR、WAR格式的基礎
  • 2、從網絡中獲取,這種場景最典型的應用就是Applet
  • 3、運行時計算生成,這種場景使用最多的就是動態代理
  • 4、由其它文件生成,典型場景是JSP應用,即由JSP文件生成對應的Class類
  • 5、從數據庫中讀取,這種場景相對較少,例如有些中間件服務器(SAP Netweaver)可以選
    擇把程序安裝到數據庫中來完成程序代碼在集群間的分發。

相對于類加載過程的其他階段,一個非數組的加載階段(加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的,因為加載階段既可以使用系統提供的引導類加載器來完成,也可以由用戶自定義的類加載器去完成(開發人員通過定義自己的類加載器去控制字節流的獲取方式,即重寫一個類加載器的loadClass()方法)。

對于數組類而言,情況就有所不同,數組類本身不通過類加載器創建,它是由Java虛擬機直接創建的。但數組類與類加載器仍然有很密切的關系,因為數組類的元素類型(Element Typ
e,指的是數組去掉所有維度的類型)最終是要靠類加載器去創建,一個數組類創建過程遵循以下規則:

  • 1、如果數組的組件類型(Element Type,指的是數組去掉一個維度的類型)是引用類型,那
    就遞歸采用非數組加載過程去加載這個組件類型
  • 2、如果數組的組件類型是基本數據類型,Java虛擬機將會把數組C標記為與引導類加載器相關聯
  • 3、數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認為public

加載階段完成之后,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機實現自行定義,虛擬機規范未規定此區域的具體數據結構。然后在內存中實例化一個java.lang.Class類的對象(并沒有明確規定是在Java堆中,對于HotSpot虛擬機而言,Class對象比較特殊,它雖然是對象,但是存放在方法區里面),這個對象將作為程序訪問方法區中的這些類型數據的外部接口。

加載階段與連接階段的部分內容是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但是這兩個階段的開始時間仍然保持固定的先后順序。

2.2、驗證

驗證是連接階段的第一步,這一階段目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。

  • 1、文件格式驗證
  • 2、元數據驗證
  • 3、字節碼驗證
  • 4、符號引用驗證

2.3、準備

準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段中有兩個容易混淆的概念,首先,這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化隨著對象一起分配在Java堆中。其次,這里所說的初始值“通常情況”下是數據類型的零值,類變量的賦值動作是在初始化階段才會執行。

2.4、解析

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

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

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行。

1、類或接口的解析
假設當前代碼所處的類為D,如果要把一個從未解析過的符號引用N解析為一個類或接口C的直接引用,那么虛擬機完成整個解析過程需要3個步驟:

  • 1、如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器去加載這個類C。在加載過程中,由于元數據驗證、字節碼驗證的需要,又可能觸發其他相關類的加載動作,例如加載這個類的父類或實現的接口。
  • 2、如果C是一個數組類型,并且數組的元素類型為對象,也就是N的描述符會是類似“[Ljava/lang/Integer”的形式,那將會按照第1點的規則加載數組元素類型。如果N的描述符如前面所假設的形式,需要加載的元素類型就是“java.lang.Integer”,接著由虛擬機生成一個代表此數組維度和元素的數組對象。
  • 3、如果上面的步驟沒有出現任何異常,那么C在虛擬機中實際上已經成為一個有效的類或接口了,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問權限。

2、字段解析
虛擬機規范要求按照如下步驟對C進行后續字段的搜索

  • 1、如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
  • 2、否則,如果在C中實現了接口,將會按照繼承關系從下往上遞歸搜索各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
  • 3、否則,如果C不是java.lang.Object的話,將會按照繼承關系從下往上遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
  • 4、否則,查找失敗,拋出java.lang.NoSuchFieldError異常。

查找過程成功返回引用,將會對這個字段進行權限驗證,如果發現不具備對字段的訪問權限,將拋出java.lang.IlleglAccessError異常。

3、類方法解析
類方法解析第一個步驟與字段解析一樣,也需要先解析出類方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,我們依然用C表示這個類,接下來虛擬機將會按照如下步驟進行后續的類方法搜索:

  • 1、類方法和接口方法符號引用的常量類型定義是分開的,如果在類方法表中發現class_index中索引的C是個接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。
  • 2、在類C中查找是否有與目標相匹配的方法
  • 3、否則,在類C的父類中遞歸查找是否有與目標相匹配的方法,如果存在匹配的方法,返回這個方法的直接引用。
  • 4、否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明C是一個抽象類,這時查找結束,拋出java.lang.AbstractMethodError異常。
  • 5、否則,方法查找失敗,拋出java.lang.NoSunchMethodError。
    最后,查找成功返回直接引用,將會對這個方法進行權限驗證。

4、接口方法解析
接口方法也需要先解析出接口方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,依然用C表示這個接口,后續接口方法搜索步驟:

  • 1、首先在接口方法列表中發現class_index中的索引C不能是類應該是接口
  • 2、否則,在接口C中查找是否有與目標相匹配的方法
  • 3、否則,在接口C的父接口中遞歸查找,知道java.lang.Object類為止,看是否有簡單名稱和描述符都與目標相匹配的方法
  • 4、否則,方法查找失敗,拋出java.lang.NoSuchMethodError異常。
    因為接口中所有方法默認為public,所以不存在權限問題。

2.5初始化

類初始化階段是類加載階段過程的最后一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。

初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法執行過程中一些可能會影響程序運行行為的特點和細節

  • 1、<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合并而成的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊可以賦值但是不能訪問。
  • 2、<clinit>()方法與類的構造函數(或者說實例構造器<init>())不同,它不需要顯示地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。
  • 3、由于父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先于子類的變量賦值操作。
  • 4、<clinit>()方法對于類或接口來說并不是必需的,如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法。
  • 5、接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。
  • 6、虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖,同步,如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要等待,知道活動線程執行<clinit>()方法完畢(執行完<clinit>()方法的線程退出<clinit>()方法后,其它線程喚醒之后不會再去執行<clinit>()方法。同一個類加載器下,一個類型只會初始化一次)。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞,實際應用中這種阻塞往往是很隱蔽的。

3、類加載器

虛擬機團隊把類加載階段中的“通過一個類的全限定名來獲取描述此類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需要的類。實現這個動作的代碼模塊成為“類加載器”。
類加載器在類層次劃分、OSGi、熱部署、代碼加密等領域大放異彩,成為了Java技術體系的一塊基石。

3.1、類與類加載器

比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有的意義,否則,即使這兩個類來源于同一個class文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那么這兩個類就必定不相等。

3.2、雙親委派模型

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

從開發人員的角度劃分,類加載器還可以劃分得更細致一些,絕大多數Java程序都會使用以下3種系統提供的類加載器。
-1、啟動類加載器:這個類加載器負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,并且是虛擬機識別的(僅按照文件名識別,如rt.jar)類庫加載到虛擬機內存中。啟動類加載器無法被Java程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器,那直接使用null代替即可。

  • 2、擴展類加載器:這個加載器由sun.misc.Launcher$ExtClassLoader實現,它負責加載<JAVA_HOME>\lib\ext目錄中的,或者是被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。
  • 3、應用程序類加載器:這個類加載器由sun.misc.Launcher$AppClassLoader實現。由于這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱他為系統類加載器。它負責加載用戶路徑上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

使用雙親委派模型來組織類加載器的優點在于:一個類java.lang.Object,存放在rt.jar中無論哪一個類加載器要加載這個類,最終都是委派給處于模型

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容