天天看點

六.類加載的時機機制

Java虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換解析和初始化,最終形成可以被虛拟機直接使用的Java類型,這個過程被稱作虛拟機的類加載機制

1.類加載的時機

一個類型從被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期将會經曆加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為連接配接(Linking)。

六.類加載的時機機制

加載、驗證、準備、初始化和解除安裝這五個階段的順序是确定的,類型的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始, 這是為了支援Java語言的運作時綁定特性(也稱為動态綁定或晚期綁定)。請注意,這裡筆者寫的是按部就班地“開始”,而不是按部就班地“進行”或按部就班地“完成”,強調這點是因為這些階段通常都是互相交叉地混合進行的,會在一個階段執行的過程中調用、激活另一個階段。

        關于在什麼情況下需要開始類加載過程的第一個階段“加載”,《Java虛拟機規範》中并沒有進行強制限制,這點可以交給虛拟機的具體實作來自由把握。但是對于初始化階段,《Java虛拟機規範》則是嚴格規定了有且隻有六種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):

    1.遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令時,如果類型沒有進行過初始化,則需要先觸發其初始化階段。能夠生成這四條指令的典型Java代碼場景有:

          使用new關鍵字執行個體化對象的時候。

         讀取或設定一個類型的靜态字段(被final修飾、已在編譯期把結果放入常量池的靜态字段除外) 的時候。

          調用一個類型的靜态方法的時候。

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

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

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

   5.當使用JDK 7新加入的動态語言支援時,如果一個java.lang.invoke.MethodHandle執行個體最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,并且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。

   6.當一個接口中定義了JDK 8新加入的預設方法(被default關鍵字修飾的接口方法)時,如果有這個接口的實作類發生了初始化,那該接口要在其之前被初始化。

public class SuperClass {

    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}

public class SubClass extends SuperClass {     
    static {
        System.out.println("SubClass init!");
    }
}

/**
*非主動使用類字段示範
**/
public class NotInitialization {

    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }

}
           

      上述代碼運作之後,隻會輸出“SuperClass init!”,而不會輸出“SubClass init!”。對于靜态字段, 隻有直接定義這個字段的類才會被初始化,是以通過其子類來引用父類中定義的靜态字段,隻會觸發父類的初始化而不會觸發子類的初始化。至于是否要觸發子類的加載和驗證階段,在《Java虛拟機規範》中并未明确規定,是以這點取決于虛拟機的具體實作。對于HotSpot虛拟機來說,可通過-XX:+TraceClassLoading參數觀察到此操作是會導緻子類加載的

public class NotInitialization {

public static void main(String[] args) { SuperClass[] sca = new SuperClass[10];
}
           

       運作之後發現沒有輸出“SuperClass init!”,說明并沒有觸發類org.fenixsoft.classloading.SuperClass的初始化階段。但是這段代碼裡面觸發了另一個名為“[Lorg.fenixsoft.classloading.SuperClass”的類的初始化階段,對于使用者代碼來說,這并不是一個合法的類型名稱,它是一個由虛拟機自動生成的、直接繼承于java.lang.Object的子類,建立動作由位元組碼指令newarray觸發

          這個類代表了一個元素類型為org.fenixsoft.classloading.SuperClass的一維數組,數組中應有的屬性 和方法(使用者可直接使用的隻有被修飾為public的length屬性和clone()方法)都實作在這個類裡。Java語言中對數組的通路要比C/C++相對安全,很大程度上就是因為這個類包裝了數組元素的通路[1],而C/C++中則是直接翻譯為對數組指針的移動。在Java語言裡,當檢查到發生數組越界時會抛出java.lang.ArrayIndexOutOfBoundsException異常,避免了直接造成非法記憶體通路

/**
*被動使用類字段示範三:
*常量在編譯階段會存入調用類的常量池中,本質上沒有直接引用到定義常量的類,是以不會觸發定義常量的類的初始化
**/
public class ConstClass {
	static {
		System.out.println("ConstClass init!");
	}
	public static final String HELLOWORLD = "hello world";
}
public class NotInitialization {
public static void main(String[] args) { 
	System.out.println(ConstClass.HELLOWORLD);
}
}
           

上述代碼運作之後,也沒有輸出“ConstClass init!”,這是因為雖然在Java源碼中确實引用了ConstClass類的常量HELLOWORLD,但其實在編譯階段通過常量傳播優化,已經将此常量的值“hello world”直接存儲在NotInitialization類的常量池中,以後NotInitialization對常量ConstClass.HELLOWORLD的引用,實際都被轉化為NotInitialization類對自身常量池的引用了。也就是說,實際上NotInitialization的Class檔案之中并沒有ConstClass類的符号引用入口,這兩個類在編譯成Class檔案後就已不存在任何聯系

         接口的加載過程與類加載過程稍有不同,針對接口需要做一些特殊說明:接口也有初始化過程, 這點與類是一緻的,上面的代碼都是用靜态語句塊“static{}”來輸出初始化資訊的,而接口中不能使 用“static{}”語句塊,但編譯器仍然會為接口生成“<clinit>()”類構造器,用于初始化接口中所定義的成員變量。接口與類真正有所差別的是前面講述的六種“有且僅有”需要觸發初始化場景中的第三種: 當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,隻有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始 化。

2.類加載的過程

   2.1 加載

           “加載”(Loading)階段是整個“類加載”(Class Loading)過程中的一個階段,希望讀者沒有混淆這兩個看起來很相似的名詞。在加載階段,Java虛拟機需要完成以下三件事情

     1) 通過一個類的全限定名來擷取定義此類的二進制位元組流

     2) 将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構

     3) 在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口。

       《Java虛拟機規範》對這三點要求其實并不是特别具體,留給虛拟機實作與Java應用的靈活度都是相當大的。例如“通過一個類的全限定名來擷取定義此類的二進制位元組流”這條規則,它并沒有指明二進制位元組流必須得從某個Class檔案中擷取,确切地說是根本沒有指明要從哪裡擷取、如何擷取。僅僅這一點空隙,Java虛拟機的使用者們就可以在加載階段搭建構出一個相當開放廣闊的舞台,Java發展曆程中,充滿創造力的開發人員則在這個舞台上玩出了各種花樣,許多舉足輕重的Java技術都建立在這一基礎之上,例如:

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

      2.從網絡中擷取,這種場景最典型的應用就是Web Applet。

      3.運作時計算生成,這種場景使用得最多的就是動态代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()來為特定接口生成形式為“*$Proxy”的代理類的二進制位元組流。

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

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

      6.可以從加密檔案中擷取,這是典型的防Class檔案被反編譯的保護措施,通過加載時解密Class檔案來保障程式運作邏輯不被窺探。

       相對于類加載過程的其他階段,非數組類型的加載階段(準确地說,是加載階段中擷取類的二進制位元組流的動作)是開發人員可控性最強的階段。加載階段既可以使用Java虛拟機裡内置的引導類加載器來完成,也可以由使用者自定義的類加載器去完成,開發人員通過定義自己的類加載器去控制位元組流的擷取方式(重寫一個類加載器的findClass()或loadClass()方法),實作根據自己的想法來賦予應用程式擷取運作代碼的動态性。

        對于數組類而言,情況就有所不同,數組類本身不通過類加載器建立,它是由Java虛拟機直接在記憶體中動态構造出來的。但數組類與類加載器仍然有很密切的關系,因為數組類的元素類型(ElementType,指的是數組去掉所有次元的類型)最終還是要靠類加載器來完成加載,一個數組類(下面簡稱為C)建立過程遵循以下規則:

           1.如果數組的元件類型(Component Type,指的是數組去掉一個次元的類型,注意和前面的元素類型區分開來)是引用類型,那就遞歸采用本節中定義的加載過程去加載這個元件類型,數組C将被辨別在加載該元件類型的類加載器的類名稱空間上

          2.如果數組的元件類型不是引用類型(例如int[]數組的元件類型為int),Java虛拟機将會把數組C标記為與引導類加載器關聯。

          3.數組類的可通路性與它的元件類型的可通路性一緻,如果元件類型不是引用類型,它的數組類的可通路性将預設為public,可被所有的類和接口通路到。

          加載階段結束後,Java虛拟機外部的二進制位元組流就按照虛拟機所設定的格式存儲在方法區之中了,方法區中的資料存儲格式完全由虛拟機實作自行定義,《Java虛拟機規範》未規定此區域的具體資料結構。類型資料妥善安置在方法區之後,會在Java堆記憶體中執行個體化一個java.lang.Class類的對象, 這個對象将作為程式通路方法區中的類型資料的外部接口。

         加載階段與連接配接階段的部分動作(如一部分位元組碼檔案格式驗證動作)是交叉進行的,加載階段尚未完成,連接配接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬于連接配接階段的一部分,這兩個階段的開始時間仍然保持着固定的先後順序。

2.2驗證

        驗證是連接配接階段的第一步,這一階段的目的是確定Class檔案的位元組流中包含的資訊符合《Java虛拟機規範》的全部限制要求,保證這些資訊被當作代碼運作後不會危害虛拟機自身的安全\

   驗證階段大緻上會完成下面四個階段的檢驗動作:

       檔案格式驗證:第一階段要驗證位元組流是否符合Class檔案格式的規範,并且能被目前版本的虛拟機處理

       中繼資料驗證:第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合《Java語言規範》的要求

       位元組碼驗證:第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過資料流分析和控制流分析,确定程式語義是合法的、符合邏輯的。在第二階段對中繼資料資訊中的資料類型校驗完畢以後,這階段就要對類的方法體(Class檔案中的Code屬性)進行校驗分析,保證被校驗類的方法在運作時不會做出危害虛拟機安全的行為

       符号引用驗證:最後一個階段的校驗行為發生在虛拟機将符号引用轉化為直接引用的時候,這個轉化動作将在連接配接的第三階段——解析階段中發生。符号引用驗證可以看作是對類自身以外(常量池中的各種符号引用)的各類資訊進行比對性校驗,通俗來說就是,該類是否缺少或者被禁止通路它依賴的某些外部類、方法、字段等資源

2.2準備

          準備階段是正式為類中定義的變量(即靜态變量,被static修飾的變量)配置設定記憶體并設定類變量初始值的階段,從概念上講,這些變量所使用的記憶體都應當在方法區中進行配置設定,但必須注意到方法區本身是一個邏輯上的區域,在JDK 7及之前,HotSpot使用永久代來實作方法區時,實作是完全符合這種邏輯概念的;而在JDK 8及之後,類變量則會随着Class對象一起存放在Java堆中,這時候“類變量在方法區”就完全是一種對邏輯概念的表述了,關于這部分内容

關于準備階段,還有兩個容易産生混淆的概念筆者需要着重強調,首先是這時候進行記憶體配置設定的僅包括類變量,而不包括執行個體變量,執行個體變量将會在對象執行個體化時随着對象一起配置設定在Java堆中。其次是這裡所說的初始值“通常情況”下是資料類型的零值,假設一個類變量的定義為

public static int value = 123;

那變量value在準備階段過後的初始值為0而不是123,因為這時尚未開始執行任何Java方法,而把value指派為123的putstatic指令是程式被編譯後,存放于類構造器<clinit>()方法之中,是以把value指派為123的動作要到類的初始化階段才會被執

    如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量值就會被初始化為ConstantValue屬性所指定的初始值,假設上面類變量value的定義修改為:

public static final int value = 123;

編譯時Javac将會為value生成ConstantValue屬性,在準備階段虛拟機就會根據Con-stantValue的設定将value指派為123

2.2解析

        解析階段是Java虛拟機将常量池内的符号引用替換為直接引用的過程,符号引用在在Class檔案中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現

       符号引用(Symbolic References):符号引用以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時能無歧義地定位到目标即可。符号引用與虛拟機實作的記憶體布局無關,引用的目标并不一定是已經加載到虛拟機記憶體當中的内容。各種虛拟機實作的記憶體布局可以各不相同, 但是它們能接受的符号引用必須都是一緻的,因為符号引用的字面量形式明确定義在《Java虛拟機規範》的Class檔案格式中

       直接引用(Direct References):直接引用是可以直接指向目标的指針、相對偏移量或者是一個能間接定位到目标的句柄。直接引用是和虛拟機實作的記憶體布局直接相關的,同一個符号引用在不同虛拟機執行個體上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目标必定已經在虛拟機的記憶體中存在

     《Java虛拟機規範》之中并未規定解析階段發生的具體時間,隻要求了在執行ane-warray、checkcast 、 getfield 、 getstatic 、 instanceof 、 invokedynamic 、 invokeinterface 、 invoke-special 、 invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic這17個用于操作符号引用的位元組碼指令之前,先對它們所使用的符号引用進行解析。是以虛拟機實作可以根據需要來自行判斷,到底是在類被加載器加載時就對常量池中的符号引用進行解析,還是等到一個符号引用将要被使用前才去解析它。

      類似地,對方法或者字段的通路,也會在解析階段中對它們的可通路性(public、protected、private、<package>)進行檢查,至于其中的限制規則已經是Java語言的基本常識,筆者就不再贅述了。

對同一個符号引用進行多次解析請求是很常見的事情,除invokedynamic指令以外,虛拟機實作可以對第一次解析的結果進行緩存,譬如在運作時直接引用常量池中的記錄,并把常量辨別為已解析狀态,進而避免解析動作重複進行。無論是否真正執行了多次解析動作,Java虛拟機都需要保證的是在同一個實體中,如果一個符号引用之前已經被成功解析過,那麼後續的引用解析請求就應當一直能夠成功;同樣地,如果第一次解析失敗了,其他指令對這個符号的解析請求也應該收到相同的異常,哪怕這個請求的符号在後來已成功加載進Java虛拟機記憶體之中。

不過對于invokedynamic指令,上面的規則就不成立了。當碰到某個前面已經由invokedynamic指令觸發過解析的符号引用時,并不意味着這個解析結果對于其他invokedynamic指令也同樣生效。因為invokedynamic指令的目的本來就是用于動态語言支援[1],它對應的引用稱為“動态調用點限定符(Dynamically-Computed Call Site Specifier)”,這裡“動态”的含義是指必須等到程式實際運作到這條指令時,解析動作才能進行。相對地,其餘可觸發解析的指令都是“靜态”的,可以在剛剛完成加載階段,還沒有開始執行代碼時就提前進行解析。

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符這7 類符号引用進行,分别對應于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、

CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8種常量類型。下面筆者将講解前4種引用的解析過程,對于後4 種,它們都和動态語言支援密切相關,由于Java語言本身是一門靜态類型語言,在沒有講解清楚invokedynamic指令的語意之前,我們很難将它們直覺地和現在的Java語言文法對應上

    2.2.1 類或接口的解析

假設目前代碼所處的類為D,如果要把一個從未解析過的符号引用N解析為一個類或接口C的直接引用,那虛拟機完成整個解析的過程需要包括以下3個步驟:

  1. 如果C不是一個數組類型,那虛拟機将會把代表N的全限定名傳遞給D的類加載器去加載這個類C。在加載過程中,由于中繼資料驗證、位元組碼驗證的需要,又可能觸發其他相關類的加載動作,例如加載這個類的父類或實作的接口。一旦這個加載過程出現了任何異常,解析過程就将宣告失敗。 
  2. 如果C是一個數組類型,并且數組的元素類型為對象,也就是N的描述符會是類似“[Ljava/lang/Integer”的形式,那将會按照第一點的規則加載數組元素類型。如果N的描述符如前面所假設的形式,需要加載的元素類型就是“java.lang.Integer”,接着由虛拟機生成一個代表該數組次元和元素的數組對象。
  3. 如果上面兩步沒有出現任何異常,那麼C在虛拟機中實際上已經成為一個有效的類或接口了, 但在解析完成前還要進行符号引用驗證,确認D是否具備對C的通路權限。如果發現不具備通路權限, 将抛出java.lang.IllegalAccessError異常。

針對上面第3點通路權限驗證,在JDK 9引入了子產品化以後,一個public類型也不再意味着程式任何位置都有它的通路權限,我們還必須檢查子產品間的通路權限。

如果我們說一個D擁有C的通路權限,那就意味着以下3條規則中至少有其中一條成立:

  • 被通路類C是public的,并且與通路類D處于同一個子產品。
  • 被通路類C是public的,不與通路類D處于同一個子產品,但是被通路類C的子產品允許被通路類D的子產品進行通路。
  • 被通路類C不是public的,但是它與通路類D處于同一個包中。

 2.2.2字段解析

        要解析一個未被解析過的字段符号引用,首先将會對字段表内class_index項中索引的CONSTANT_Class_info符号引用進行解析,也就是字段所屬的類或接口的符号引用。如果在解析這個類或接口符号引用的過程中出現了任何異常,都會導緻字段符号引用解析的失敗。如果解析成功完 成,那把這個字段所屬的類或接口用C表示,《Java虛拟機規範》要求按照如下步驟對C進行後續字段的搜尋:

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

如果查找過程成功傳回了引用,将會對這個字段進行權限驗證,如果發現不具備對字段的通路權限,将抛出java.lang.IllegalAccessError異常。

以上解析規則能夠確定Java虛拟機獲得字段唯一的解析結果,但在實際情況中,Javac編譯器往往會采取比上述規範更加嚴格一些的限制,譬如有一個同名字段同時出現在某個類的接口和父類當中, 或者同時在自己或父類的多個接口中出現,按照解析規則仍是可以确定唯一的通路字段,但Javac編譯器就可能直接拒絕其編譯為Class檔案。在代碼清單7-4中示範了這種情況,如果注釋了Sub類中的“public static int A=4;”,接口與父類同時存在字段A,那Oracle公司實作的Javac編譯器将提示“The field Sub.A is ambiguous”,并且會拒絕編譯這段代碼。

2.2.3方法解析

方法解析的第一個步驟與字段解析一樣,也是需要先解析出方法表的class_index[4]項中索引的方法所屬的類或接 口的符号引用,如果解析成功,那麼我們依然用C表示這個類,接下來虛拟機将會按照如下步驟進行後續的方法搜尋:

  1. 由于Class檔案格式中類的方法和接口的方法符号引用的常量類型定義是分開的,如果在類的方法表中發現class_index中索引的C是個接口的話,那就直接抛出java.lang.IncompatibleClassChangeError 異常。
  2. 如果通過了第一步,在類C中查找是否有簡單名稱和描述符都與目标相比對的方法,如果有則傳回這個方法的直接引用,查找結束。
  3. 否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目标相比對的方法,如果有則傳回這個方法的直接引用,查找結束。
  4. 否則,在類C實作的接口清單及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目标相比對的方法,如果存在比對的方法,說明類C是一個抽象類,這時候查找結束,抛出java.lang.AbstractMethodError異常。
  5. 否則,宣告方法查找失敗,抛出java.lang.NoSuchMethodError。

最後,如果查找過程成功傳回了直接引用,将會對這個方法進行權限驗證,如果發現不具備對此方法的通路權限,将抛出java.lang.IllegalAccessError異常。

2.2.13初始化

         類的初始化階段是類加載過程的最後一個步驟,之前介紹的幾個類加載的動作裡,除了在加載階段使用者應用程式可以通過自定義類加載器的方式局部參與外,其餘動作都完全由Java虛拟機來主導控制。直到初始化階段,Java虛拟機才真正開始執行類中編寫的Java程式代碼,将主導權移交給應用程式。

        進行準備階段時,變量已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程式員通過程式編碼制定的主觀計劃去初始化類變量和其他資源。我們也可以從另外一種更直接的形式來表 達:初始化階段就是執行類構造器<clinit>()方法的過程。<clinit>()并不是程式員在Java代碼中直接編寫的方法,它是Javac編譯器的自動生成物,但我們非常有必要了解這個方法具體是如何産生的,以及<clinit>()方法執行過程中各種可能會影響程式運作行為的細節,這部分比起其他類加載過程更貼近于普通的程式開發人員的實際工作

         <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>()方法先執行,也就意味着父類中定義的靜态語句塊要優先于子類的變量指派操作,如代碼清單,字段B的值将會是2而不是1。
static class Parent {
    
public static int A = 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);
}
           
  • <clinit>()方法對于類或接口來說并不是必需的,如果一個類中沒有靜态語句塊,也沒有對變量的指派操作,那麼編譯器可以不為這個類生成<clinit>()方法。
  • 接口中不能使用靜态語句塊,但仍然有變量初始化的指派操作,是以接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法, 因為隻有當父接口中定義的變量被使用時,父接口才會被初始化。此外,接口的實作類在初始化時也一樣不會執行接口的<clinit>()方法。

Java虛拟機必須保證一個類的<clinit>()方法在多線程環境中被正确地加鎖同步,如果多個線程同時去初始化一個類,那麼隻會有其中一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行完畢<clinit>()方法。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個程序阻塞[2],在實際應用中這種阻塞往往是很隐蔽的。

static class DeadLoopClass { 

    static {
    // 如果不加上這個if語句,編譯器将提示“Initializer does not complete normally”并拒絕編譯
    
        if (true) {
            System.out.println(Thread.currentThread() + "init DeadLoopClass"); 

        while (true) {}
        
        }
    }
}

public static void main(String[] args) {
     Runnable script = new Runnable() {
        public void run() {
         System.out.println(Thread.currentThread() + "start");
         DeadLoopClass dlc = new DeadLoopClass();
        System.out.println(Thread.currentThread() + " run over");
    }
};

Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script); 
thread1.start();
thread2.start();
}
           

但如果執行<clinit>()方法的那條線程退出<clinit>()方法後,其他線程喚醒後則不會再次進入<clinit>()方法。同一個類加載器下,一個類型隻會被初始化一次。