Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

類別與物件

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、不可變性與物件誕生那一刻的精確劇本〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。