[深入理解Java虛擬機器]第七章 類載入的過程

Coding-lover發表於2015-10-24

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

載入

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

  • 1 )通過一個類的全限定名來獲取定義此類的二進位制位元組流。
  • 2 ) 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
  • 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將在載入該元件型別的類載入器的類名稱空間上被標識(這點很重要,在7.4節會介紹到,一個類必須與類載入器一起確定唯一性)。
  • 如果陣列的元件型別不是引用型別(例如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屬性來騙過虛擬機器的型別校驗則是虛擬機器設計者值得思考的問題。

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

4.符號引用驗證

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

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

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

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

停機問題就是判斷任意一個程式是否會在有限的時間之內結束執行的問題。如果這個問題可以在有限的時間之內解決,可以有一個程式判斷其本身是否會停機並做出相反的行為。這時候顯然不管停機問題的結果是什麼都不會符合要求,所以這是一個不可解的問題。具體的證明過程可參考:http://zh.wikipedia.org/zh/停機問題。

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先 ,這時候進行記憶體分配的僅包括類變數(被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 final int value=123;

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

解析

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

符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同 ,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。

直接引用(DirectReferences): 直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。

虛擬機器規範之中並未規定解析階段發生的具體時間,只要求了在執行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_info 7種常量型別。下面將講解前面4種引用的解析過程,對於後面3種 ,與JDK 1.7新增的動態語言支援息息相關,由於Java語言是一門靜態型別的語言,因此在沒有介紹invokedynamic指令的語義之前,沒有辦法將它們和現在的Java語言對應上,筆者將在第8章介紹動態語言呼叫時一起分析講解。

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 FieldResolution {

    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.langIllegalAccessError異常。

初始化

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

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

<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的 ,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問,如程式碼清單7-5中的例子所示。

public class Test {
    static {
        i = 0;  //  給變數複製可以正常編譯通過
        System.out.print(i);  // 這句編譯器會提示“非法向前引用”  
    }
    static int i = 1;
}

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

由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作,如在程式碼清單7-6中 ,欄位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>() 方法。

虛擬機器會保證一個類的<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();
}

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


Thread[Thread-0 ,5 ,main]start 
Thread[Thread-1 ,5 ,main]start 
Thread[Thread-0 ,5 ,main]init DeadLoopClass

需要注意的是,其他執行緒雖然會被阻塞,但如果執行<clinit>()方法的那條執行緒退出<clinit>()方法後,其他執行緒喚醒之後不會再次進入<clinit>()方法。同一個類載入器下,一個型別只會初始化一次。

相關文章