Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

類別與物件

Java 類別與物件進階:static、不可變性與物件誕生那一刻的精確劇本

從建構子裡逸出的半成品物件談起,深入 static 類別狀態、final 不可變設計、初始化順序、巢狀類別與 record,並一路挖到 JVM 的 <clinit>/<init> 與 JMM 的 final 欄位安全發布保證。

一個謎題:在建構子還沒跑完之前,物件就已經被別人看見了?

你已經會用 new Member("Alice", 100) 造出一個物件,也知道建構子(constructor)負責把它初始化到「一出生就合法」的狀態。那我問你一個刁鑽的問題:在建構子的大括號還沒執行到最後一行之前,這個「半成品物件」會不會已經被外面的程式碼看見、甚至被呼叫方法?

答案是——會,而且這正是 Java 物件初始化裡最隱晦的一類 bug 來源。要回答這個問題,你必須往下挖一層,搞清楚「一個物件從無到有」這段旅程裡,到底有哪些階段、誰先誰後、static 的東西又是什麼時候被準備好的。這篇進階篇假設你已讀過入門篇(會員卡那篇),所以我們不再重述欄位、方法、this、封裝,而是直接切進類別層級的狀態、不可變性、初始化順序,以及巢狀類別這些真正決定 Java 程式碼品質的機制。

類別與物件進階概念示意圖

static:屬於「類別」而不屬於「物件」的東西

入門篇裡每個欄位都是實例欄位(instance field)——alice 有自己的 pointsbob 也有自己的 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),newcomervip 這種帶語意的名字讓呼叫端一眼讀懂意圖。靜態工廠還有建構子做不到的本事:它可以回傳快取的既有物件(不一定每次都 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 的寫法是不可變設計的精髓:需要「改變」時,不修改原物件,而是製造一個帶著新狀態的新物件StringIntegerLocalDate 全都是這樣設計的——這也是為什麼 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 提供了好幾種初始化機制,它們有嚴格的執行順序。

靜態成員(類別第一次被載入時跑一次):

  1. 靜態欄位依「在程式碼中出現的順序」逐一初始化;
  2. 靜態初始化區塊(static initializer block)也照出現順序穿插執行。

實例成員(每次 new 都跑一次):

  1. 實例欄位的初始值與實例初始化區塊(instance initializer block),照出現順序執行;
  2. 最後才執行建構子的本體。
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

為什麼 messagenull?因為 Java 的初始化劇本是:建立 Sub 物件時,會先執行父類別 Base 的建構子,而此時 Sub 自己的實例欄位(message還沒輪到被初始化。偏偏 Base 的建構子呼叫了 init(),因為多型(polymorphism)的緣故,實際跑的是被 Sub 覆寫的版本,它去讀 message——但那一刻 message 仍停留在預設值 null

這就是開頭說的「物件還沒蓋好,就被別人看見了」。this 在建構過程中提早「逸出(escape)」,導致別人讀到一個半成品。

結論與守則:建構子裡只做單純的欄位賦值,不要呼叫任何可被覆寫(非 private、非 static、非 final)的方法。若非得在初始化時執行某段邏輯,把它設計成 privatefinal,斷掉被覆寫的可能。這是 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、equalshashCodetoString……動輒幾十行,而且容易寫錯。Java 16 起正式引入的 record(記錄) 把這一切壓成一行:

public record Point(int x, int y) { }

這一行 record,編譯器自動替你生成了:兩個 private final 欄位、一個把全部欄位都填好的「標準建構子(canonical constructor)」、x()y() 兩個存取器(accessor,注意命名是 x() 不是 getX())、以及符合契約的 equalshashCodetoString

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 同時解決了樣板冗長與 equalshashCode 容易寫錯這兩個老問題。

重點回顧

  • static 成員屬於類別、全體共用一份;實例成員屬於每個物件、各自一份。靜態方法裡沒有 this
  • 靜態工廠方法(如 List.of)比 new 更能表達意圖,且能回傳快取物件或子型別。
  • final 與不可變物件讓狀態建構後永不改變,天生執行緒安全;但 final 只鎖參考,鎖不住被指向物件的內部變動。
  • 初始化順序:靜態(一次)→ 實例欄位與實例區塊 → 建構子本體。絕不要在建構子裡呼叫可被覆寫的方法,否則會讀到尚未初始化的子類別欄位。
  • 內部類別默默持有外層物件參考(小心記憶體洩漏,用不到外層就加 static);record 一行生成不可變資料載體與正確的 equalshashCode

深入探討(研究所視角)

把鏡頭再拉遠,看 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 條款在替你撐腰。

AI 共讀助教正在陪你讀:Java 類別與物件進階:static、不可變性與物件誕生那一刻的精確劇本
嗨!我是這篇文章的共讀助教,只根據〈Java 類別與物件進階:static、不可變性與物件誕生那一刻的精確劇本〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。