網路上關於「類載入機制」的文章汗牛充棟,但真正從 JVM 位元碼層面剖析「類初始化」與「對象初始化」協作關係的卻鳳毛麟角。本文從一個真實的單例類 Bug 切入,帶你深入 JVM 底層,看清初始化順序如何導致靜態欄位被「神秘覆蓋」。
public class CachingEnumResolver {
// 餓漢式單例實例 —— 禍根在此
private static final CachingEnumResolver SINGLE_ENUM_RESOLVER = new CachingEnumResolver();
private static Map CODE_MAP_CACHE;
static {
CODE_MAP_CACHE = new HashMap();
CODE_MAP_CACHE.put("0", "北京市");
}
private CachingEnumResolver() {
initEnums(); // 構造器中呼叫靜態方法
}
public static void initEnums() {
if (CODE_MAP_CACHE == null) { // 這裡竟然為 null!
System.out.println("CODE_MAP_CACHE為空,問題在這裡開始暴露.");
CODE_MAP_CACHE = new HashMap();
}
CODE_MAP_CACHE.put("1", "北京市");
CODE_MAP_CACHE.put("2", "雲南省");
}
// ... getter / main 省略
}
執行結果:
CODE_MAP_CACHE為空,問題在這裡開始暴露.
{0=北京市}
明明在 static 區塊已初始化並放入 "0=北京市",為何進入 initEnums() 時 CODE_MAP_CACHE 卻是 null?且最終輸出只剩一條資料?
<clinit>() 方法<clinit>() 線程安全、只執行一次<clinit>() 必須先於子類完成<init>() 方法super() → 實例欄位初始化 → 建構子本體代碼this(...),則調用另一個 <init>(),不再隱式調用 super()「首次主動使用」:
new、反射、克隆、反序列化建立實例final 常量除外)Class.forName 等main()反編譯 CachingEnumResolver.class 關鍵位元碼片段:
// 方法
0: new #2 // new CachingEnumResolver() ← ①
3: dup
4: invokespecial #14 // 呼叫 () ← ②
7: putstatic #16 // 賦值給 SINGLE_ENUM_RESOLVER
10: new #18 // new HashMap() ← ③
13: dup
14: invokespecial #19
17: putstatic #21 // 賦值給 CODE_MAP_CACHE
20: getstatic #21
23: ldc "0"
25: ldc "北京市"
27: invokeinterface put
32: pop
33: return
// 方法(建構子)
0: aload_0
1: invokespecial #34 // super()
4: invokestatic #37 // 呼叫 initEnums() ← ④
7: return
// initEnums() 方法
0: getstatic #21 // 讀取 CODE_MAP_CACHE ← ⑤ 此時為 null!
3: ifnonnull 24
... new HashMap() // 重新 new 一個 Map ← ⑥
... put "1", "北京市" ← ⑦
... put "2", "雲南省" ← ⑧
main 調用 getInstance() → 讀取靜態欄位 SINGLE_ENUM_RESOLVER → 觸發 <clinit>()<clinit>() 開始:執行到 ① new CachingEnumResolver()<init>() → ④ 呼叫 initEnums()getstatic CODE_MAP_CACHE → 此時 <clinit>() 尚未執行到 ③,欄位仍為 nullnull → ⑥ 重新 new HashMap() → ⑦⑧ 放入 "1"、"2"<init>():實例賦值給 SINGLE_ENUM_RESOLVER<clinit>() 繼續:執行 ③ new HashMap() → 覆蓋掉剛才在 initEnums() 中建立的 Map → 放入 "0"CODE_MAP_CACHE 只剩 "0=北京市"在類初始化(
<clinit>())尚未完成時,若通過構造器或靜態方法間接訪問該類的其它靜態欄位,會看到「尚未初始化」的默認值(null/0/false)。若此時對欄位賦值,後續<clinit>()繼續執行時會再次覆蓋,導致資料遺失。
getInstance()(懶漢式)private static CachingEnumResolver SINGLE_ENUM_RESOLVER;
public static synchronized CachingEnumResolver getInstance() {
if (SINGLE_ENUM_RESOLVER == null) {
SINGLE_ENUM_RESOLVER = new CachingEnumResolver();
}
return SINGLE_ENUM_RESOLVER;
}
private static final CachingEnumResolver SINGLE_ENUM_RESOLVER;
private static Map CODE_MAP_CACHE;
static {
CODE_MAP_CACHE = new HashMap();
CODE_MAP_CACHE.put("0", "北京市");
initEnums(); // 此時 CODE_MAP_CACHE 已就緒
SINGLE_ENUM_RESOLVER = new CachingEnumResolver(); // 最後才 new 實例
}
將資料載入邏輯移至靜態區塊或專門的 init() 方法,由外部顯式調用。
public class Test {
public static Map CACHE;
static {
CACHE = new HashMap();
CACHE.put("0", "A");
init(); // 呼叫靜態方法
}
public static void init() { ... }
}
✅ 安全:CACHE 在呼叫 init() 前已完成初始化。
public class Test {
private static final Test INSTANCE;
public static Map CACHE;
static {
CACHE = new HashMap();
CACHE.put("0", "A");
INSTANCE = new Test(); // 實例化在 init() 之前
init();
}
}
❌ 不安全:構造器若訪問 CACHE,同樣會遇到「初始化未完成」問題。
Java 類與對象初始化看似簡單,實則暗藏順序陷阱。鐵律:類初始化(<clinit>())必須在對象初始化(<init>())可能訪問的靜態欄位「就緒」後再觸發實例化。 閱讀位元碼是定位此類問題的終極武器——源碼會騙人,位元碼不會。