Java Internals & Concurrency/Initialization safety của final field — freeze action
12/75
Bài 12 / 75~10 phútConcurrency cơ bảnMiễn phí lượt xem

Initialization safety của final field — freeze action

Vì sao final field hiển thị đúng cho mọi thread kể cả khi publish qua data race: freeze action JLS §17.5, this không escape, và mẫu immutable holder.

TL;DR: Initialization safety là bảo đảm bộ nhớ mà final field cho không: nếu object được construct đúng cách (this không escape khỏi constructor), mọi thread thấy tham chiếu tới nó đều chắc chắn thấy các final field mang giá trị đã gán — kể cả khi publish qua một field thường, không volatile, không khóa. Cơ chế là freeze action (JLS §17.5): cuối constructor, các final field được "đóng băng" bằng một hàng rào bộ nhớ, cấm reorder khiến reader thấy object nửa vời. Object mutable không có đặc quyền này nên phải safe-publish; khi chỉ có "effectively immutable", mẫu immutable holder sau một tham chiếu volatile là cách công bố state mới an toàn.

Bài trước dừng lại ở một lời hứa: final không chỉ cấm gán lại mà còn mang một bảo đảm về bộ nhớ mà Java Memory Model cho không — ngoại lệ hiếm hoi của quy tắc safe publication. Bài này trả nợ lời hứa đó, và cho thấy phải làm gì khi bạn không có đặc quyền của final.

Một phép ẩn dụ để neo trực giác: gửi một kiện hàng. Object không final giống vừa nhét hàng vào thùng vừa dán nhãn địa chỉ song song — người giao có thể chộp thùng đi ngay khi nhãn vừa dán, lúc hàng bên trong chưa đủ. final field thì như một quy trình có kỷ luật: đóng đủ hàng → niêm phong thùng → rồi mới cho phép gửi đi; ai nhận thùng cũng chắc chắn thấy hàng đầy đủ.

Đời thườngInitialization safety
Kiện hàngObject đang construct
Nhét hàng vào thùngGhi final field trong constructor
Niêm phong thùng trước khi gửiFreeze action cuối constructor
Người nhận mở thùng, hàng đủThread khác thấy final field đúng giá trị
Vừa nhét vừa dán nhãn, không niêm phongPublish object không final → thấy nửa vời

1. Điều gì xảy ra khi publish object qua một data race?

Bài Visibility đã dựng nên vấn đề: thread tạo object rồi công bố tham chiếu cho thread khác mà không đồng bộ hóa, thread thứ hai có thể thấy tham chiếu đã gán nhưng bắt gặp object ở trạng thái chưa hoàn tất, vì ghi field và ghi tham chiếu có thể bị reorder. Đó là lý do object mutable cần được safe-publish: bằng volatile, bằng khóa, hay bằng một cấu trúc concurrent.

💡 Thử đoán

Trong đoạn dưới, một thread ghi holder = new Holder(42), thread kia chờ tham chiếu xuất hiện rồi đọc holder.value. Field value KHÔNG khai báo final, và holder là field thường (không volatile, không khóa). Reader có thể in ra những giá trị nào? Viết ra dự đoán của bạn trước khi đọc tiếp.

static class Holder {
    int value;                              // KHONG final -- khong co freeze guarantee
    Holder(int v) { this.value = v; }
}
static Holder holder;                       // field thuong: khong volatile, khong lock

public static void main(String[] args) {    // hai thread: mot ghi, mot doc
    new Thread(() -> {
        while (holder == null) { }          // doi tham chieu xuat hien
        System.out.println(holder.value);   // doc va in holder.value
    }).start();
    holder = new Holder(42);                // unsafe publication
}

Theo JMM, dòng holder = new Holder(42) thực chất là một chuỗi: cấp phát bộ nhớ, ghi value = 42, gán tham chiếu vào holder — hai bước sau được phép hoán đổi thứ tự. Reader vì thế có thể bắt tham chiếu trước khi value được ghi, rồi in ra 0 thay vì 42. Trên x86 chạy nghìn lần cũng khó tái hiện; chính sự "gần như luôn đúng" đó khiến lỗi hiểm — nó hợp lệ theo spec, chỉ lộ trên CPU yếu về ordering hoặc đúng lần JIT tối ưu mạnh tay.

2. Freeze action — đặc quyền JLS §17.5 của final field

JLS §17.5 (final field semantics) trao cho final field một đặc quyền: nếu object được construct đúng cách — this không escape khi constructor còn chạy — thì mọi thread nhìn thấy tham chiếu tới nó đều chắc chắn thấy các final field mang giá trị đã gán trong constructor, không cần đồng bộ hóa. Cơ chế đó tên là freeze action: theo §17.5.1, freeze của một final field xảy ra khi constructor kết thúc (dù kết thúc bình thường hay do exception). Về hiện thực, JVM chèn một hàng rào bộ nhớ (store-store barrier) giữa các lần ghi final field và lần ghi tham chiếu công bố object, cấm đúng kiểu reorder đã làm ví dụ trên in ra 0.

flowchart TB
    subgraph plain["Field thuong - unsafe publication"]
        W1["Writer: ghi value = 42"] --> P1["Gan tham chieu vao bien chia se"]
        P1 -. "JMM cho phep reorder 2 buoc tren" .- W1
        P1 --> R1["Reader: doc tham chieu"] --> V1["Co the thay value = 0 (default)"]
    end
    subgraph fin["final field - co freeze action"]
        W2["Constructor: ghi final value = 42"] --> F2["Freeze action cuoi constructor"] --> P2["Gan tham chieu"]
        P2 --> R2["Reader: doc tham chieu"] --> V2["LUON thay value = 42"]
    end
    V1 ~~~ W2
    style V1 fill:#FCA5A5
    style V2 fill:#6EE7B7
public final class Money {                         // khong setter, khong method sua field
    private final long amount;
    private final String currency;
    public Money(long amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
}

Một Money như thế truyền sang thread khác qua field thường, không volatile, không khóa, mà thread nhận vẫn luôn thấy amount cùng currency đúng. JLS còn bảo đảm thêm: nếu một final field trỏ tới object hoặc mảng khác, reader thấy phiên bản của object/mảng đó ít nhất cũng "mới" ngang với final field. Object mutable không bao giờ có những bảo đảm này.

3. Điều kiện đi kèm: this không escape khỏi constructor

Freeze action nằm ở cuối constructor; thread nào bắt được tham chiếu tới object trước thời điểm đó thì đứng ngoài mọi bảo đảm — có thể thấy final field còn mang giá trị mặc định. Hai cách rò kinh điển, đều trông vô hại:

public final class AuditedMoney {
    private final long amount;
    public AuditedMoney(long amount, EventBus bus) {
        bus.register(this);                 // RO 1: listener co the duoc goi NGAY
        new Thread(this::report).start();   // RO 2: thread moi cam 'this' som
        this.amount = amount;               // ... khi field con chua duoc ghi
    }
}

Đăng ký this vào listener/event bus, hay khởi động thread từ trong constructor và trao this cho nó (kể cả gián tiếp qua method reference hoặc inner class không static, vốn ngầm giữ this) — cả hai đều phát tán một object nửa vời. Quy tắc thực dụng: constructor chỉ gán field; đăng ký và khởi động làm sau khi construct xong, lý tưởng qua static factory method. Đây là điều kiện duy nhất không nhìn thấy qua chữ ký class.

4. Effectively immutable và immutable holder

Nhiều object không khai báo final chỉn chu nhưng thực tế không bao giờ bị sửa sau khi tạo — gọi là effectively immutable: kỹ thuật thì sửa được, theo cách dùng thì không ai sửa. Nó an toàn để chia sẻ miễn là được safe-publish; thiếu đặc quyền của final field, nó cần một cây cầu đồng bộ hóa lúc công bố: volatile field, ConcurrentHashMap, hay BlockingQueue như bài Confinement đã chỉ.

Sức mạnh thật sự của immutability lộ ra khi dữ liệu vừa chia sẻ vừa "thay đổi" theo thời gian. Mẹo nằm ở phân tách: giữ dữ liệu là immutable, để cái thay đổi là tham chiếu trỏ tới nó. "Cập nhật" nghĩa là tạo object mới rồi gán lại tham chiếu — mẫu immutable holder.

public record PriceTable(Map<String, Long> prices) {
    public PriceTable { prices = Map.copyOf(prices); }       // snapshot bat bien
    public long priceOf(String tier) {
        Long price = prices.get(tier);                       // nhan ve Long, kiem null tuong minh
        if (price == null) throw new IllegalArgumentException("Unknown tier: " + tier);
        return price;
    }
    public PriceTable withPrice(String tier, long price) {   // "wither": tra ve snapshot MOI
        Map<String, Long> next = new HashMap<>(prices);      // copy ban cu, sua tren ban copy
        next.put(tier, price);
        return new PriceTable(next);
    }
}

public class PriceBoard {
    private volatile PriceTable current;                     // tham chieu doi, doi tuong thi khong
    public PriceBoard(PriceTable initial) { this.current = initial; }
    public PriceTable snapshot() { return current; }          // reader: luon nhat quan
    public void publish(PriceTable next) { current = next; }  // "cap nhat" = thay nguyen holder
}

// Cap nhat gia VIP: khong sua bang cu, thay nguyen bang moi
board.publish(board.snapshot().withPrice("VIP", 750_000L));

Mỗi PriceTable là một snapshot bất biến của toàn bảng giá; reader gọi snapshot() nhặt một tham chiếu rồi đọc thoải mái, không bao giờ thấy bảng giá nửa cũ nửa mới. Method biến đổi trả về instance mới như withPrice — quen gọi là wither, theo cách đặt tên withX — chẳng có gì để đồng bộ: nó chỉ đọc một object bất biến và tạo object mới. Toàn bộ "sự thay đổi" dồn về đúng một điểm: lệnh gán vào current. Tham chiếu volatile lo phần visibility (snapshot mới hiển thị kịp thời cho reader); tính immutable của PriceTable lo phần nhất quán (không khoảnh khắc nào reader bắt gặp trạng thái dở dang) — công bố trạng thái mới mà không cần một khóa nào.

Nhưng mẫu này có ranh giới cứng: nó chỉ đúng khi việc gán current không phụ thuộc giá trị hiện tại của current, hoặc khi chỉ một thread được phép công bố. Nếu nhiều thread cùng đọc current, cùng dựng phiên bản kế tiếp từ nó, rồi cùng ghi đè, ta rơi vào read-modify-write và một cập nhật bốc hơi — đúng kiểu lost update mà immutability tưởng đã loại bỏ. Đây là chỗ Atomic & CAS nối tiếp: AtomicReference.compareAndSet vá đúng chỗ này.

5. Liên hệ các bài khác

  • Immutability — bài mẹ: ba điều kiện của immutable object và defensive copy; bài này mổ bảo đảm bộ nhớ mà điều kiện "mọi field final" mang lại.
  • Visibility — dựng nên vấn đề unsafe publication ở mục 1; freeze action là ngoại lệ hiếm cho phép bỏ qua safe publication.
  • Confinement — cây cầu BlockingQueue của bài đó là một cách safe-publish cho effectively immutable object.
  • volatile & synchronizedvolatile lo phần visibility cho immutable holder; bài đó vẽ bản đồ happens-before đầy đủ đứng sau chữ "hiển thị kịp thời".
  • Atomic & CASAtomicReference.compareAndSet vá đúng chỗ immutable holder vỡ khi nhiều thread cùng publish phiên bản kế tiếp.

6. Deep Dive

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: §17.5 là đoạn dễ đọc nhất của chương 17 — ví dụ mở đầu mô tả đúng kịch bản unsafe publication ở mục 1, đáng đọc để thấy spec nói gì bằng ngôn ngữ hình thức.

7. Tóm tắt

  • Initialization safety: nếu this không escape khỏi constructor, mọi thread thấy tham chiếu tới object đều thấy final field đúng giá trị — kể cả publish qua field thường trong data race.
  • Cơ chế là freeze action (JLS §17.5): freeze xảy ra khi constructor kết thúc; JVM chèn hàng rào bộ nhớ giữa ghi final field và ghi tham chiếu công bố, cấm reorder khiến reader thấy giá trị mặc định.
  • Điều kiện sống còn: this không được rò khi constructor còn chạy — đăng ký listener hay khởi động thread trong constructor đều phát tán object nửa vời. Làm sau construct, qua static factory.
  • Effectively immutable thiếu final nên không có freeze action → bắt buộc safe-publish (volatile/lock/concurrent structure).
  • Immutable holder "cập nhật" bằng thay nguyên snapshot sau tham chiếu volatile; vỡ khi nhiều thread cùng dựng phiên bản kế tiếp từ giá trị hiện tại (cần CAS).

8. Tự kiểm tra

Tự kiểm tra
Q1
final field cho guarantee gì khi object được publish qua một data race (field thường, không volatile, không lock)? Cơ chế nào đứng sau?
Nếu object được construct đúng cách — this không escape khi constructor còn chạy — thì mọi thread nhìn thấy tham chiếu tới nó đều chắc chắn thấy các final field với giá trị đã gán trong constructor, không cần một chút đồng bộ hóa nào. Cơ chế là freeze action của JLS §17.5: freeze xảy ra khi constructor kết thúc (dù bình thường hay do exception), và JVM chèn hàng rào bộ nhớ giữa các lần ghi final field và lần ghi tham chiếu công bố object, cấm kiểu reorder khiến reader bắt được tham chiếu trước khi field được ghi. Field thường không có đặc quyền này: reader qua data race có thể thấy 0 hoặc null.
Q2
Điều gì xảy ra khi this escape khỏi constructor? Vì sao đăng ký listener hay khởi động thread bên trong constructor là nguy hiểm?
Freeze action nằm ở cuối constructor. Thread nào bắt được tham chiếu tới object trước thời điểm đó thì đứng ngoài mọi bảo đảm của final field — nó có thể quan sát object ở trạng thái nửa vời, final field còn mang giá trị mặc định 0/null. Đăng ký this vào event bus nghĩa là listener có thể được gọi ngay lập tức từ thread khác, trước khi các dòng gán phía dưới constructor chạy xong; khởi động thread và trao this cho nó (kể cả ngầm qua method reference hay inner class không static) cũng vậy. Quy tắc thực dụng: constructor chỉ gán field; đăng ký và khởi động làm sau khi construct xong, lý tưởng qua static factory method.
Q3
Trong PriceBoard, vì sao reader không bao giờ thấy một bảng giá nửa cũ nửa mới? Và ranh giới nào khiến mẫu immutable holder vỡ?
Hai mảnh ghép chia nhau hai vấn đề. Tính immutable của PriceTable lo phần nhất quán: snapshot mà reader cầm không bao giờ đổi ruột dưới chân nó, nên hoặc thấy nguyên bảng cũ, hoặc nguyên bảng mới — không có trạng thái lai. Tham chiếu volatile lo phần visibility: snapshot mới được publish thì hiển thị kịp thời với mọi reader. Mẫu này vỡ khi việc gán current phụ thuộc giá trị hiện tại của current và có nhiều writer: nhiều thread cùng đọc snapshot, cùng dựng phiên bản kế tiếp, cùng ghi đè — read-modify-write quay lại và một cập nhật bốc hơi. Nó chỉ đúng khi một thread duy nhất publish, hoặc cập nhật không phụ thuộc giá trị cũ.
Q4
Freeze action nằm ở CUỐI constructor, không phải sau mỗi lần gán final field. Vì sao đặt hàng rào bộ nhớ ở đúng chỗ đó là đủ?
Bảo đảm cần có là: bất kỳ thread nào thấy tham chiếu tới object đều thấy MỌI final field đã mang giá trị. Chỉ cần một hàng rào store-store duy nhất, đặt sau lần ghi final field cuối cùng (tức cuối constructor) và trước khi tham chiếu được publish, là đủ: nó cấm mọi lần ghi final field bị reorder xuống sau điểm publish. Đặt hàng rào sau mỗi lần gán field thì vừa thừa (nhiều rào không thêm bảo đảm) vừa đắt hơn. Điều kiện đi kèm: this không escape trước cuối constructor — nếu tham chiếu thoát ra trước freeze, reader đứng trước hàng rào nên không được bảo đảm gì.
Q5
Effectively immutable khác immutable thật ở điểm nào về yêu cầu khi publish object cho thread khác?
Effectively immutable là object về kỹ thuật sửa được (field không final, có thể có setter) nhưng theo cách dùng thì không ai sửa sau khi tạo. Nó an toàn để chia sẻ, nhưng vì thiếu final field nên không có freeze action — không được hưởng initialization safety. Do đó nó bắt buộc phải được safe-publish qua một cây cầu đồng bộ hóa: gán vào field volatile, đặt vào ConcurrentHashMap hay BlockingQueue, hoặc trao tay trong khối khóa. Object immutable thật với mọi field final thì publish qua cả một field thường giữa data race vẫn hiển thị đúng — đó là khác biệt cốt lõi.

Bài tiếp theo: volatile & synchronized — hai cơ chế đồng bộ nội tại của Java

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

volatile và synchronized: hai cơ chế đồng bộ nội tại Java