1. 類載入時機
一個型別(介面/類)從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期將歷載入、驗證、準備、解析、初始化、使用和解除安裝七個階段,其中驗證、準備、解析三個部分統稱為連線(Linking)。
載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的,型別的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結特性。請注意,這些階段通常都是互相交叉地混合進行的,會在一個階段執行的過程中呼叫、啟用另一個階段。
關於在什麼情況下需要開始類載入過程的第一個階段“載入”,《Java虛擬機器規範》中並沒有進行強制約束,這點可以交給虛擬機器的具體實現來自由把握。但是對於初始化階段《Java虛擬機器規範》則是嚴格規定了有且只有六種情況必須立即對類進行“初始化”(而載入、驗證、準備自然需要在此之前開始):
- 遇到new、getstatic、putstatic 或invokestatic這四條位元組碼指令時,如果型別沒有進行過初始化,則需要先觸發其初始化階段。能夠生成這四條指令的典型Java程式碼場景有:
- 使用new 關鍵字例項化物件的時候。
- 讀取或設定一個型別的靜態欄位(被final修飾、已在編譯期把結果放人常量池的靜態欄位除外)的時候。
- 呼叫一個型別的靜態方法的時候。
- 使用java.lang.reflect包的方法對型別進行反射呼叫的時候,如果型別沒有進行過初始化,則需要先觸發其初始化。
- 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。(但是一個介面在初始化時,並不要求其父介面全部都完成了初始化在,只有在真正使用到父介面的時候(如引用介面中定義的常量)才會初始化。)
- 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
- 當使用JDK7新加人的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial
四種型別的方法控制代碼,並且這個方法控制代碼對應的類沒有進行過初始化,則需要先觸發其初始化。 - 當一個介面中定義了JDK8新加人的預設方法(被default 關鍵字修飾的介面方法時,如果有這個介面的實現類發生了初始化,那該介面要在其之前被初始化。
2. 類載入的過程
類載入的過程,主要包括了載入,驗證,準備,解析和初始化。
2.1 載入
“載入”階段是整個“類載入”過程中的一個階段。在載入階段,Java虛擬機器需要完成以下三件事情:
1)透過一個類的全限定名來獲取定義此類的二進位制位元組流。
2)將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
3)在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問人口。
載入階段既可以使用Java虛擬機器裡內建的引導類載入器來完成,也可以由使用者自定義的類載入器去完成,開發人員透過定義自己的類載入器去控制位元組流的獲取方式(重寫一個類載入器的findClass( )或loadClass( )方法),實現根據自己的想法來賦予應用程式獲取執行程式碼的動態性。
對於陣列類而言,情況就有所不同,陣列類本身不透過類載入器建立,它是由Java虛擬機器直接在記憶體中動態構造出來的。但陣列類與類載入器仍然有很密切的關係,因為陣列類的元素型別(指的是陣列去掉所有維度的型別)最終還是要靠類載入器完成載入,一個陣列類建立過程遵循以下規則:
-
如果陣列的元件型別(指的是陣列去掉一個維度的型別)是引用型別,那就遞迴採用定義的載入過程去載入這個元件型別,陣列類將被標識在載入該元件型別的類載入器的類名稱空間上
(這點很重要,一個型別必須與類載入器一起確定唯一性)。 -
如果陣列的元件型別不是引用型別(例如int[]陣列的元件型別為int),Java虛擬機器將會把陣列類標記為與引導類載入器關聯。
-
陣列類的可訪問性與它的元件型別的可訪問性一致,如果元件型別不是引用型別,它的陣列類的可訪問性將預設為public,可被所有的類和介面訪問到。
載入階段結束後,Java虛擬機器外部的二進位制位元組流就按照虛擬機器所設定的格式儲存在方法區之中了。型別資料妥善安置在方法區之後,會在Java堆記憶體中例項化一個java.lang.Class類的物件,這個物件將作為程式訪問方法區中的型別資料的外部介面。
載入階段與連線階段的部分動作(如一部分位元組碼檔案格式驗證動作)是交叉進行的,載入階段尚未完成,連線階段可能已經開始,但這些夾在載入階段之中進行的動作,仍然屬於連線階段的一部分,這兩個階段的開始時間仍然保持著固定的先後順序。
2.2 驗證
驗證是連線階段的第一步,這一階段的目的是確保Class 檔案的位元組流中包含的資訊符合《Java虛擬機器規範》的全部約束要求,保證這些資訊被當作程式碼執行後不會危害虛擬機器自身的安全。
驗證階段大致上會完成下面四個階段的檢驗動作:檔案格式驗證,後設資料校驗、位元組碼驗證和符號引用驗證。
-
檔案格式驗證:
第一階段要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。主要目的是保證輸入的位元組流能正確地解析並儲存於方法區之內,格式上符合描述一個Java型別資訊的要求。這階段的驗證是基於二進位制位元組流進行的,只有透過了這個階段的驗證之後,這段位元組流才被允許進入Java虛擬機器記憶體的方法區中進行儲存,所以後面的三個驗證階段全部是基於方法區的儲存結構上進行的,不會再直接讀取、操作位元組流了。 -
後設資料驗證
第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合《Java語言規範》的要求。 -
位元組碼驗證:
第三階段主要目的是透過資料流分析和控制流分析、確定程式語義是合法的、符合邏輯的。在第二階段對後設資料資訊中的資料型別校驗完畢後。這階段就要對類的方法體(Class檔案中的Code屬性)進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的行為。
-
符號引用驗證:
最後一個階段的校驗行為發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三階段--解析中發生。符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類資訊進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪間它依賴的某些外部類、方法、欄位等資源。
驗證階段對於虛擬機器的類載入機制來說,是一個非常重要的、但卻不是必須要執行的階段,因為驗證階段只有透過或者不透過的差別,只要透過了驗證,其後就對程式執行期沒有任何影響了。如果程式執行的全部程式碼(包括自己編寫的、第三方包中的、從外部載入
的、動態生成的等所有程式碼)都已經被反覆使用和驗證過,在生產環境的實施階段就可以考慮使用-Xveify:none 引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。
2.3 準備
準備階段是正式為類中定義的變數(即靜態變數,被static修飾的變數)分配記憶體並設定類變數初始值的階段、從概念上進,這些變數所使用的記憶體都應當在方法區中進行分配,但必須注意到方法區本身是一個邏輯上的區域,在JDK7及之前,HotSpot使用永久代來實現方法區時、實現是完全符合這種邏輯概念的;而在JDK8及之後,類變數則會隨著Class物件一起存放在Java堆中,這時候“類變數在方法區”就完全是一種對邏輯概念的表述了。
這時候進行記憶體分配的僅包括類變數,而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。
這裡所說的初始值“通常情況”下是資料型別的零值,
例外:
對於 public static final int value =123;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue 將value 賦值為 123。
2.4 解析
解析階段是Java虛擬機器將常量池內的符號引用替換為直接引用的過程。
符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定是已經載入到虛報機記憶體當中的內容。各種虛報機實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須都是一致的、因為符號引用的字面量形式明確定義在《Java虛擬機器規範》的Class檔案格式中。
直接引用(DirectReferences):直接引用是可以直接指向目標的指標、相對偏移量或者是一個能間接定位到目標的控制代碼。直接引用是和虛擬機器實現的記憶體佈局直接相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同、如果有了直接引用,那引用的目標必定已經在虛擬機器的記憶體中存在。
對同一個符號引用進行多次解析請求是很常見的事情,除invokedymamic指令以外,虛擬機器實現可以對第一次解析的結果進行快取,譬如在執行時直接引用常量池中的記錄,並把常量標識為已解析狀態,從而避免解析動作重複進行。無論是否真正執行了多次解析動作,Java虛擬機器都需要保證的是在同一個實體中,如果一個符號引用之前已經被成功解材過,那麼後續的引用解析請求就應當一直能夠成功;同樣地,如果第一次解析失敗了。其他指令對這個符號的解析請求也應該收到相同的異常,哪怕這個請求的符號在後來已成功載入進 Java 虛擬機器記憶體之中。
不過對於invokedynamic指令,上面的規則就不成立了。當碰到某個前面已經由imvokedypamic指令觸發過解析的符號引用時,並不意味著這個解析結果對於其他invokedynamic指令也同樣生效。因為invokedynamic指令的目的本來就是用於動態語言支援,它對應的引用稱為“動態呼叫點限定符(Dynamically-Computed CallSite Spceiier)“這裡“動態”的含義是指必須等到程式實際執行到這條指令時,解析動作才能進行。相對地,其餘可觸發解析的指令都是“靜態”的,可以在剛剛完成載入階段,還沒有開始執行程式碼時就提前進行解析
解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符這7類符號引用進行的。
-
類或介面的解析
假設當前程式碼所處的類為D,如果要把一個從未解析過的符號引用N解析為一個類或介面C的直接引用,那虛擬機器完成整個解析的過程需要包括以下3個步驟:
1)如果C不是一個陣列型別,那虛擬機器將會把代表N的全限定名傳遞給D的類載入器去載入這個類C。在載入過程中,由於後設資料驗證、位元組碼驗證的需要,又可能觸發其他相關類的載入動作,例如載入這個類的父類或實現的介面。一旦這個載入過程出現了何異常,解析過程就將宣告失敗。
2)如果C是一個陣列型別,並且陣列的元素型別為物件,那將會按照第一點的規則載入陣列元素型別。接著由虛擬機器生成一個代表該陣列維度和元素的陣列物件。
3)如果上面兩步沒有出現任何異常,那麼C在虛擬機器中實際上已經成為一個有效的類或介面了,但在解析完成前還要進行符號引用驗證,確認D是否具備對C的訪問許可權。如果發現不具備訪問許可權,將丟擲java.lang.llegalAccessError異常。針對上面第3點訪問許可權驗證,在JDK9引人了模組化以後,一個public型別也不意味著程式任何位置都有它的訪問許可權,我們還必須檢查模組間的訪問許可權。
如果我們說一個D擁有C的訪問許可權,那就意味著以下3條規則中至少有其中一條成立- 被訪問類C是public的,並且與訪問類D處於同一個模組。
- 被訪問類C是public的,不與訪問類D處於同一個模組,但是被訪問類C的模組允許被訪問類D的模組進行訪問。
- 被訪問類C不是 public 的,但是它與訪問類D 處於同一個包中,
-
欄位解析
要解析一個未被解析過的欄位符號引用,首先會解析欄位所屬的類或介面的符號引用。如果在解析這個類或介面符號引用的過程中出現了任何異常,都會導致欄位符號引用解析的失敗。如果解析成功完成,那把這個欄位所屬的類或介面用C表示,《Java虛擬機器規範》要求按照如下步驟對C進行後續欄位的搜尋:
1)如果C本身就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
2)否則,如果在C中實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和它的父介面,如果介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
3)否則,如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞迴搜尋其父類、如果在父類中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
4)否則,查詢失敗,丟擲java.lang.NoSuchFieldError異常 如果查詢過程成功返回了引用,將會對這個欄位進行許可權驗證,如果發現不具備對字
段的訪問許可權,將丟擲java.lang.lllegalAccessError異常。 -
方法解析
方法解析的第一個步驟與欄位解析一樣,也是需要先解析出方法所屬的類或介面的符號引用,如果解析成功,那麼我們依然用C表示這個類,接下來虛擬機器將會按照如下步驟進行後續的方法搜尋:
1)由於Class 檔案格式中類的方法和介面的方法符號引用的常量型別定義是分開的,如果在類的方法表中發現是個介面的話,那就直接丟擲java.lang.IncompatibleClassChangeError異常。
2)如果透過了第一步,在類C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
3)否則,在類C的父類中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
4)否則,在類C實現的介面列表及它們的父介面之中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,這時候查詢結束,丟擲java.lang.AbstractMethodError異常。
5)否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError。
最後,如果查詢過程成功返回了直接引用,將會對這個方法進行許可權驗證,如果發不具備對此方法的訪問許可權,將丟擲java.lang.IllegalAccessError 異常。 -
介面方法解析
介面方法也是需要先解析出介面方法所屬的類或介面的符號引用,如果解析成功,依然用C表示這個介面,接下來虛擬機器將會按照如下步驟進行後續的介面方法搜尋:
1)與類的方法解析相反,如果發現C是個類而不是介面,那麼就直接丟擲 java.lang.IneompatibleClassChangeEror 異常。
2)否則,在介面C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
3)否則,在介面C的父介面中遞迴查詢,直到java.lang.Object類(介面方法的查詢範圍也會包括 Object 類中的方法)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
4)對於規則3,由於Java的介面允許多重繼承,如果C的不同父介面中存有多個簡單名稱和描述符都與目標相匹配的方法,那將會從這多個方法中返回其中一個並結束查詢,《Java虛擬機器規範》中並沒有進一步規則約束應該返回哪一個介面方法。但與之前欄位查詢
類似地,不同發行商實現的Javac編譯器有可能會按照更嚴格的約束拒絕編譯這種程式碼來避免不確定性。
5)否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError 異常。在JDK9之前,Java介面中的所有方法都預設是public的,也沒有模組化的訪問約束,所以不存在訪問許可權的問題,介面方法的符號解析就不可能丟擲java.lang.IllegalAccessError異常。但在JDK9中增加了介面的靜態私有方法,也有了模組化的訪問約束,所以從JDK9起,介面方法的訪問也完全有可能因訪問許可權控制而出現java.lang.IllegalAccessError 異常。
2.5 初始化
類的初始化階段是類載入過程的最後一個步驟,之前介紹的幾個類載入的動作裡,除了在載入階段使用者應用程式可以透過自定義類載入器的方式區域性參與外,其餘動作都完全由java虛擬機器來主導控制。直到初始化階段,Java虛擬機器才真正開始執行類中編寫的java程式程式碼,將主導權移交給應用程式。
進行準備階段時,變數已經賦過一次系統要求的初始零值,而在初始化階段,則會根據序員透過程式編碼制定的主觀計劃去初始化類變數和其他資源。我們也可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器clinit( )方法的過程。clinit( )並不是程式設計師在Java程式碼中直接編寫的方法,它是Javac編譯器的自動生成的。
clinit( )方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問。
public class Test{
static{
i=0; // 給變數複製可以正常編譯透過
System.out.print(i); // 這句編譯器會提示“非法向前引用’
}
static int i=1;
}
clinit( )方法與類的建構函式(即在虛擬機器視角中的例項構造器init( )方法)不同,它不需要顯式地呼叫父類構造器,Java虛擬機器會保證在子類的clinit( )方法執行前,父類的clinit( )方法已經執行完畢。因此在Java 虛擬機器中第一個被執行clinit( )方法的型別肯定是java.lang.Object。
由於父類的clinit( )方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。
static class Parent(
public static int A=1;
static{
A = 2;
}
}
static class Sub extends Parent {
public static intB=A;
}
public static void main(string[]args) {
System.out.println(Sub.B); // 是2而不是1。
}
clinit( )方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成clinit( )方法。
介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成clinit( )方法。但介面與類不同的是,執行介面的clinit( )方法不需要先執行父介面的clinit( )方法,因為只有當父介面中定義的變數被使用時,父口才會被初始化。此外,介面的實現類在初始化時也一樣不會執行介面的clinit( )方法。
Java虛擬機器必須保證一個類的clinit( )方法在多執行緒環境中被正確地枷鎖同步,如果多個執行緒同時去初始化一個類,那麼只會有其中一個執行緒去執行這個類的clinit( )方法,其他執行緒都需要阻塞等待,直到活動執行緒執行完clinit( )方法,如果在一個類的clinit()方法中有耗時很長的操作,那就可能造成多個程序阻寒。
// 舉個例子
public class Test {
static class A {
static {
if (true){
System.out.println(Thread.currentThread().getName() + "init class A....");
while (true){
// 耗時操作
}
}
}
}
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread() + "start ...");
A a = new A();
System.out.println(Thread.currentThread() + "end ...");
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
}
}