Java 類別與物件進階:static、不可變性與物件誕生那一刻的精確劇本
從建構子裡逸出的半成品物件談起,深入 static 類別狀態、final 不可變設計、初始化順序、巢狀類別與 record,並一路挖到 JVM 的 <clinit>/<init> 與 JMM 的 final 欄位安全發布保證。
一個謎題:在建構子還沒跑完之前,物件就已經被別人看見了?
你已經會用 new Member("Alice", 100) 造出一個物件,也知道建構子(constructor)負責把它初始化到「一出生就合法」的狀態。那我問你一個刁鑽的問題:在建構子的大括號還沒執行到最後一行之前,這個「半成品物件」會不會已經被外面的程式碼看見、甚至被呼叫方法?
答案是——會,而且這正是 Java 物件初始化裡最隱晦的一類 bug 來源。要回答這個問題,你必須往下挖一層,搞清楚「一個物件從無到有」這段旅程裡,到底有哪些階段、誰先誰後、static 的東西又是什麼時候被準備好的。這篇進階篇假設你已讀過入門篇(會員卡那篇),所以我們不再重述欄位、方法、this、封裝,而是直接切進類別層級的狀態、不可變性、初始化順序,以及巢狀類別這些真正決定 Java 程式碼品質的機制。

static:屬於「類別」而不屬於「物件」的東西
入門篇裡每個欄位都是實例欄位(instance field)——alice 有自己的 points,bob 也有自己的 points,兩者互不相干。但有些資料,邏輯上不該屬於任何單一物件,而該屬於「整個類別」。
舉例:你想統計「總共建立過幾位會員」。這個計數器不屬於 Alice、也不屬於 Bob,它屬於 Member 這個型別本身。這就是 static(靜態)的用途。
public class Member {
private static int totalMembers = 0; // 類別變數:全類別共用一份
private final int id; // 實例變數:每個物件各自一份
private String name;
public Member(String name) {
this.name = name;
totalMembers++; // 每 new 一次就 +1
this.id = totalMembers; // 拿目前總數當作這位會員的編號
}
public static int getTotalMembers() { // 靜態方法
return totalMembers;
}
}
關鍵差異在於:static 的成員只有一份,存在於類別本身,所有物件共享;非 static 的成員則是每個物件各一份。
new Member("Alice");
new Member("Bob");
System.out.println(Member.getTotalMembers()); // 輸出:2
注意呼叫方式是 Member.getTotalMembers()——透過類別名稱而非物件來存取,因為它根本不需要任何物件就能運作。這也帶出一條重要規則:靜態方法裡不能使用 this,也不能直接存取實例欄位。原因很單純——靜態方法是在「沒有特定物件」的前提下被呼叫的,this 此刻無從指起。你寫過的 public static void main(String[] args) 正是如此:JVM 啟動時還沒有任何物件,只能呼叫一個不依賴物件的入口方法。
靜態工廠方法:比建構子更會說話的造物方式
static 還有一個漂亮的應用:靜態工廠方法(static factory method)。與其讓外部直接 new,不如提供一個有名字、能回傳物件的靜態方法。
public class Member {
private final String name;
private final int points;
private Member(String name, int points) { // 建構子設為 private,封死直接 new
this.name = name;
this.points = points;
}
public static Member newcomer(String name) { // 工廠方法:新會員送 0 點
return new Member(name, 0);
}
public static Member vip(String name) { // 工廠方法:VIP 直接送 500 點
return new Member(name, 500);
}
}
Member a = Member.newcomer("Alice");
Member b = Member.vip("Bob");
比起兩個都叫 Member(...) 的多載建構子(overloaded constructor),newcomer 與 vip 這種帶語意的名字讓呼叫端一眼讀懂意圖。靜態工廠還有建構子做不到的本事:它可以回傳快取的既有物件(不一定每次都 new)、可以回傳子型別。Java 標準函式庫到處都是這種設計,例如 Integer.valueOf(42)、List.of(1, 2, 3)、Optional.of(x)——它們全都是靜態工廠,而不是 new Integer(...)。
final 與不可變性:一旦定型,永不更改
入門篇用 private 把欄位藏起來、靠 setter 守門。但更高一個層次的安全,是讓物件根本沒有 setter——一旦建立就永遠不變。這叫不可變物件(immutable object),是現代 Java(與並行程式設計)強烈推崇的設計。
工具是 final 關鍵字。final 修飾的變數只能被賦值一次:
public final class Point { // class 加 final:禁止被繼承
private final int x; // 欄位加 final:建構後不可再改
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
// 沒有任何 setter,也沒有任何會改 x、y 的方法
public Point movedBy(int dx, int dy) {
return new Point(x + dx, y + dy); // 不改自己,而是回傳一個「新的」Point
}
}
final int x 保證 x 在建構子賦值後就被釘死,編譯器會擋掉任何後續的 this.x = ...。movedBy 的寫法是不可變設計的精髓:需要「改變」時,不修改原物件,而是製造一個帶著新狀態的新物件。String、Integer、LocalDate 全都是這樣設計的——這也是為什麼 String s = "abc"; s.toUpperCase(); 之後 s 依然是 "abc",因為 toUpperCase() 回傳的是一個新字串,原字串動都沒動。
不可變物件的價值在於:它天生執行緒安全(thread-safe)。多個執行緒同時讀同一個不可變物件,永遠不可能讀到「改到一半」的中間狀態,因為它根本不會被改。在多核心、高並行的時代,這是一個極大的優勢。
一個常見陷阱:final 擋得住重新賦值,擋不住內部被改
這裡要破除一個迷思:final 保證的是「參考不能改指向別的物件」,不是「物件內容不能變」。
final List<String> names = new ArrayList<>();
names.add("Alice"); // 合法!final 沒有禁止你改動 List 的內容
names.add("Bob"); // 合法!
// names = new ArrayList<>(); // 編譯錯誤!這才是 final 禁止的:重新賦值
final 鎖住的是 names 這個「把手」,讓它永遠指向同一個 ArrayList;但那個 ArrayList 內部是可變的,你照樣能 add。要做到真正的不可變集合,得用 List.copyOf(...) 或 Collections.unmodifiableList(...) 把它包成唯讀。理解這層差別,是寫出正確不可變類別的前提。
初始化順序:物件誕生那一刻的精確劇本
現在回到開頭那個謎題。要看懂它,必須先掌握 Java 物件「從無到有」的完整劇本。Java 提供了好幾種初始化機制,它們有嚴格的執行順序。
對靜態成員(類別第一次被載入時跑一次):
- 靜態欄位依「在程式碼中出現的順序」逐一初始化;
- 靜態初始化區塊(static initializer block)也照出現順序穿插執行。
對實例成員(每次 new 都跑一次):
- 實例欄位的初始值與實例初始化區塊(instance initializer block),照出現順序執行;
- 最後才執行建構子的本體。
public class Demo {
static int s = log("1. 靜態欄位 s");
static { log("2. 靜態區塊"); }
int a = log("3. 實例欄位 a");
{ log("4. 實例區塊"); }
Demo() {
log("5. 建構子本體");
}
static int log(String msg) {
System.out.println(msg);
return 0;
}
public static void main(String[] args) {
System.out.println("--- 第一次 new ---");
new Demo();
System.out.println("--- 第二次 new ---");
new Demo();
}
}
輸出:
1. 靜態欄位 s
2. 靜態區塊
--- 第一次 new ---
3. 實例欄位 a
4. 實例區塊
5. 建構子本體
--- 第二次 new ---
3. 實例欄位 a
4. 實例區塊
5. 建構子本體
讀懂這份輸出,你會發現兩個關鍵事實:靜態的東西只在類別載入時跑一次(第一次用到 Demo 之前),而實例欄位與實例區塊一定在建構子本體之前完成。也就是說,當你的建構子第一行開始執行時,所有實例欄位的「初始值」其實都已經就緒了。
建構子鏈接:用 this(...) 把多個建構子串起來
當一個類別有多個建構子,你不該在每個裡面重複初始化邏輯,而是用 this(...) 讓建構子彼此呼叫,把工作集中到一個「主建構子」。
public class Member {
private final String name;
private final int points;
public Member(String name, int points) { // 主建構子:唯一真正幹活的地方
this.name = name;
this.points = points;
}
public Member(String name) {
this(name, 0); // 委派給上面那個,補上預設 0 點
}
}
this(name, 0) 必須是建構子的第一行(這是語言硬規定)。這種「委派建構子(delegating constructor)」讓初始化邏輯只有一份,要改規則只改一處,避免幾個建構子各寫一套、日久不一致。
為什麼「建構子裡不要呼叫可被覆寫的方法」
有了初始化順序的知識,現在可以正面回答開頭的謎題了。看這段危險的程式碼:
class Base {
Base() {
init(); // 在建構子裡呼叫一個可被覆寫的方法——危險!
}
void init() {
System.out.println("Base.init");
}
}
class Sub extends Base {
private String message = "Hello";
@Override
void init() {
System.out.println("Sub.init,message = " + message);
}
}
new Sub();
你可能期待輸出 Sub.init,message = Hello,但實際上是:
Sub.init,message = null
為什麼 message 是 null?因為 Java 的初始化劇本是:建立 Sub 物件時,會先執行父類別 Base 的建構子,而此時 Sub 自己的實例欄位(message)還沒輪到被初始化。偏偏 Base 的建構子呼叫了 init(),因為多型(polymorphism)的緣故,實際跑的是被 Sub 覆寫的版本,它去讀 message——但那一刻 message 仍停留在預設值 null。
這就是開頭說的「物件還沒蓋好,就被別人看見了」。this 在建構過程中提早「逸出(escape)」,導致別人讀到一個半成品。
結論與守則:建構子裡只做單純的欄位賦值,不要呼叫任何可被覆寫(非 private、非 static、非 final)的方法。若非得在初始化時執行某段邏輯,把它設計成 private 或 final,斷掉被覆寫的可能。這是 Java 工程裡一條老資格的、用無數 bug 換來的鐵律。
巢狀類別:把類別寫進類別裡
Java 允許在一個類別內部再定義類別,這叫巢狀類別(nested class)。它主要分兩種,差別只在一個 static,但語意天差地別。
靜態巢狀類別(static nested class):加了 static,它與外層類別只是「寫在一起」的組織關係,本身不持有任何外層物件的參考,行為等同一個普通的頂層類別。
public class Tree {
static class Node { // 靜態巢狀類別
int value;
Node left, right;
}
}
內部類別(inner class):沒有 static,它綁定到一個外層物件的實例,可以直接存取外層物件的私有欄位。它的存在本身就握著一個指向外層物件的隱藏參考。
public class Counter {
private int count = 0;
class Ticker { // 內部類別(非 static)
void tick() {
count++; // 直接動到外層 Counter 物件的 count
}
}
public Ticker newTicker() {
return new Ticker(); // 這個 Ticker 綁定到「目前這個」Counter
}
}
這裡藏著一個實務上的記憶體陷阱:因為內部類別默默持有外層物件的參考,只要那個 Ticker 還活著(例如被放進某個長壽命的集合),它就會讓對應的 Counter 無法被垃圾回收(GC),即使你早就不需要那個 Counter 了。這是 Java 記憶體洩漏的經典成因之一。若內部類別用不到外層物件的狀態,請一律加上 static,斷開這條隱形的綁帶。
看一個例子:匿名類別與它捕獲的變數
巢狀類別還有一種「用完即丟」的形態——匿名類別(anonymous class),常用來當場實作一個介面或覆寫一個方法,連名字都懶得取。
import java.util.*;
public class SortDemo {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("Charlie", "Alice", "Bob"));
// 當場 new 出一個 Comparator 的匿名實作
names.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length(); // 依字串長度排序
}
});
System.out.println(names); // [Bob, Alice, Charlie]
}
}
匿名類別有一條值得記住的規則:它若要用到外層方法的區域變數,那個變數必須是 final 或「實質上不可變(effectively final)」——也就是賦值一次後就沒再改過。
public Runnable makeGreeter(String who) {
// who 沒有被重新賦值,因此是 effectively final,匿名類別才能捕獲它
return new Runnable() {
@Override
public void run() {
System.out.println("Hi, " + who);
}
};
}
為什麼有這條限制?因為區域變數活在堆疊(stack)上,方法一返回就消失,但匿名類別的物件活在堆積(heap)上、可能活得更久。Java 的做法是讓匿名類別捕獲(capture)變數當下的值,存成自己的一份副本。若允許之後再改那個區域變數,副本與原值就會分歧、語意混亂,所以乾脆要求它不准改。理解這點,你也就懂了 Java 8 之後 lambda 運算式為何同樣受這條規則約束——lambda 本質上是匿名類別的語法糖。
record:一行宣告一個不可變資料載體
寫一個純粹用來「裝資料」的類別,傳統上要手刻一堆樣板:private final 欄位、建構子、每個欄位的 getter、equals、hashCode、toString……動輒幾十行,而且容易寫錯。Java 16 起正式引入的 record(記錄) 把這一切壓成一行:
public record Point(int x, int y) { }
這一行 record,編譯器自動替你生成了:兩個 private final 欄位、一個把全部欄位都填好的「標準建構子(canonical constructor)」、x() 與 y() 兩個存取器(accessor,注意命名是 x() 不是 getX())、以及符合契約的 equals/hashCode/toString。
Point p = new Point(3, 4);
System.out.println(p.x()); // 3
System.out.println(p); // Point[x=3, y=4](自動生成的 toString)
Point q = new Point(3, 4);
System.out.println(p.equals(q)); // true(自動生成的 equals 逐欄位比對)
record 預設就是不可變的——所有欄位都是 final,沒有 setter。若你需要在建構時驗證資料,可以寫精簡建構子(compact constructor),只寫驗證邏輯、不必重複賦值:
public record Point(int x, int y) {
public Point { // 精簡建構子:沒有參數列
if (x < 0 || y < 0) {
throw new IllegalArgumentException("座標不可為負");
}
// 不用寫 this.x = x; 編譯器會自動補上
}
}
record 不是要取代所有類別——它專為「不可變的資料聚合」而生(DTO、座標、一筆查詢結果、API 回應……)。當你的類別有可變狀態、複雜行為、或需要繼承,傳統 class 仍是正解。但對「只是裝幾個值」的場景,record 同時解決了樣板冗長與 equals/hashCode 容易寫錯這兩個老問題。
重點回顧
static成員屬於類別、全體共用一份;實例成員屬於每個物件、各自一份。靜態方法裡沒有this。- 靜態工廠方法(如
List.of)比new更能表達意圖,且能回傳快取物件或子型別。 final與不可變物件讓狀態建構後永不改變,天生執行緒安全;但final只鎖參考,鎖不住被指向物件的內部變動。- 初始化順序:靜態(一次)→ 實例欄位與實例區塊 → 建構子本體。絕不要在建構子裡呼叫可被覆寫的方法,否則會讀到尚未初始化的子類別欄位。
- 內部類別默默持有外層物件參考(小心記憶體洩漏,用不到外層就加
static);record 一行生成不可變資料載體與正確的equals/hashCode。
深入探討(研究所視角)
把鏡頭再拉遠,看 JVM 怎麼把上述機制落實到位元組碼(bytecode)與類別生命週期。
類別載入與 <clinit>/<init>
你寫的 static 初始化與實例初始化,在編譯後其實被收斂成兩個特殊方法,名字你在原始碼裡永遠看不到:
-
<clinit>(class initializer):JVM 把所有靜態欄位初始值與靜態區塊依出現順序合併成這一個方法。它在類別初始化階段被呼叫,且 JVM 規格保證整個程式生命週期只跑一次,並由 JVM 在底層加鎖確保多執行緒下也只執行一次——這正是「靜態初始化天生執行緒安全」的根據,也是經典的 Initialization-on-Demand Holder 單例(singleton)慣用法之所以安全的原理:把單例實例放進一個靜態巢狀類別的靜態欄位,第一次存取才觸發<clinit>,由 JVM 保證初始化只發生一次,完全不需要自己寫synchronized。 -
<init>(instance initializer):每個建構子各自編譯成一個<init>方法,編譯器會把實例欄位初始值、實例初始化區塊「織入」到建構子本體之前。這就是為什麼前面的劇本是「實例欄位 → 實例區塊 → 建構子本體」——它們在位元組碼層級根本被縫進了同一個<init>。
類別載入本身又細分為載入(loading)→ 連結(linking:驗證、準備、解析)→ 初始化(initialization) 三大階段。<clinit> 屬於最後的初始化階段,且 JVM 採惰性(lazy)策略——一個類別要到「主動使用」(new 它、存取它的靜態成員、反射等)那一刻才會被初始化。理解這點,你就能解釋為什麼「只是宣告一個型別的變數」不會觸發該類別的靜態區塊,而「第一次存取它的靜態欄位」會。
建構過程中 final 欄位的記憶體可見性保證
最後一個研究所等級的細節,連結到 Java 記憶體模型(Java Memory Model, JMM)。前面說不可變物件天生執行緒安全,這背後有一條精確的語言保證:JMM 規定,只要一個物件在建構子裡正確初始化了它的 final 欄位,且建構過程中 this 沒有逸出,那麼任何執行緒只要拿到這個物件的參考,就保證能看到那些 final 欄位的正確初始值——不需要任何額外的同步(synchronization)。
這條 final field 安全發布(safe publication) 保證,正是把開頭那個「this 逸出」陷阱推到極致的反面教材:一旦 this 在建構子裡逸出(例如把 this 註冊到某個靜態集合、或像前面那樣呼叫可覆寫方法),這條保證就作廢,別的執行緒可能讀到 final 欄位「尚未寫入」的中間狀態。所以「不可變 + 建構子不洩漏 this」不只是好風格,它是你能否安全地把物件丟給其他執行緒、而完全不加鎖的法理依據。你今天寫的每一個 record、每一個 final 欄位,背後都站著這條 JMM 條款在替你撐腰。