解析Java類和物件的初始化過程(轉)

ba發表於2007-08-16
解析Java類和物件的初始化過程(轉)[@more@]  類的初始化和物件初始化是 JVM 管理的型別生命週期中非常重要的兩個環節,Google 了一遍網路,有關類裝載機制的文章倒是不少,然而類初始化和物件初始化的文章並不多,特別是從位元組碼和 JVM 層次來分析的文章更是鮮有所見。

  本文主要對類和物件初始化全過程進行分析,透過一個實際問題引入,將原始碼轉換成 JVM 位元組碼後,對 JVM 執行過程的關鍵點進行全面解析,並在文中穿插入了相關 JVM 規範和 JVM 的部分內部理論知識,以理論與實際結合的方式介紹物件初始化和類初始化之間的協作以及可能存在的衝突問題。

  問題引入

  近日我在除錯一個列舉型別的解析器程式,該解析器是將資料庫內一萬多條列舉程式碼裝載到快取中,為了實現快速定位列舉程式碼和具體列舉類別的所有列舉元素,該類在裝載列舉程式碼的同時對其採取兩種策略建立記憶體索引。由於該類是一個公共服務類,在程式各個層面都會使用到它,因此我將它實現為一個單例類。這個類在我調整類例項化語句位置之前執行正常,但當我把該類例項化語句調整到靜態初始化語句之前時,我的程式不再為我工作了。 下面是經過我簡化後的示例程式碼:

  [清單一]

package com.ccb.framework.enums;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class CachingEnumResolver {
 //單態例項 一切問題皆由此行引起
 private static final CachingEnumResolver SINGLE_ENUM_RESOLVER = new CachingEnumResolver();
 /*MSGCODE->Category記憶體索引*/
 private static Map CODE_MAP_CACHE;
 static {
  CODE_MAP_CACHE = new HashMap();
  //為了說明問題,我在這裡初始化一條資料
  CODE_MAP_CACHE.put("0","北京市");
 }
 //private, for single instance
 private CachingEnumResolver() {
  //初始化載入資料 引起問題,該方法也要負點責任
  initEnums();
 }
 /** * 初始化所有的列舉型別 */
 public static void initEnums() {
  // ~~~~~~~~~問題從這裡開始暴露 ~~~~~~~~~~~//
  if (null == CODE_MAP_CACHE) {
   System.out.println("CODE_MAP_CACHE為空,問題在這裡開始暴露.");
   CODE_MAP_CACHE = new HashMap();
  }
  CODE_MAP_CACHE.put("1", "北京市");
  CODE_MAP_CACHE.put("2", "雲南省");
  //..... other code...
 }
 public Map getCache() {
  return Collections.unmodifiableMap(CODE_MAP_CACHE);
 }
 /** * 獲取單態例項 * * @return */
 public static CachingEnumResolver getInstance() {
  return SINGLE_ENUM_RESOLVER;
 }
 public static void main(String[] args) {
  System.out.println(CachingEnumResolver.getInstance().getCache());
 }
}

  想必大家看了上面的程式碼後會感覺有些茫然,這個類看起來沒有問題啊,這的確屬於典型的餓漢式單態模式啊,怎麼會有問題呢?

  是的,他看起來的確沒有問題,可是如果將他 run 起來時,其結果是他不會為你正確 work。執行該類,它的執行結果是:
[清單二]

  CODE_MAP_CACHE為空,問題在這裡開始暴露.{0=北京市}

  我的程式怎麼會這樣?為什麼在 initEnum() 方法裡 CODE_MAP_CACHE 為空?為什麼我輸出的 CODE_MAP_CACHE 內容只有一個元素,其它兩個元素呢????!!

  看到這裡,如果是你在除錯該程式,你此刻一定覺得很奇怪,難道是我的 Jvm 有問題嗎?非也!如果不是,那我的程式是怎麼了?這絕對不是我想要的結果。可事實上無論怎麼修改 initEnum() 方法都無濟於事,起碼我最初是一定不會懷疑到問題可能出在建立 CachingEnumResolver 例項這一環節上。正是因為我太相信我建立 CachingEnumResolver 例項的方法,加之對 Java 類初始化與物件例項化底層原理理解有所偏差,使我為此付出了三、四個小時--約半個工作日的大好青春。

  那麼問題究竟出在哪裡呢?為什麼會出現這樣的怪事呢?在解決這個問題之前,先讓我們來了解一下JVM的類和物件初始化的底層機制。

  類的生命週期

圖展示的是類生命週期流向;在本文裡,我只打算談談類的"初始化"以及"物件例項化"兩個階段。

  類初始化

  類"初始化"階段,它是一個類或介面被首次使用的前階段中的最後一項工作,本階段負責為類變數賦予正確的初始值。

  Java 編譯器把所有的類變數初始化語句和型別的靜態初始化器通通收集到 方法內,該方法只能被 Jvm 呼叫,專門承擔初始化工作。

  除介面以外,初始化一個類之前必須保證其直接超類已被初始化,並且該初始化過程是由 Jvm 保證執行緒安全的。另外,並非所有的類都會擁有一個 () 方法,在以下條件中該類不會擁有 () 方法:

  該類既沒有宣告任何類變數,也沒有靜態初始化語句;該類宣告瞭類變數,但沒有明確使用類變數初始化語句或靜態初始化語句初始化;該類僅包含靜態 final 變數的類變數初始化語句,並且類變數初始化語句是編譯時常量表示式。
物件初始化

  在類被裝載、連線和初始化,這個類就隨時都可能使用了。物件例項化和初始化是就是物件生命的起始階段的活動,在這裡我們主要討論物件的初始化工作的相關特點。

  Java 編譯器在編譯每個類時都會為該類至少生成一個例項初始化方法--即 "()" 方法。此方法與原始碼中的每個構造方法相對應,如果類沒有明確地宣告任何構造方法,編譯器則為該類生成一個預設的無參構造方法,這個預設的構造器僅僅呼叫父類的無參構造器,與此同時也會生成一個與預設構造方法對應的 "()" 方法.

  通常來說,() 方法內包括的程式碼內容大概為:呼叫另一個 () 方法;對例項變數初始化;與其對應的構造方法內的程式碼。 如果構造方法是明確地從呼叫同一個類中的另一個構造方法開始,那它對應的 () 方法體內包括的內容為:一個對本類的 () 方法的呼叫;對應用構造方法內的所有位元組碼。

  如果構造方法不是透過呼叫自身類的其它構造方法開始,並且該物件不是 Object 物件,那 () 法內則包括的內容為:一個對父類 () 方法的呼叫;對例項變數初始化方法的位元組碼;最後是對應構造子的方法體位元組碼。

  如果這個類是 Object,那麼它的 () 方法則不包括對父類 () 方法的呼叫。

  類的初始化時機

  本文到目前為止,我們已經大概有了解到了類生命週期中都經歷了哪些階段,但這個類的生命週期的開始階段--類裝載又是在什麼時候被觸發呢?類又是何時被初始化的呢?讓我們帶著這三個疑問繼續去尋找答案。

  Java 虛擬機器規範為類的初始化時機做了嚴格定義:"initialize on first active use"--" 在首次主動使用時初始化"。這個規則直接影響著類裝載、連線和初始化類的機制--因為在型別被初始化之前它必須已經被連線,然而在連線之前又必須保證它已經被裝載了。

  在與初始化時機相關的類裝載時機問題上,Java 虛擬機器規範並沒有對其做嚴格的定義,這就使得 JVM 在實現上可以根據自己的特點提供採用不同的裝載策略。我們可以思考一下 Jboss AOP 框架的實現原理,它就是在對你的 class 檔案裝載環節做了手腳--插入了 AOP 的相關攔截位元組碼,這使得它可以對程式設計師做到完全透明化,哪怕你用 new 運算子建立出的物件例項也一樣能被 AOP 框架攔截--與之相對應的 Spring AOP,你必須透過他的 BeanFactory 獲得被 AOP 代理過的受管物件,當然 Jboss AOP 的缺點也很明顯--他是和 JBOSS 伺服器繫結很緊密的,你不能很輕鬆的移植到其它伺服器上。嗯~……,說到這裡有些跑題了,要知道 AOP 實現策略足可以寫一本厚厚的書了,嘿嘿,就此打住。

說了這麼多,類的初始化時機就是在"在首次主動使用時",那麼,哪些情形下才符合首次主動使用的要求呢?

  首次主動使用的情形:

  ·建立某個類的新例項時--new、反射、克隆或反序列化;

  ·呼叫某個類的靜態方法時;

  ·使用某個類或介面的靜態欄位或對該欄位賦值時(final欄位除外);

  ·呼叫Java的某些反射方法時

  ·初始化某個類的子類時

  ·在虛擬機器啟動時某個含有main()方法的那個啟動類。

  除了以上幾種情形以外,所有其它使用JAVA型別的方式都是被動使用的,他們不會導致類的初始化。

  我的問題究竟出在哪裡

  好了,瞭解了JVM的類初始化與物件初始化機制後,我們就有了理論基礎,也就可以理性的去分析問題了。

  下面讓我們來看看前面[清單一]的JAVA原始碼反組譯出的位元組碼:

  [清單三]

public class com.ccb.framework.enums.CachingEnumResolver extendsjava.lang.Object{
 static {};
 Code: 0: new #2;
 //class CachingEnumResolver
 3: dup
 4: invokespecial #14;
 //Method "":()V ①
 7: putstatic #16;
 //Field SINGLE_ENUM_RESOLVER:Lcom/ccb/framework/enums/CachingEnumResolver;
 10: new #18;
 //class HashMap ②
 13: dup
 14: invokespecial #19;
 //Method java/util/HashMap."":()V
 17: putstatic #21;
 //Field CODE_MAP_CACHE:Ljava/util/Map;
 20: getstatic #21;
 //Field CODE_MAP_CACHE:Ljava/util/Map;
 23: ldc #23;
 //String 0
 25: ldc #25;
 //String 北京市
 27: invokeinterface #31, 3;
 //InterfaceMethod java/util/Map.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; ③
 32: pop 33: returnprivate com.ccb.framework.enums.CachingEnumResolver();
 Code: 0: aload_0 1: invokespecial #34;
 //Method java/lang/Object."":()V 4: invokestatic #37;
 //Method initEnums:()V ④ 7: returnpublic static void initEnums();
 Code: 0: getstatic #21;
 //Field CODE_MAP_CACHE:Ljava/util/Map;
 ⑤ 3: ifnonnull 24 6: getstatic #44;
 //Field java/lang/System.out:Ljava/io/PrintStream;
 9: ldc #46;
 //String CODE_MAP_CACHE為空,問題在這裡開始暴露.
 11: invokevirtual #52;
 //Method java/io/PrintStream.println:(Ljava/lang/String;)V 14: new #18;
 //class HashMap 17: dup 18: invokespecial #19;
 //Method java/util/HashMap."":()V ⑥ 21: putstatic #21;
 //Field CODE_MAP_CACHE:Ljava/util/Map;
 24: getstatic #21;
 //Field CODE_MAP_CACHE:Ljava/util/Map;
 27: ldc #54;
 //String 1 29: ldc #25;
 //String 北京市 31: invokeinterface #31, 3;
 //InterfaceMethod java/util/Map.put:(Ljava/lang/Object;
 Ljava/lang/Object;)Ljava/lang/Object;
 ⑦ 36: pop 37: getstatic #21;
 //Field CODE_MAP_CACHE:Ljava/util/Map;
 40: ldc #56;
 //String 2 42: ldc #58;
 //String 雲南省 44: invokeinterface #31, 3;
 //InterfaceMethod java/util/Map.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
 ⑧ 49: pop 50: returnpublic java.util.Map getCache();
 Code: 0: getstatic #21;
 //Field CODE_MAP_CACHE:Ljava/util/Map;
 3: invokestatic #66;
 //Method java/util/Collections.unmodifiableMap:(Ljava/util/Map;)Ljava/util/Map;
 6: areturnpublic static com.ccb.framework.enums.CachingEnumResolver getInstance();
 Code: 0: getstatic #16;
 //Field SINGLE_ENUM_RESOLVER:Lcom/ccb/framework/enums/CachingEnumResolver;
 ⑨ 3: areturn}
如果上面[清單一]顯示,清單內容是在 JDK1.4 環境下的位元組碼內容,可能這份清單對於很大部分兄弟來說確實沒有多少吸引力,因為這些 JVM 指令確實不像原始碼那樣漂亮易懂。但它的的確確是查詢和定位問題最直接的辦法,我們想要的答案就在這份 JVM 指令清單裡。

  現在,讓我們對該類從類初始化到物件例項初始化全過程分析[清單一]中的程式碼執行軌跡。

  如前面所述,類初始化是在類真正可用時的最後一項前階工作,該階段負責對所有類正確的初始化值,此項工作是執行緒安全的,JVM會保證多執行緒同步。

  第1步:呼叫類初始化方法 CachingEnumResolver.(),該方法對外界是不可見的,換句話說是 JVM 內部專用方法,() 內包括了 CachingEnumResolver 內所有的具有指定初始值的類變數的初始化語句。要注意的是並非每個類都具有該方法,具體的內容在前面已有敘述。

  第2步:進入 () 方法內,讓我們看位元組碼中的 "①" 行,該行與其上面兩行組合起來代表 new 一個 CachingEnumResolver 物件例項,而該程式碼行本身是指呼叫 CachingEnumResolver 類的 ()方法。每一個 Java 類都具有一個 () 方法,該方法是 Java 編譯器在編譯時生成的,對外界不可見,() 方法內包括了所有具有指定初始化值的例項變數初始化語句和java類的構造方法內的所有語句。物件在例項化時,均透過該方法進行初始化。然而到此步,一個潛在的問題已經在此埋伏好,就等著你來犯了。

  第3步:讓我們順著執行順序向下看,"④" 行,該行所在方法就是該類的構造器,該方法先呼叫父類的構造器 () 對父物件進行初始化,然後呼叫 CachingEnumResolver.initEnum() 方法載入資料。

  第4步:"⑤" 行,該行獲取 "CODE_MAP_CACHE" 欄位值,其執行時該欄位值為 null。注意,問題已經開始顯現了。(作為程式設計師的你一定是希望該欄位已經被初始化過了,而事實上它還沒有被初始化)。透過判斷,由於該欄位為 NULL,因此程式將繼續執行到 "⑥" 行,將該欄位例項化為 HashMap()。

  第5步:在 "⑦"、"⑧" 行,其功能就是為 "CODE_MAP_CACHE" 欄位填入兩條資料。

  第6步:退出物件初始化方法 (),將生成的物件例項初始化給類欄位 "SINGLE_ENUM_RESOLVER"。(注意,此刻該物件例項內的類變數還未初始化完全,剛才由 () 呼叫 initEnum() 方法賦值的類變數 "CODE_MAP_CACHE" 是 () 方法還未初始化欄位,它還將在後面的類初始化過程再次被覆蓋)。

  第7步:繼續執行 ()方法內的後繼程式碼,"②" 行,該行對 "CODE_MAP_CACHE" 欄位例項化為 HashMap 例項(注意:在物件例項化時已經對該欄位賦值過了,現在又重新賦值為另一個例項,此刻,"CODE_MAP_CACHE"變數所引用的例項的類變數值被覆蓋,到此我們的疑問已經有了答案)。

  第8步:類初始化完畢,同時該單態類的例項化工作也完成。

  透過對上面的位元組碼執行過程分析,或許你已經清楚瞭解到導致錯誤的深層原因了,也或許你可能早已被上面的分析過程給弄得暈頭轉向了,不過也沒折,雖然我也可以從原始碼的角度來闡述問題,但這樣不夠深度,同時也會有僅為個人觀點、不足可信之嫌。

  如何解決

  要解決上面程式碼所存在的問題很簡單,那就是將 "SINGLE_ENUM_RESOLVER" 變數的初始化賦值語句轉移到 getInstance() 方法中去即可。換句話說就是要避免在類還未初始化完成時從內部例項化該類或在初始化過程中引用還未初始化的欄位。

  寫在最後

  靜下浮燥之心,仔細思量自己是否真的掌握了本文主題所引出的知識,如果您覺得您已經完全或基本掌握了,那麼很好,在最後,我將前面的程式碼稍做下修改,請思考下面兩組程式是否同樣會存在問題呢?

  程式一

public class CachingEnumResolver {
 public static Map CODE_MAP_CACHE;
 static {
  CODE_MAP_CACHE = new HashMap();
  //為了說明問題,我在這裡初始化一條資料
  CODE_MAP_CACHE.put("0","北京市");
  initEnums();
 }

  程式二

public class CachingEnumResolver {
 private static final CachingEnumResolver SINGLE_ENUM_RESOLVER;
 public static Map CODE_MAP_CACHE;
 static {
  CODE_MAP_CACHE = new HashMap();
  //為了說明問題,我在這裡初始化一條資料
  CODE_MAP_CACHE.put("0","北京市");
  SINGLE_ENUM_RESOLVER = new CachingEnumResolver();
  initEnums();
 }

  最後,一點關於 JAVA 群體的感言:時下正是各種開源框架盛行時期,Spring 更是大行其道,吸引著一大批 JEE 開發者的眼球(我也是 fans 中的一員)。然而,讓我們仔細觀察一下--以 Spring 群體為例,在那麼多的 Spring fans 當中,有多少人去研究過 Spring 原始碼?又有多少人對 Spring 設計思想有真正深入瞭解呢?當然,我是沒有資格以這樣的口吻來說事的,我只是想表明一個觀點--學東西一定要"正本清源"。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10617731/viewspace-960544/,如需轉載,請註明出處,否則將追究法律責任。

相關文章