1. 程式人生 > >JVM----類載入的過程

JVM----類載入的過程

本篇來自周志明的<<深入理解java虛擬機器>>

上一篇介紹了類載入的時機:https://blog.csdn.net/q5706503/article/details/85012392

這篇詳細講解一下 Java 虛擬機器中類載入的全過程,也即是載入、驗證、準備、解析和初始化這 5 個階段所執行的具體動作

載入

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

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
  • 將這個位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構。
  • 在記憶體中生成一個代表這個類的 java.lang.Class 物件,作為方法區這個類的各種資料的訪問入口。

虛擬機器規範的這 3 點要求其實並不算具體,因此虛擬機器實現與具體應用的靈活度都是相當大的。例如 “通過一個類的全限定名來獲取定義此類的二進位制位元組流” 這條,它沒有指明二進位制位元組流要從一個 Class 檔案中獲取,準確地說是根本沒有指明要從哪裡獲取、怎樣獲取。虛擬機器設計團隊在載入階段搭建了一個相當開放的、廣闊的 “舞臺”,Java 發展歷程中,充滿創造力的開發人員則再這個 “舞臺” 上玩出了各種花樣,許多舉足輕重的 Java 技術都建立在這一基礎之上,例如:

  • 從 ZIP 包中讀取,這很常見,最終成為日後 JAR、EAR、WAR 格式的基礎。
  • 從網路中獲取,這種常見最典型的應用就是 Applet。
  • 執行時計算生成,這種常見使用得最多的就是動態代理技術,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 來為特定介面生成形式為 “*$Proxy” 的代理類的二進位制位元組流。
  • 由其他檔案生成,典型場景是 JSP 應用,即由 JSP 檔案生成對應的 Class 類。
  • 從資料庫中讀取,這種場景相對少見些,例如有些中介軟體伺服器(如 SAP Netweaver)可以選擇把程式安裝到資料庫中來完成程式程式碼在叢集間的分發。
  • .......

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

        對於陣列類而言,情況就有所不同,陣列類本身不通過類載入器建立,它是由 Java 虛擬機器直接建立的。但陣列類與類載入器仍然有很密切的關係,因為陣列類的元素型別(Element Type,指的是陣列去掉所有緯度的型別)最終是要考類載入器去建立,一個數組類(下面簡稱為 C)建立過程要遵循以下規則:

  • 如果陣列的元件型別(Component Type,指的是陣列去掉一個緯度的型別)是引用型別,那就遞迴採用本節中定義的載入過程去載入這個元件型別,陣列 C 將在載入該元件型別的類載入器的類名稱空間上被標識(這點很重要,一個類必須與類載入器一起確定唯一性)。
  • 如果陣列的元件型別不是引用型別(例如 int[] 陣列),Java 虛擬機器將會把陣列 C 標記為與引導類載入器關聯。
  • 陣列類的可見性與它的元件型別的可見性一直,如果元件型別不是引用型別,那陣列類的可見性將預設為 public。

        載入階段完成後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,方法區中的資料儲存格式由虛擬機器實現自行定義,虛擬機器規範未規定此區域的具體資料結構。然後在記憶體中例項化一個 java.lang.Class 類的物件(並沒有明確規定是在 Java 堆中,對於 HotSpot 虛擬機器而言,Class 物件比較特殊,它雖然是物件,但是存放在方法區裡面),這個物件將作為程式訪問方法區中的這些型別資料的外部介面。

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

驗證

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

        Java 語言本身是相對安全的語言(依然是相對於 C/C++ 來說),使用純粹的 Java 程式碼無法做到諸如訪問陣列邊界以外的資料、將一個物件轉型為它並未實現的型別、跳轉到不存在的程式碼行之類的事情,如果這樣做了,編譯器將拒絕編譯。但前面已經說過,Class 檔案並不一定要求用 Java 原始碼編譯而來,可以使用任何途徑產生,甚至包括用十六進位制編輯器直接編寫來產生 Class 檔案。在位元組碼語言層面上,上述 Java 程式碼無法做到的事情都是可以實現的,至少語義上是可以表達出來的。虛擬機器如果不檢查輸入的位元組流,對其完全信任的話,很可能會因為載入了有害的位元組流而導致系統崩潰,所以驗證是虛擬機器對自身保護的一項重要工作。

        驗證階段是非常重要的,這個階段是否嚴謹,直接決定了 Java 虛擬機器是否能承受惡意程式碼的攻擊,從執行效能的角度上講,驗證階段的工作量在虛擬機器的類載入子系統中又佔了相當大的一部分。《Java 虛擬機器規範(第 2 版)》對這個階段的限制、知道還是比較籠統的,規範中列舉了一些 Class 檔案格式中的靜態和結構化約束,如果驗證到輸入的位元組流不符合 Class 檔案格式的約束,虛擬機器就應丟擲一個 java.lang.VerifyError 異常或其子類異常,但具體應當檢查哪些方面,如何檢查,何時檢查,都沒有足夠具體的要求和明確的說明。知道 2011 年釋出的《Java 虛擬機器規範(Java SE 7 版)》,大幅增加了描述驗證過程的篇幅(從不到 10 頁增加到 130 頁),這時約束和驗證規則才變得具體起來。受篇幅所限,無法逐條規則去講解,但從整體上看,驗證階段大致上會完成下面 4 個階段的檢驗動作:檔案格式驗證、元資料驗證、位元組碼驗證、符號引用驗證。

1. 檔案格式驗證

        第一階段要驗證位元組流是否符合 Class 檔案格式的規範,並且能被當前版本的虛擬機器處理。這一階段可能包括下面這些驗證點:

  • 是否以魔數 0xCAFEBABE 開頭。
  • 主、次版本號是否在當前虛擬機器處理範圍之內。
  • 常量池的常量中是否有不被支援的常量型別(檢查常量 tag 標誌)。
  • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 編碼的資料。
  • Class 檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊。
  • ......

        實際上,第一階段的驗證點還遠不止這些,上面這些知識從 HotSpot 虛擬機器中摘抄的一小部分內容,該驗證階段的主要目的是保證輸入的位元組流能正確地解析並存儲於方法區之內,格式上符合描述一個 Java 型別資訊的要求。這階段的驗證是基於二進位制位元組流進行的,只有通過了這個階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,所以後面 3 個驗證階段全部是基於方法區的儲存結構進行的,不會再直接操作位元組流。

2.元資料驗證

        第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合 Java 語言規範的要求,這個階段可能包括的驗證點如下:

  • 這個類是否有父類(除了 java.lang.Object 之外,所有的類都應當有父類)。
  • 這個類的父類是否繼承了不允許被繼承的類(被 final 修飾的類)。
  • 如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法。
  • 類中的欄位、方法是否與父類產生矛盾(例如覆蓋了父類的 final 欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等)。
  • ……

        第二階段的主要目的是對類的元資料資訊進行語義校驗,保證不存在不符合Java 語言規範的元資料資訊。

3.位元組碼驗證

        第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。在第二階段對元資料資訊中的資料型別做完校驗後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件。例如:

  • 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放置了一個 int 型別的資料,使用時卻按 long 型別來載入入本地變量表中。
  • 保證跳轉指令不會跳轉到方法體以外的位元組碼指令上。
  • 保證方法體中的型別轉換是有效的,例如可以把一個子類物件賦值給父類資料型別,這是安全的,但是把父類物件賦值給子類資料型別,甚至把物件賦值給與它毫無繼承關係、完全不相干的一個數據型別,則是危險和不合法的。

……

        如果一個類方法體的位元組碼沒有通過位元組碼驗證,那肯定是有問題的;但如果一個方法體通過了位元組碼驗證,也不能說明其一定就是安全的。即使位元組碼驗證之中進行了大量的檢查,也不能保證這一點。這裡涉及了離散數學中一個很著名的問題 “Halting Problem”(停機問題):通俗一點的說法就是,通過程式去校驗程式邏輯是無法做到絕對準確的——不能通過程式準確地檢查出程式是否能在有限的時間之內結束執行。

        由於資料流驗證的高複雜性,虛擬機器設計團隊為了避免過多的時間消耗在位元組碼驗證階段,在 JDK 1.6 之後的 javac 編譯器和 Java 虛擬機器中進行了一項優化,給方法體的 Code 屬性的屬性表中增加了一項名為 “StackMapTable” 的屬性,這項屬性描述了方法體中所有的基本快(Basic Block,按照控制流拆分的程式碼塊)開始時本地變量表和操作棧應有的狀態,在位元組碼驗證期間,就不需要根據程式推導這些狀態的合法性,只需要檢查 StackMapTable 屬性中的記錄是否合法即可。這樣講位元組碼驗證的型別推導轉變為型別檢查從而節省一些時間。

        理論上 StackMapTable 屬性也存在錯誤或被篡改的可能,所以是否有可能在惡意篡改了 Code 屬性的同時,也生成相應的 StackMapTable 屬性來騙過虛擬機器的型別校驗則是虛擬機器設計者值得思考的問題。

        在 JDK 1.6 的 HotSpot 虛擬機器中提供了 -XX:-UseSplitVerifier 選項來關閉這項優化,或者使用引數 -XX:+FailOverToOldVerifier 要求在型別校驗失敗的時候退回到舊的型別推導方式進行校驗。而在 JDK 1.7 之後,對於主版本大於 50 的 Class 檔案,使用型別檢查來完成資料流分析則是唯一的選擇,不允許再退回到型別推導的校驗方式。

4.符號引用驗證

        最後一個階段的校驗發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉換動作將在連線的第三個階段——解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,通常需要校驗下列內容:

  • 符號引用中通過字串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位。
  • 符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可被當前類訪問。

……

        符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那麼將會丟擲一個 java.lang.IncompatibleClassChangeError 異常的子類,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。

        對於虛擬機器的類載入機制來說,驗證階段是一個非常重要的、但不是一定必要(因為對程式執行期沒有影響)的階段。如果所執行的全部程式碼(包括自己編寫的及第三方包中的程式碼)都已經被反覆使用和驗證過,那麼在實施階段就可以考慮使用-Xverify:none 引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。

準備

        準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先,這時候進行記憶體分配的僅包括類變數(被 static 修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在 Java 堆中。其次,這裡所說的初始值 “通常情況” 下是資料型別的零值,假設一個類變數的定義為:

public static int value = 123;

那變數 value 在準備階段過後的初始值為 0 而不是 123,因為這時候尚未開始執行任何 Java 方法,而把 value 賦值為 123 的 putstatic 指令是程式被編譯後,存放於類構造器 <clinit>() 方法之中,所以把 value 賦值為 123 的動作將在初始化階段才會執行。表 7-1 列出了 Java 中所有基本資料型別的零值。

 

       上面提到,在 “通常情況” 下初始值是零值,那相對的會有一些 “特殊情況”:如果類欄位屬性表中存在 ConstantValue 屬性,那在準備階段變數 value 就會被初始化為 ConstantValue 屬性所指定的值,假設上面類變數 value 的定義變為: 

public static <strong>final </strong>int value = 123;

 編譯時 javac 將會為 value 生成 ConstantValue 屬性,在準備階段虛擬機器就會根據 ConstantValue 的設定將 value 複製為 123。

解析

解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程,在 Class 檔案中符號引用它以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等型別的常量出現,那解析階段中所說的直接引用與符號引用又有什麼關聯呢?

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在 Java 虛擬機器規範的 Class 檔案格式中。
  • 直接引用(Direct References):直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。

        虛擬機器規範之中並未規定解析階段發生的具體時間,只要求了在執行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield 和 putstatic 這 16 個用於操作符號引用的位元組碼指令之前,先對它們所使用的符號引用進行解析。所以虛擬機器實現可以根據需要來判斷到底是在類被載入器載入時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。

        對同一個符號引用進行多次解析請求是很常見的事情,除 invokedynamic 指令以外,虛擬機器實現可以對第一次解析的結果進行快取(在執行時常量池中記錄直接引用,並把常量標識為已解析狀態)從而避免解析動作重複進行。無論是否真正執行了多次解析動作,虛擬機器需要保證的是在同一個實體中,如果一個符號引用之前已經被成功解析過,那麼後續的引用解析請求就應當一直成功;同樣的,如果第一次解析失敗了,那麼其他指令對這個符號的解析請求也應該受到相同的異常。

        對於 invokedynamic 指令,上面規則則不成立。當碰到某個前面已經由 invokedynamic 指令觸發過解析的符號引用時,並不意味著這個解析結果對於其他 invokedynamic 指令也同樣生效。因為 invokedynamic 指令的目的本來就是用於動態語言支援(目前僅使用 Java 語言不會生成這條位元組碼指令),它所對應的引用稱為 “動態呼叫點限定符”(Dynamic Call Site Specifier),這裡 “動態” 的含義就是必須等到程式實際執行到這條指令的時候,解析動作才能進行。相對的,其餘可觸發解析的指令都是 “靜態” 的,可以在剛剛完成載入階段,還沒有開始執行程式碼時就進行解析。

        解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符 7 類符號引用進行,分別對應於常量池的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info 和 CONSTANT_InvokeDynamic_info7 種常量型別。下面將講解前面 4 種應用的解析過程。

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 的訪問許可權。如果發現不具備訪問許可權,將丟擲 java.lang.IllegalAccessError 異常。

2.欄位解析

        要解析一個未被解析過的欄位符號引用,首先將會對欄位表內 class_index 項中索引的 CONSTANT_Class_info 符號引用進行解析,也就是欄位所屬的類或介面的符號引用。如果在解析這個類或介面符號引用的過程中出現了任何異常,都會導致欄位符號引用解析的失敗。如果解析成功完成,那將這個欄位所屬的類或介面用 C 表示,虛擬機器規範要求按照如下步驟對 C 進行後續欄位的搜尋。

  1.   如果 C 本身就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
  2.   否則,如果在 C 中實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和它的父介面,如果介面中包含了簡單名稱和欄位描述符與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
  3.   否則,如果 C 不是 java.lang.Object 的話,將會按照繼承關係從下往上遞迴搜尋其父類,如果在父類中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束。
  4.   否則,查詢失敗,丟擲 java.lang.NoSuchFieldError 異常。

        如果查詢過程成功返回了引用,將會對這個欄位進行許可權驗證,如果發現不具備對欄位的訪問許可權,將丟擲 java.lang.IllegalAccessError 異常。

        在實際應用中,虛擬機器的編譯器實現可能會比上述規範要求得更加嚴格一些,如果有一個同名欄位同時出現在 C 的介面和父類中,或者同時在自己或父類的多個介面中出現,那編譯器將可能拒絕編譯。在程式碼清單 7-4 中,如果註釋了 Sub 類中的 “public static int A=4;”,介面與父類同時存在欄位 A,那編譯器將提示 “The field Sub.A is ambiguous”,並且拒絕編譯這段程式碼。

程式碼清單 7-4  欄位解析

package org.fenixsoft.classloading;
 
public class FieldResoulution {
	
	interface Interface0 {
		int A = 0;
	}
	
	interface Interface1 extends Interface0 {
		int A = 1;
	}
	
	interface Interface2 {
		int A = 2;
	}
	
	static class Parent implements Interface1 {
		public static int A = 3;
	}
	
	static class Sub extends Parent implements Interface2 {
		public static int A = 4;
	}
	
	public static void main(String[] args) {
		System.out.println(Sub.A);
	}
}

3.類方法解析

        類方法解析的第一個步驟與欄位解析一樣,也需要先解析出類方法表的 class_index 項中索引的方法所屬的類或介面的符號引用,如果解析成功,我們依然用 C 表示這個類,接下來虛擬機器將會按照如下步驟進行後續的類方法搜尋。

  1.   類方法和介面方法符號引用的常量型別定義是分開的,如果在類方法表中發現 class_index 中索引的 C 是個介面,那就直接丟擲 java.lang.IncompatibleClassChangeError 異常。
  2.   如果通過了第 1 步,在類 C 中查詢是否有簡單名稱和描述符都有目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
  3.   否則,在類 C 的父類中遞迴查詢是否有簡單名稱和描述符都有目標想匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
  4.   否則,在類 C 實現的介面列表及它們的父介面之中遞迴查詢是否有簡單名稱和描述符都有與目標相匹配的方法,如果存在匹配的方法,說明類 C 是一個抽象類,這時查詢結束,丟擲 java.lang.AbstractMethodError 異常。
  5.   否則,宣告方法查詢失敗,丟擲 java.lang.NoSuchMethodError。

        最後,如果查詢過程成功返回了直接引用,將會對這個方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,將丟擲 java.lang.IllegalAccessError 異常。

4.介面方法解析

        介面方法也需要先解析出介面方法表的 class_index 項中索引的方法所屬的類或介面的符號引用,如果解析成功,依然用 C 表示這個介面,接下來虛擬機器將會按照如下步驟進行後續的介面方法搜尋。

  1.   與類方法解析不同,如果在介面方法表中發現 class_index 中索引 C 是個類而不是介面,那就直接丟擲 java.lang.IncompatibleClassChangeError 異常。
  2.   否則,在介面 C 中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
  3.   否則,在介面 C 的父介面中遞迴查詢,知道 java.lang.Object 類(查詢範圍會包括 Object 類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
  4.   否則,宣告方法查詢失敗,丟擲 java.lang.NoSuchMethodError 異常。

        由於介面中的所有方法預設都是 public 的,所以不存在訪問許可權的問題,因此介面方法的符號解析應當不會丟擲 java.lang.IllegalAccessError 異常。

 

初始化

        類初始化階段是類載入過程的最後一步,前面的類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘動作完全由虛擬機器主導和控制。到了初始化階段,才真正開始執行類中定義的 Java 程式程式碼(或者說是位元組碼)。

        在準備階段,變數已經賦過一次系統要求的初始值,而在初始化階段,則根據程式設計師通過程式指定的主觀計劃去初始化類變數和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器 <clinit>() 方法的過程。我們在下文會講解 <clinit>() 方法是怎麼生成的,在這裡,我們先看一下 <clinit>() 方法執行過程中一些可能會影響程式執行行為的特點和細節,這部分相對於更貼近普通的程式開發人員。

  • <clinit>() 方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{} 塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問,如程式碼清單 7-5 中的例子所示。
  • 程式碼清單 7-5  非法向前引用變數
    public class Test {
     
    	static {
    		i = 0;			// 給變數賦值可以正常編譯通過
    		System.out.println(i);	// 這句編譯器會提示“非法向前引用”
    	}
    	static int i = 1;
    }
    
  • <clinit>() 方法與類的建構函式(或者說例項構造器 <init>() 方法)不同,它不需要顯示地呼叫父類構造器,虛擬機器會保證在子類的 <clinit>() 方法執行之前,父類的 <clinit>() 方法已經執行完畢。因此在虛擬機器中第一個被執行的 <clinit>() 方法的類肯定是 java.lang.Object。
  • 由於父類的 <clinit>() 方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作,如在程式碼清單 7-6 中,欄位 B 的值將會是 2 而不是 1。
  • 程式碼清單 7-6  <clinit>() 方法執行順序
    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>() 方法。
  • 虛擬機器會保證一個類的 <clinit>() 方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的 <clinit>() 方法,其他執行緒都需要阻塞等待,知道活動執行緒執行 <clinit>() 方法完畢。如果在一個類的 <clinit>() 方法中有耗時很長的操作,就可能造成多個程序阻塞(需要注意的是,其他執行緒雖然會被阻塞,但如果執行 <clinit>() 方法的那條執行緒退出 <clinit>() 方法後,其他執行緒喚醒之後不會再次進入 <clinit>() 方法。同一個型別只會初始化一次),在實際引用中這種阻塞往往是很隱蔽的。程式碼清單 7-7 演示了這種場景。

程式碼清單 7-7  欄位解析

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();
}

 執行結果如下,即一條執行緒在死迴圈以模擬長時間操作,另外一條執行緒在阻塞等待。