Java 初始化順序怎麼判斷?搞懂變數、建構子與 static 初始化順序【Thinking in Java筆記(4-4)】

Java 初始化順序怎麼判斷?搞懂變數、建構子與 static 初始化順序【Thinking in Java筆記(4-4)】
Photo by orbtal media / Unsplash

寫 Java 的時候,你可能有想過:「為什麼我沒設定值,Java 就能幫我填好變數?」「建構子跟 static 到底有什麼玄機?」這篇文章就是為了解開這些 Java 黑魔法的面紗。看完你會更懂得 Java 背後的設計邏輯,而不是照本宣科用卻永遠不知所以然

為什麼我們要在意成員初始化?

因為 Java 就像一個緊張兮兮的直升機家長,看到你少寫一個初始值就馬上攔下來:「不可以!你這樣會出事!」它寧願報錯讓你生氣,也不讓你在 runtime 跌個狗吃屎

void f() {
  int i;
  i++; // Error -- i not initialized
}

像這樣,局部變數沒初始化會直接噴錯,因為 Java 認為你很可能是忘了給值。這不是 bug,這是保母式保護機制

雖然編譯器可以為 i 賦予一個預設值,但未初始化的變數更可能是疏忽,因此強制提供初始值可以幫助我們找出程式中的缺陷

類別成員的預設初始化值

但對類別成員變數(field)就沒那麼兇了。Java 幫你預設好值:

public class InitialValues {
  boolean t;
  char c;
  byte b;
  short s;
  int i;
  long l;
  float f;
  double d;
  InitialValues reference;
  void printInitialValues() {
    System.out.println("Data type      Initial value");
    System.out.println("boolean        " + t);
    System.out.println("char           [" + c + "]");
    System.out.println("byte           " + b);
    System.out.println("short          " + s);
    System.out.println("int            " + i);
    System.out.println("long           " + l);
    System.out.println("float          " + f);
    System.out.println("double         " + d);
    System.out.println("reference      " + reference);
  }
  public static void main(String[] args) {
    InitialValues iv = new InitialValues();
    iv.printInitialValues();
    /* You could also say:
    new InitialValues().printInitialValues();
    */
  }
} /* Output:
Data type      Initial value
boolean        false
char           [ ]
byte           0
short          0
int            0
long           0
float          0.0
double         0.0
reference      null
*/

這就像你剛搬進一間新房,雖然你什麼都沒帶,系統已經先幫你擺好了最基本的家具——可能是 IKEA 特價款,但總比赤裸裸一間空屋好得多

如果那項家具是一個「物件」參考(reference),Java 不會亂幫你買一個,而是先放個佔位牌子寫著「目前尚未安裝」,也就是 null。你要是沒注意就對它下指令,比如叫它開燈或倒水(也就是呼叫方法),系統就會爆炸,大聲警告你:這東西根本還沒搬進來!這就是傳說中的 NullPointerException

指定(顯式)初始化:你也可以自己擺家具

你不滿意預設值?可以在定義時直接給值

public class InitialValues2 {
  boolean bool = true;
  char ch = 'x';
  byte b = 47;
  short s = 0xff;
  int i = 999;
  long lng = 1;
  float f = 3.14f;
  double d = 3.14159;
} 

連物件也一樣:

class Depth {}

public class Measurement {
  Depth d = new Depth();
  // ...
}

但要記得,如果你只是宣告一個物件參考,像是 Depth d;,而沒有使用 new 來建立實體,那麼 d 裡面其實什麼都沒有,它的值是 null。此時如果你硬要呼叫 d.someMethod(),就會出現經典的 NullPointerException。這就像你寫下了一個地址,但那個房子根本還沒蓋起來,當你按門鈴時,系統會跳出來說:「這裡根本沒房子啊你在按什麼?」

用方法初始化:打開工廠模式

有些初始值不是固定的,就可以用方法來產生:

public class MethodInit {
  int i = f();
  int f() { return 11; }
}

public class MethodInit2 {
  int i = f();
  int j = g(i);
  int f() { return 11; }
  int g(int n) { return n * 10; }
}

public class MethodInit3 {
  //! int j = g(i); // Illegal forward reference
  int i = f();
  int f() { return 11; }
  int g(int n) { return n * 10; }
}

注意順序,這段範例其實是在說:你不能在變數還沒被初始化前,就拿它來做進一步的計算。像下面這一行:int j = g(i);,這時候 i 還沒被設定(因為初始化是照程式碼從上到下執行的),你卻急著把它拿去算 g(i),就好比你還沒煮好飯,就打算把它裝進便當盒裡,系統當然會跳出來大叫:「這碗根本沒東西!」

這種錯誤叫做 forward reference,也就是往前引用,Java 編譯器會擋下這種操作,保你一命

建構子初始化:當你需要根據情境設定東西

建構子是在你 new 物件時執行的初始化程式碼。你可以把它想像成買一台新手機第一次開機時,它會問你語言、Wi-Fi、Google 帳號等設定,幫你把該準備的東西一次設好,之後你就可以正常使用。建構子做的事也一樣:在物件正式開始運作之前,先處理好它該有的內部狀態

不過,就算你什麼都不設定,Java 也不會讓你完全裸奔。它會自動幫所有類別的成員變數初始化為預設值,例如整數會被設為 0,布林值為 false,物件參考則是 null。這就像你開新手機時,雖然還沒連網、沒登入帳號,但系統已經確保不會一打開就是黑畫面當機。Java 幫你鋪好底層,等你來決定要怎麼客製

public class Counter {
  int i;
  Counter() { i = 7; }
  // ...
}

你可以用參數來決定初始化邏輯,這就是建構子最大的彈性。舉例來說,你可能會寫一個 Person 類別,然後在建構子的參數中傳入名字、年齡等資料:

public class Person {
  String name;
  int age;

  Person(String nameInput, int ageInput) {
    name = nameInput;
    age = ageInput;
  }
}

這樣當你 new Person("Alice", 25),就能一口氣設定這個人的名字和年齡。建構子提供了一個讓物件「出生時就配好資料」的機會,不用再事後一個個設定欄位。搭配 Java 自動提供的預設值(像 int 是 0,物件參考是 null),即便你沒設值,也不會整個爆炸,這讓 Java 的建構流程又穩又彈性

成員變數的初始化順序:先填表單,再見面

你可能以為建構子裡的程式碼最早執行,但其實在你呼叫 new 建立物件時,Java 是照著「先初始化所有成員變數,再跑建構子」這個劇本來進行。也就是說,建構子只是壓軸登場,成員變數早就在你喊 Action 之前就偷偷上台了

這段範例展示了這個順序:

// When the constructor is called to create a
// Window object, you'll see a message:
class Window {
  Window(int marker) { System.out.println("Window(" + marker + ")"); }
}

class House {
  Window w1 = new Window(1); // Before constructor
  House() {
    // Show that we're in the constructor:
    print("House()");
    w3 = new Window(33); // Reinitialize w3
  }
  Window w2 = new Window(2); // After constructor
  void f() { print("f()"); }
  Window w3 = new Window(3); // At end
}

public class OrderOfInitialization {
  public static void main(String[] args) {
    House h = new House();
    h.f(); // Shows that construction is done
  }
} /* Output:
Window(1)
Window(2)
Window(3)
House()
Window(33)
f()
*/

那這個順序到底代表什麼?

你可以想像你要開一場重要的簡報會議,進會議室前,助理(也就是成員變數的初始化區塊)已經幫你把講義印好、PPT打開、茶水準備好。等你(建構子)一走進會議室,只要再做些個人風格微調就能直接開講。甚至你也可以把某些資料重新再準備一次(像範例裡 w3 被重新賦值為 new Window(33)

簡單來說:Java 一律先照成員變數定義順序初始化,再跑建構子。這個規則讓程式行為更可預測,不會因為一個變數擺錯位置就產生意料之外的 bug

如果你在建構子內部又對某個變數賦值,會直接蓋掉原本的初始化,務必要小心避免不必要的重工或邏輯錯誤

static 的世界:一間公司只需要一個會計部

到目前為止,我們講的成員變數初始化,都跟「每個物件有自己的版本」有關。但有時候,我們只需要一份共用的資料,不需要每個物件都複製一份,這時候 static 就出場了

你可以把 static 想成公司裡的會計:不管你公司有幾百個員工,他們都共用一組會計系統(static 資料),不會一人一份(那也太浪費)

在 Java 裡,static 成員變數屬於「類別」本身,不屬於任何「實例」。換句話說,只要類別被載入一次,這些 static 資料就初始化一次,之後不管你建立幾個物件,都會共用那份資料

來看個例子,並解釋一下到底發生了什麼:

class Bowl {
  Bowl(int marker) {
    print("Bowl(" + marker + ")");
  }
  void f1(int marker) {
    print("f1(" + marker + ")");
  }
}

class Table {
  static Bowl bowl1 = new Bowl(1);
  Table() {
    System.out.println("Table()");
    bowl2.f1(1);
  }
  void f2(int marker) {
    System.out.println("f2(" + marker + ")");
  }
  static Bowl bowl2 = new Bowl(2);
}

class Cupboard {
  Bowl bowl3 = new Bowl(3);
  static Bowl bowl4 = new Bowl(4);
  Cupboard() {
    System.out.println("Cupboard()");
    bowl4.f1(2);
  }
  void f3(int marker) {
    System.out.println("f3(" + marker + ")");
  }
  static Bowl bowl5 = new Bowl(5);
}

public class StaticInitialization {
  public static void main(String[] args) {
    System.out.println("Creating new Cupboard() in main");
    new Cupboard();
    System.out.println("Creating new Cupboard() in main");
    new Cupboard();
    table.f2(1);
    cupboard.f3(1);
  }
  static Table table = new Table();
  static Cupboard cupboard = new Cupboard();
} /* Output:
Bowl(1)
Bowl(2)
Table()
f1(1)
Bowl(4)
Bowl(5)
Bowl(3)
Cupboard()
f1(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f1(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f1(2)
f2(1)
f3(1)
*/

這段程式碼展示了 static 與非 static 的初始化順序,並透過 print() 來觀察各種變數何時被初始化:

  1. TableCupboard 類別中都定義了 static 和非 static 的 Bowl 實例
  2. static 的變數會在類別第一次被載入時就初始化一次
  3. 非 static 的變數則會在每次物件被建立時重新初始化

main() 的最一開始,先定義了

static Table table = new Table();
static Cupboard cupboard = new Cupboard();

這段其實是靜態初始化區塊的一部分,會在 main() 執行前就先跑

這會導致:

  • Table 的 bowl1bowl2 被初始化 → 分別印出 Bowl(1)Bowl(2)
  • 建構子跑起來 → 印出 Table()
  • 呼叫 bowl2.f1(1) → 印出 f1(1)

接著是 Cupboard 的 static:

  • bowl4bowl5 初始化(static) → 印出 Bowl(4)Bowl(5)
  • 然後建構 Cupboard 的物件 → 初始化 bowl3(非 static)→ 印出 Bowl(3)
  • 建構子本體執行 → 印出 Cupboard(),再呼叫 bowl4.f1(2) → 印出 f1(2)

最後 main() 開始跑:

  • 印出 Creating new Cupboard(),然後 new 出一個新的 Cupboard():又會執行非 static 的 bowl3 初始化 → 印 Bowl(3),接著建構子跑完

這整段程式的重點是:

  • static 的變數只初始化一次(類別第一次被載入時)
  • 非 static 的變數會在每次物件建立時被初始化一次

這種行為特別重要,當你希望某些資源全程共用時(例如資料庫連線、常數、設定檔),就該放 static。 反之,屬於物件自己的資料(像是每個人有不同的名字)就不該用 static,不然就變成大家共用同一個名字了,非常詭異

額外補充:類別載入與 .class 的真相

在上一段討論 static 的初始化順序時,還有一個容易被忽略但非常關鍵的概念:Java 不是一開始就把所有 .class 檔都載入進記憶體,而是「有用到才載入」。這叫做延遲載入(lazy loading)

假設我們有一個類別:

class Dog {
  static int count = 0;
  Dog() {
    count++;
  }
}

當你執行程式但完全沒用到 Dog 類別時(沒 new,也沒引用 Dog.count),這個類別根本不會被載入。這意味著:

  • Dog.class 檔案還在硬碟上等著你叫它
  • 它裡面的 static 初始化程式碼也完全沒被執行

一旦你呼叫 new Dog(),或是存取了 Dog.count,Java 才會:

  1. 去找 .class
  2. 載入它
  3. 執行所有 static 初始化
  4. 然後才開始 new 或執行你要的程式邏輯

這也說明為什麼 static 初始化「只會執行一次」——因為類別只會載入一次

所以如果你看到某些 static 變數或 static block 沒有執行,請先確認這個類別有沒有被真正用到

這個機制是為了效能與記憶體管理設計的,不會一開始就把整個世界灌進 JVM,只有「被叫到的才進場」。這點對初學者來說很容易被忽略,但理解這件事會讓你在日後處理大型專案、除錯載入行為時更有底氣

顯式的靜態初始化

延續上面對 .class 載入機制的說明,Java 還提供一個特殊的寫法:static 區塊(static block),讓你在類別被載入的時候執行一段一次性的靜態初始化邏輯

這種 static block 非常適合用來做「組合式的初始化」——當你有一堆 static 欄位需要依賴其他欄位或做邏輯處理時,就可以放進 static 區塊裡

來看一個最簡單的範例:

public class Spoon {
  static int i;
  static {
    i = 47;
  }
}

看起來像是方法,但其實不是。這段 static block 的意思是:「當 Spoon 類別被載入時,把 i 設成 47。」

也就是說,只要你呼叫 Spoon.i 或 new 出一個 Spoon 物件,這段 static block 就會自動執行一次。不會重複、不會失憶——正好一次

這種方式特別適合用在:

  • 初始化需要一點小邏輯的 static 資料(不能直接指定值)
  • 一開始要跑某些與靜態變數有關的程式(例如 log 設定、計算常數等)

簡單來說,當你需要「一進場就幹一件事,然後全場都知道」的情境,static block 就是你的武器

來看這段程式碼:

class Cup {
  Cup(int marker) {
    System.out.println("Cup(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

class Cups {
  static Cup cup1;
  static Cup cup2;
  static {
    cup1 = new Cup(1);
    cup2 = new Cup(2);
  }
  Cups() {
    System.out.println("Cups()");
  }
}

public class ExplicitStatic {
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Cups.cup1.f(99);  // (1)
  }
  // static Cups cups1 = new Cups();  // (2)
  // static Cups cups2 = new Cups();  // (2)
} /* Output:
Inside main()
Cup(1)
Cup(2)
f(99)
*/
  • 類別 Cups 裡面有一個 static 區塊,裡面初始化了兩個靜態變數 cup1cup2
  • main() 裡第一次「使用」到 Cups 類別(這裡是 Cups.cup1.f(99))時,Java 會馬上去把 Cups.class 載入進來
  • 一旦載入,就會執行 static 區塊
  • 所以你會看到 Cup(1)Cup(2) 被印出來
  • 然後 f(99) 被呼叫

這就像一間日式餐廳(Cups 類別)早上開門營業的準備流程(static block):第一次有人打開門(使用這個類別)時,店員會一次性開燈、煮水、鋪好榻榻米、設定背景音樂。這些準備工作只做一次,不管之後有幾組客人進來(也就是 new 幾個物件),這些設定都已經就緒,不會每次重頭來過。這就是 static block 的概念——只要類別第一次被使用,Java 就會幫你完成所有靜態資源的初始化,後續的使用者就能享受一個早就備妥的環境

你雖然在 main() 裡什麼都沒寫 new Cups(),但只要你有用到 Cups 的 static 變數,Java 就會載入整個類別,並執行它的 static block。這是理解 static 區塊的關鍵

這個機制非常適合拿來做一些「只要一次」的初始化,例如載入設定檔、建立資料庫連線池、或是 log 設定等情境

非靜態實例初始化:建構子外掛

有時候你會寫一個類別裡面有兩三個建構子,結果每一個建構子裡都要重複貼上初始化程式碼。久而久之就變得很容易出錯或難維護。這時候就可以使用 Java 提供的一個機制:非靜態初始化區塊(instance initializer block)

這個區塊會在每次你 new 一個物件的時候都執行,而且它的執行順序比建構子還早,但在成員變數初始化之後。你可以把它想像成是「建構子的前菜」,不管你叫哪一道主菜(哪個建構子),這道前菜都會先上

class Mug {
  Mug(int marker) {
    System.out.println("Mug(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

public class Mugs {
  Mug mug1;
  Mug mug2;
  {
    mug1 = new Mug(1);
    mug2 = new Mug(2);
    print("mug1 & mug2 initialized");
  }
  Mugs() {
    System.out.println("Mugs()");
  }
  Mugs(int i) {
    System.out.println("Mugs(int)");
  }
  public static void main(String[] args) {
    System.out.println("Inside main()");
    new Mugs();
    System.out.println("new Mugs() completed");
    new Mugs(1);
    System.out.println("new Mugs(1) completed");
  }
} /* Output:
Inside main()
Mug(1)
Mug(2)
mug1 & mug2 initialized
Mugs()
new Mugs() completed
Mug(1)
Mug(2)
mug1 & mug2 initialized
Mugs(int)
new Mugs(1) completed
*/

你會發現,不管呼叫哪個建構子,初始化區塊都會在物件建立過程中跑一次,把 mug1mug2 都準備好

所以它的用處是什麼?

這種語法非常適合:

  • 你有多個建構子,但都需要共用某段初始化邏輯
  • 你不想在每個建構子裡都貼上重複的程式碼
  • 你想要確保某些初始化「永遠都會在建構子之前」執行

想像你是一個組裝模型的工廠,無論你選的是哪個版本的模型(建構子),工廠都會先準備兩個標準零件(mug1 和 mug2)。這樣你就可以專注在後面組裝的細節,不用一直重做一樣的前置準備

總之,如果你討厭複製貼上,這段語法值得學起來

而且這種非靜態初始化區塊在匿名內部類別(anonymous inner class)裡特別重要。什麼是匿名內部類別呢? 它就是沒有名字、現場直接定義的小型類別,常常會在你需要快速實作一個介面或繼承類別時出現

例如:

Button b = new Button();
b.addActionListener(new ActionListener() {
  public void actionPerformed(ActionEvent e) {
    // 這裡就是匿名內部類別
  }
});

由於匿名類別不能有自己的建構子,你就只能靠非靜態初始化區塊來做事情。這樣不論你用什麼方式產生它,初始化邏輯都還是能被正確執行,這讓它在某些需要「微客製但懶得命名」的場景中非常實用

結語:這些初始化不是語法糖,而是設計哲學

從預設值到建構子、從 static block 到 instance block,這整套初始化機制不是 Java 在耍帥,也不是單純讓語法變「短一點」。它背後藏的是一種哲學:讓程式的行為更可預期、讓資源管理更有效率、讓開發者能專注在該做的事上

Java 的這些「看似自動」的行為,其實都建立在一套明確的時序與原則上——你可以不去干涉它,但你最好理解它。因為唯有知道哪些事情「會自動發生」、哪些「要你自己做」,你才不會寫出執行順序不如預期的code,還在懷疑是不是乖乖過期了

希望你看完這篇,能理解為什麼一個物件能在你 new 出來時就一切就緒;為什麼你沒用到的類別根本沒被載入;為什麼 static 的東西只做一次;為什麼匿名類別還是能初始化。這些不是巧合,而是工程設計

當你寫 Java 的時候,請記得:它不是在幫你偷懶,它是在替你設好每一張椅子、調整每一盞燈,等你上場