深入理解JVM(四)類載入的時機

_雲起_發表於2018-06-15

1.類載入的時機

1.1.虛擬機器類載入機制的概念

虛擬機器把描述類的資料從class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化。最終形成可以被虛擬機器最直接使用的java型別的過程就是虛擬機器的類載入機制。

1.2 類載入的時機

類從被載入到虛擬機器記憶體到卸出記憶體為止,它的整個生命週期包括:


深入理解JVM(四)類載入的時機
類的生命週期

1.3 類初始化時機

虛擬機器規範中並沒有強制約束何時進行載入,但是規範嚴格規定了有且只有下列五種情況必須對類進行初始化(載入、驗證、準備都會隨著發生):

1.3.1 遇到 new、getstatic、putstatic、invokestatic 這四條位元組碼指令時,如果類沒有進行過初始化,則必須先觸發其初始化。最常見的生成這 4 條指令的場景是:使用 new 關鍵字例項化物件的時候;讀取或設定一個類的靜態欄位(被 final 修飾、已在編譯器把結果放入常量池的靜態欄位除外)的時候;以及呼叫一個類的靜態方法的時候。

1.3.2 使用 java.lang.reflect 包的方法對類進行反射呼叫的時候,如果類沒有進行初始化,則需要先觸發其初始化。

1.3.3當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

1.3.4 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含 main() 方法的那個類),虛擬機器會先初始化這個主類;

1.3.5 當使用 JDK.7 的動態語言支援時,如果一個 java.lang.invoke.MethodHandle 例項最後的解析結果為 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行過初始化,則需要先觸發其初始化;

以上 5 種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。被動引用的常見例子包括:

通過子類引用父類的靜態欄位,不會導致子類初始化。

通過陣列定義來引用類,不會觸發此類的初始化。該過程會對陣列類進行初始化,陣列類是一個由虛擬機器自動生成的、直接繼承自 Object 的子類,其中包含了陣列的屬性和方法。

常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。

2.類載入的過程

2.1. 載入

載入是類載入的一個階段,注意不要混淆。

載入過程完成以下三件事:

2.1.1 通過一個類的全限定名來獲取定義此類的二進位制位元組流。

2.1.2.將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時儲存結構。

2.1.3 在記憶體中生成一個代表這個類的 Class 物件,作為方法區這個類的各種資料的訪問入口。

其中二進位制位元組流可以從以下方式中獲取:

從 ZIP 包讀取,這很常見,最終成為日後 JAR、EAR、WAR 格式的基礎。

從網路中獲取,這種場景最典型的應用是 Applet。

執行時計算生成,這種場景使用得最多得就是動態代理技術,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 的代理類的二進位制位元組流。

由其他檔案生成,典型場景是 JSP 應用,即由 JSP 檔案生成對應的 Class 類。

從資料庫讀取,這種場景相對少見,例如有些中介軟體伺服器(如 SAP Netweaver)可以選擇把程式安裝到資料庫中來完成程式程式碼在叢集間的分發。 ...

2.2 驗證

驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

虛擬機器如果不檢查輸入的位元組流,並對其完全信任的話,很可能會因為載入了有害的位元組流而導致系統崩潰,所以驗證是虛擬機器對自身保護的一項重要工作。這個階段是否嚴謹,直接決定了java虛擬機器是否能承受惡意程式碼的攻擊。

從整體上看,驗證階段大致上會完成4個階段的校驗工作:檔案格式、後設資料、位元組碼、符號引用

2.2.1. 檔案格式驗證

驗證位元組流是否符合 Class 檔案格式的規範,並且能被當前版本的虛擬機器處理。

2.2.2 後設資料驗證

對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合 Java 語言規範的要求。

2.2.3 位元組碼驗證

通過資料流和控制流分析,確保程式語義是合法、符合邏輯的。

2.2.4 符號引用

發生在虛擬機器將符號引用轉換為直接引用的時候,對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗。

3 準備

準備階段是正式為類變數(static 成員變數)分配記憶體並設定類變數初始值(零值)的階段,這些變數所使用的記憶體都將在方法區中進行分配。這時候進行記憶體分配的僅包括類變數,而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在堆中。其次,這裡所說的初始值“通常情況”下是資料型別的零值,假設一個類變數的定義為:

public static int value=123;

  那麼,變數value在準備階段過後的值為0而不是123。因為這時候尚未開始執行任何java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器方法()之中,所以把value賦值為123的動作將在初始化階段才會執行。至於“特殊情況”是指:當類欄位的欄位屬性是ConstantValue時,會在準備階段初始化為指定的值,所以標註為final之後,value的值在準備階段初始化為123而非0。

public static final intvalue =123;

4 解析

 解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用進行。

5 初始化

初始化階段才真正開始執行類中的定義的 Java 程式程式碼。初始化階段即虛擬機器執行類構造器 () 方法的過程。

在準備階段,類變數已經賦過一次系統要求的初始值,而在初始化階段,根據程式設計師通過程式制定的主觀計劃去初始化類變數和其它資源。

<init>() 方法具有以下特點:

是由編譯器自動收集類中所有類變數的賦值動作和靜態語句塊(static{} 塊)中的語句合併產生的,編譯器收集的順序由語句在原始檔中出現的順序決定。特別注意的是,靜態語句塊只能訪問到定義在它之前的類變數,定義在它之後的類變數只能賦值,不能訪問。例如以下程式碼:

public classTest{

static{

i =0;// 給變數賦值可以正常編譯通過System.out.print(i);// 這句編譯器會提示“非法向前引用”

}

static int i =1;

}

與類的建構函式(或者說例項構造器 <init> ())不同,不需要顯式的呼叫父類的構造器。虛擬機器會自動保證在子類的<init> () 方法執行之前,父類的 <init>() 方法已經執行結束。因此虛擬機器中第一個執行<init> () 方法的類肯定為 java.lang.Object。

由於父類的<init> () 方法先執行,也就意味著父類中定義的靜態語句塊要優於子類的變數賦值操作。例如以下程式碼:

static class Parent {

public static intA =1;

static{

A =2;

}

}

static class Sub extends Parent {

public static int B = A;

}

public static void main (String[] args){

System.out.println(Sub.B);// 輸出結果是父類中的靜態變數 A 的值 ,也就是 2。

}

<init>() 方法對於類或介面不是必須的,如果一個類中不包含靜態語句塊,也沒有對類變數的賦值操作,編譯器可以不為該類生成<init> () 方法。

介面中不可以使用靜態語句塊,但仍然有類變數初始化的賦值操作,因此介面與類一樣都會生成<init> () 方法。但介面與類不同的是,執行介面的<init> () 方法不需要先執行父介面的 <init>() 方法。只有當父介面中定義的變數使用時,父介面才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<init> () 方法。

虛擬機器會保證一個類的<init> () 方法在多執行緒環境下被正確的加鎖和同步,如果多個執行緒同時初始化一個類,只會有一個執行緒執行這個類的<init> () 方法,其它執行緒都會阻塞等待,直到活動執行緒執行 <init>() 方法完畢。如果在一個類的 <init>() 方法中有耗時的操作,就可能造成多個程式阻塞,在實際過程中此種阻塞很隱蔽。


6 案例分析

深入理解JVM(四)類載入的時機
圖1

這裡因為是順序執行,所以首先建立singleton物件,緊接著這裡就會在堆內開闢空間並且執行普通程式碼塊,然後執行構造器【init】方法,然後count1和count2都賦值為1;然後緊接著順序執行初始化操作,然後count1被賦值為1;count2為5,結果如下。

深入理解JVM(四)類載入的時機


深入理解JVM(四)類載入的時機
圖2

這裡先是為靜態變數賦值,count1為0;count2為5;緊接著建立singleton物件,緊接著這裡就會在堆內開闢空間並且執行普通程式碼塊,然後執行構造器【init】方法,count1進行+1為1;count2也加1;最後執行靜態程式碼塊,結果如下

深入理解JVM(四)類載入的時機

7 類載入器

虛擬機器設計團隊把類載入階段中的“通過一個類的全限定名來獲取描述此類的二進位制位元組流 ( 即位元組碼 )”這個動作放到 Java 虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類。實現這個動作的程式碼模組稱為“類載入器”。

7.1. 類與類載入器

對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在 Java 虛擬機器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。通俗而言:比較兩個類是否“相等”(這裡所指的“相等”,包括類的 Class 物件的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果,也包括使用 instanceof() 關鍵字做物件所屬關係判定等情況),只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個 Class 檔案,被同一個虛擬機器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等。

7.2 類載入器的分類

從 Java 虛擬機器的角度來講,只存在以下兩種不同的類載入器:

一種是啟動類載入器(Bootstrap ClassLoader),這個類載入器用 C++ 實現,是虛擬機器自身的一部分;另一種就是所有其他類的載入器,這些類由 Java 實現,獨立於虛擬機器外部,並且全都繼承自抽象類 java.lang.ClassLoader。

從 Java 開發人員的角度看,類載入器可以劃分得更細緻一些:

啟動類載入器(Bootstrap ClassLoader) 此類載入器負責將存放在 \lib 目錄中的,或者被 -Xbootclasspath 引數所指定的路徑中的,並且是虛擬機器識別的(僅按照檔名識別,如 rt.jar,名字不符合的類庫即使放在 lib 目錄中也不會被載入)類庫載入到虛擬機器記憶體中。 啟動類載入器無法被 Java 程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給啟動類載入器,直接使用 null 代替即可。

擴充套件類載入器(Extension ClassLoader) 這個類載入器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)實現的。它負責將 /lib/ext 或者被 java.ext.dir 系統變數所指定路徑中的所有類庫載入到記憶體中,開發者可以直接使用擴充套件類載入器。

應用程式類載入器(Application ClassLoader) 這個類載入器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的。由於這個類載入器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般稱為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

7.3 雙親委派模型

應用程式都是由三種類載入器相互配合進行載入的,如果有必要,還可以加入自己定義的類載入器。下圖展示的類載入器之間的層次關係,稱為類載入器的雙親委派模型(Parents Delegation Model)。該模型要求除了頂層的啟動類載入器外,其餘的類載入器都應有自己的父類載入器,這裡類載入器之間的父子關係一般通過組合(Composition)關係來實現,而不是通過繼承(Inheritance)的關係實現。

7.3.1 工作過程

如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入,而是把這個請求委派給父類載入器,每一個層次的載入器都是如此,依次遞迴。因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成此載入請求(它搜尋範圍中沒有找到所需類)時,子載入器才會嘗試自己載入。

7.3.2 好處

使用雙親委派模型來組織類載入器之間的關係,使得 Java 類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如類 java.lang.Object,它存放在 rt.jar 中,無論哪個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此 Object 類在程式的各種類載入器環境中都是同一個類。相反,如果沒有雙親委派模型,由各個類載入器自行載入的話,如果使用者編寫了一個稱為java.lang.Object 的類,並放在程式的 ClassPath 中,那系統中將會出現多個不同的 Object 類,程式將變得一片混亂。如果開發者嘗試編寫一個與 rt.jar 類庫中已有類重名的 Java 類,將會發現可以正常編譯,但是永遠無法被載入執行。

7.4 破壞雙親委派模型

雙親委派模型主要出現過3次較大規模“被破壞”的情況

第一次破壞是因為類載入器和抽象類java.lang.ClassLoader在JDK1.0就存在的,而雙親委派模型在JDK1.2之後才被引入,為了相容已經存在的使用者自定義類載入器,引入雙親委派模型時做了一定的妥協:在java.lang.ClassLoader中引入了一個findClass()方法,在此之前,使用者去繼承java.lang.Classloader的唯一目的就是重寫loadClass()方法。JDK1.2之後不提倡使用者去覆蓋loadClass()方法,而是把自己的類載入邏輯寫到findClass()方法中,如果loadClass()方法中如果父類載入失敗,則會呼叫自己的findClass()方法來完成載入,這樣就可以保證新寫出來的類載入器是符合雙親委派模型規則的。

第二次破壞 是因為模型自身的缺陷,例如:JNDI服務,它希望啟動類載入器去載入,但是啟動類載入器不認識。為了解決這個問題,Java設計團隊引入的設計時“執行緒上下文類載入器(Thread Context ClassLoader)”。這樣可以通過父類載入器請求子類載入器去完成類載入動作。但是卻違背了雙親委派模型的一般性原則。

第三次破壞 是由於使用者對程式動態性的追求導致的。這裡所說的動態性是指:“程式碼熱替換”、“模組熱部署”等等比較熱門的詞。說白了就是希望應用程式能夠像我們的計算機外設一樣,接上滑鼠、U盤不用重啟機器就能立即使用。


相關文章