Java Internals & Concurrency/Immutability: Thread safety bằng cách không thay đổi
6/39
Bài 6 / 39~14 phútConcurrency cơ bảnMiễn phí lượt xem

Immutability: Thread safety bằng cách không thay đổi

Thread safety bằng cách triệt tính mutable: điều kiện để một object là immutable, final và initialization safety, record cùng cạm bẫy field mutable, và immutable holder để cập nhật bằng cách thay thế.

TL;DR: Immutability triệt tiêu tính "mutable": dữ liệu không bao giờ đổi sau khi construct thì đọc lúc nào cũng ra cùng kết quả — atomicity lẫn visibility đều mất điều kiện phát sinh. Ba điều kiện phải cùng đúng: mọi field final, this không escape khỏi constructor, và không rò tham chiếu tới trạng thái mutable bên trong. final không chỉ cấm gán lại — JLS §17.5 cho nó một "freeze action" cuối constructor, nên object construct đúng cách publish qua field thường vẫn hiển thị đầy đủ cho mọi thread; field thường không có đặc quyền đó (reader có thể thấy 0/null). StringInteger cache được chia sẻ toàn JVM chính nhờ bất biến. record cho cú pháp gọn nhưng không miễn trừ defensive copy. "Cập nhật" dữ liệu chia sẻ = thay nguyên snapshot sau một tham chiếu volatile.

1. Giới thiệu

Bài trước khép lại bằng một thừa nhận: confinement chỉ là đủ khi ta thật sự có thể không chia sẻ một dữ liệu. Nhưng có những dữ liệu, theo bản chất bài toán, buộc phải đến tay nhiều thread — một Event mà hàng nghìn request cùng đọc, một bảng giá dùng chung cho cả hệ thống. Với chúng, con dao thứ nhất cùn: ta không thể cắt bỏ tính "shared". Vẫn còn con dao thứ hai, và nó cắt vào tính từ còn lại.

Gốc của mọi rắc rối luôn nằm ở hai tính từ "shared" và "mutable". Confinement triệt cái thứ nhất; immutability triệt cái thứ hai. Nếu một dữ liệu được chia sẻ nhưng không bao giờ thay đổi sau khi khởi tạo, thì đọc nó lúc nào cũng cho cùng một kết quả. Không có cập nhật thì không có lost update; không có trạng thái dở dang thì không có chuyện một thread bắt gặp một thread khác đang sửa giữa chừng. Atomicity và visibility, cả hai vấn đề nền tảng của bài thread safety, đều bốc hơi cùng lúc — không phải vì ta giải được chúng, mà vì ta tước mất điều kiện để chúng phát sinh. Một đối tượng immutable an toàn với bất kỳ số lượng thread nào, mãi mãi, mà không cần một dòng đồng bộ hóa nào.

Đây là chiến lược thứ hai trong bốn chiến lược, và nó đẹp ở chỗ phần thưởng không kèm cái giá thường trực của khóa. Nhưng "không bao giờ thay đổi" là một lời hứa khó giữ hơn vẻ ngoài. Java không có một từ khóa duy nhất để đóng băng một đối tượng; tính immutable được dựng nên từ vài ràng buộc phải đồng thời đúng, và chỉ cần một field rò ra một tham chiếu mutable là cả lâu đài sụp. Bài này đi từ định nghĩa cho chặt thế nào là immutable, tới bảo đảm mà ngôn ngữ cho không qua final, tới record như carrier immutable tự nhiên cùng cạm bẫy của nó, và cuối cùng tới cách "cập nhật" một dữ liệu mà không hề sửa nó.

2. Thế nào là một immutable object

Một đối tượng là immutable khi trạng thái của nó không thể thay đổi sau khi construct xong. Nghe đơn giản, nhưng để bảo đảm điều đó cứng cáp, cần ba điều kiện cùng đúng, và bỏ sót bất kỳ điều nào là đủ để phá vỡ.

Thứ nhất, mọi field phải được khai báo final, và state của đối tượng không được thay đổi sau khi constructor chạy xong. Không có setter, không có method nào sửa field. Thứ hai, đối tượng phải được khởi tạo trọn vẹn trong constructor — không để this escape ra ngoài khi construct còn dở dang, vì một thread khác bắt được tham chiếu tới một đối tượng nửa vời có thể thấy nó ở trạng thái chưa hoàn chỉnh. Thứ ba, và đây là điều dễ quên nhất: nếu đối tượng giữ tham chiếu tới một đối tượng mutable khác, không thread nào được phép sửa đối tượng mutable ấy, và bản thân nó cũng không được rò tham chiếu đó ra ngoài.

💡 Cách nhớ

Ba điều kiện của immutable object: (1) mọi field final, không gì sửa state sau constructor; (2) this không escape khi construct còn dở; (3) trạng thái mutable bên trong không rò ra ngoài — defensive copy lúc nhận, bản không sửa được lúc trả.

Điều kiện thứ ba là ranh giới tinh tế giữa "field không đổi" và "đối tượng không đổi". Một field final chỉ bảo đảm cái tham chiếu không trỏ đi đâu khác; nó không nói gì về việc đối tượng được trỏ tới có đổi ruột hay không.

public final class EventTags {
    private final List<String> tags;

    public EventTags(List<String> tags) {
        this.tags = tags;               // RÒ: giữ nguyên tham chiếu của caller
    }

    public List<String> tags() {
        return tags;                    // RÒ: trao thẳng tham chiếu nội bộ ra ngoài
    }
}

tagsfinal, class có vẻ immutable, nhưng nó không hề. Caller vẫn giữ tham chiếu tới đúng cái List đã truyền vào và có thể add thêm phần tử bất cứ lúc nào; người gọi tags() cũng nhận về tham chiếu sống và sửa được. Cái final chỉ khóa mũi tên, không khóa thứ ở đầu mũi tên. Một đối tượng chỉ thật sự immutable khi mọi đường ra-vào của trạng thái mutable đều bị bịt — bằng defensive copy lúc nhận, và bằng bản sao hoặc view không sửa được lúc trả.

3. String và Integer — immutable quen thuộc quanh ta

Trước khi tự dựng immutable object, hãy nhận ra bạn đã hưởng lợi từ chúng suốt từ ngày đầu học Java. String là immutable object nổi tiếng nhất: không một method nào của nó sửa nội dung — toUpperCase, substring, concat đều trả về một instance mới, còn chuỗi gốc giữ nguyên vĩnh viễn.

String s = "olhub";
String upper = s.toUpperCase();   // tao instance MOI -- "olhub" khong he doi
System.out.println(s);            // van in "olhub"

Integer a = 127, b = 127;         // autoboxing goi Integer.valueOf -> dung cache
System.out.println(a == b);       // true: CUNG mot object trong cache -128..127

Tính bất biến đó là điều kiện sống còn cho hai cơ chế chia sẻ quy mô toàn JVM. Thứ nhất là String pool: mọi string literal giống nhau trong chương trình — hàng trăm class cùng viết "OK" — được JVM intern thành đúng một instance dùng chung. Chia sẻ một object cho toàn bộ chương trình, giữa mọi thread, mà không một khóa nào — điều này chỉ an toàn vì String không bao giờ đổi ruột. Hãy tưởng tượng String mutable: một chỗ sửa "OK" thành "KO" là mọi nơi dùng literal đó đổi theo, và mọi chỗ dùng String làm key của map, làm tên class, làm đường dẫn file đều thành lỗ hổng — kiểm tra xong, kẻ khác sửa, rồi mới dùng, đúng hình mẫu check-then-act của bài Thread Safety. Cũng nhờ giá trị không đổi mà String dám cache hashCode của nó: tính một lần, dùng mãi.

Thứ hai là Integer cache: Integer.valueOf (mà autoboxing gọi ngầm) trả về object dùng chung từ cache cho các giá trị từ -128 đến 127. Hàng nghìn thread cùng cầm chung một object Integer 127 — an toàn tuyệt đối, vì không ai sửa được ruột nó. Bài học chung: immutable là giấy phép để chia sẻ thoải mái. Câu hỏi tiếp theo là điều gì cho StringInteger đặc quyền đó ở mức memory model — câu trả lời nằm ở final.

4. final và initialization safety

Lý do final đáng được nhấn mạnh không chỉ vì nó cấm gán lại. Nó còn mang một bảo đảm về bộ nhớ mà Java Memory Model cho không, và đây là một ngoại lệ hiếm hoi của quy tắc safe publication.

4.1 Unsafe publication — khi reader thấy object dở dang

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

Viết hẳn thành chương trình chạy được để thấy hình dạng của lỗi:

public class UnsafePublication {
    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) {
        new Thread(() -> {
            while (holder == null) { }      // doi tham chieu xuat hien
            System.out.println(holder.value);   // JMM cho phep in 0 thay vi 42
        }).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 — và hai bước sau được phép hoán đổi thứ tự khi không có đồng bộ hóa. Reader vì thế có thể bắt được tham chiếu trước khi value được ghi, và in ra 0. Trên x86 với JVM hiện nay bạn sẽ chạy cả nghìn lần không tái hiện được — chính sự "gần như luôn đúng" đó khiến lỗi này hiểm: nó hợp lệ theo spec, và sẽ chọn đúng kiến trúc CPU yếu về ordering hoặc đúng lần JIT tối ưu mạnh tay để xuất hiện.

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

JMM trao cho final field một đặc quyền, đặc tả ở JLS §17.5 (final field semantics): nếu một đối tượng được construct đúng cách — this không escape khi constructor còn chạy — thì bất kỳ thread nào nhìn thấy tham chiếu tới đối tượng đó đều được bảo đảm thấy các final field của nó với giá trị đã gán trong constructor, mà không cần một chút đồng bộ hóa nào. Cơ chế đứng sau có tên: freeze action. Tại thời điểm constructor kết thúc, mọi final field được "đóng băng" — về mặt cài đặt, 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ố đối tượng, cấm đúng kiểu hoán đổi thứ tự đã làm UnsafePublication in ra 0. Reader đi tới object qua tham chiếu đọc được sau freeze thì chắc chắn thấy giá trị đã gán.

So sánh hai con đường publish:

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"]
        R1 --> 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"]
        F2 --> P2["Gan tham chieu vao bien chia se"]
        P2 --> R2["Reader: doc tham chieu"]
        R2 --> V2["LUON thay value = 42"]
    end
public final class Money {
    private final long amount;
    private final String currency;

    public Money(long amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
    // ... không setter, không method sửa field
}

Một Money như thế có thể được tạo ở một thread rồi truyền sang thread khác qua một field thường, không volatile, không khóa, và thread nhận vẫn luôn thấy amount cùng currency đúng. Đây là điều mà một đối tượng mutable không bao giờ có.

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

Đặc quyền freeze gắn chặt vào việc construct cho đúng: this không được rò ra ngoài khi constructor còn chạy. Freeze action nằm ở cuối constructor; nếu một thread khác bắt được tham chiếu tới object trước thời điểm đó, nó đứng ngoài mọi bảo đảm — có thể thấy final field còn mang giá trị mặc định, vì với nó object chưa hề được "đóng băng".

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: listener co the duoc goi NGAY,
                                         // truoc khi dong duoi gan xong
        this.amount = amount;
    }
}

public final class PollingPrice {
    private final long amount;

    public PollingPrice(long amount) {
        new Thread(this::report).start();   // RO: thread moi cam 'this'
        this.amount = amount;               // ... khi field con chua duoc ghi
    }
}

Đăng ký this vào một listener/event bus, hay khởi động một thread từ bên 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ữ tham chiếu tới 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; mọi việc đăng ký, khởi động, phát tán làm sau khi construct xong, lý tưởng là qua một static factory method tạo object xong xuôi rồi mới register/start. Đây chính là điều kiện thứ hai trong ba điều kiện ở mục 2 — và là điều kiện duy nhất không nhìn thấy được qua chữ ký class, chỉ thấy được khi đọc thân constructor.

5. record — carrier immutable tự nhiên

Viết tay một immutable class đúng kiểu khá lắm lời: field final, constructor gán hết, một loạt getter, rồi equals, hashCode, toString cho khớp. Từ Java 16, record đóng gói trọn cái khuôn đó vào một dòng. Một record sinh ra các component là final, một canonical constructor gán chúng, các accessor, và equals/hashCode/toString dựa trên giá trị — đúng bộ xương của một value object immutable.

public record Seat(String section, int row, int number) { }

Vỏn vẹn dòng đó cho ta một đối tượng bất biến, so sánh theo giá trị, an toàn để hàng nghìn thread cùng đọc. Vì các component là final, nó cũng thừa hưởng nguyên initialization safety ở mục trước: một Seat công bố qua field thường vẫn hiển thị đầy đủ và đúng cho mọi thread.

Nhưng record chỉ đóng băng các tham chiếu component, không đóng băng thứ chúng trỏ tới — đúng cái bẫy ở mục 2, nay đội lốt một cú pháp gọn gàng đến mức dễ ru ngủ. Một component kiểu mutable như List, Map, hay một mảng vẫn rò trạng thái sửa được ra cả hai đầu:

public record EventInfo(String id, List<String> tags) { }

var tags = new ArrayList<>(List.of("music", "vip"));
var info = new EventInfo("concert-01", tags);
tags.add("cancelled");          // sửa từ bên ngoài — info.tags() đã đổi
info.tags().add("hacked");      // sửa qua accessor — cùng một List sống

info trông như immutable, nhưng info.tags() trả về đúng cái ArrayList mà caller vẫn cầm, và accessor mặc định cũng trao thẳng tham chiếu nội bộ. Chỉ một component mutable là cả record mất tính bất biến.

Lời giải là một compact constructor để defensive copy lúc nhận, và một accessor trả ra bản không sửa được lúc xuất:

public record EventInfo(String id, List<String> tags) {
    public EventInfo {
        tags = List.copyOf(tags);       // copy lúc nhận: cắt dây với List của caller
    }
    // List.copyOf đã cho ra một List bất biến, nên accessor mặc định trả nó cũng an toàn
}

List.copyOf (Java 10+) tạo một bản sao bất biến, cắt đứt liên hệ với List mà caller truyền vào và đồng thời khiến accessor trả ra một thứ không ai add được. Với mảng thì không có bản bất biến sẵn, nên phải clone cả lúc nhận lẫn lúc trả, hoặc tốt hơn là đừng phơi mảng ra ngoài. Nguyên tắc không đổi so với một immutable class viết tay: mọi trạng thái mutable phải bị bịt ở cả lối vào lẫn lối ra; record cho ta cú pháp, không cho ta miễn trừ trách nhiệm ấy.

6. Effectively immutable và immutable holder

Không phải đối tượng nào cũng được khai báo final chỉn chu, nhưng nhiều đối tượng trên thực tế không bao giờ bị sửa sau khi tạo. Một đối tượng như thế gọi là effectively immutable: kỹ thuật thì nó sửa được, nhưng theo cách dùng thì không ai sửa. Một effectively immutable object an toàn để chia sẻ giữa nhiều thread miễn là nó được safe-publish — vì 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ố, chẳng hạn đặt nó vào một volatile field, một ConcurrentHashMap, hay một BlockingQueue như bài Confinement đã chỉ.

Sức mạnh thật sự của immutability lộ ra khi ta cần một dữ liệu vừa chia sẻ vừa "thay đổi" theo thời gian. Nghe như mâu thuẫn với cả bài, nhưng mẹo nằm ở chỗ phân tách: giữ bản thân dữ liệu là immutable, và để cái thay đổi là tham chiếu trỏ tới nó. "Cập nhật" không còn nghĩa là sửa đối tượng tại chỗ, mà là tạo nguyên một đối tượng mới mang trạng thái mới rồi gán lại tham chiếu. Đây là 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);          // tier la -> null, KHONG duoc unbox mu quang
        if (price == null) {
            throw new IllegalArgumentException("Unknown tier: " + tier);
        }
        return price;
    }
}

public class PriceBoard {
    private volatile PriceTable current;        // tham chiếu thay đổi, đối tượng thì không

    public PriceBoard(PriceTable initial) { this.current = initial; }

    public PriceTable snapshot() { return current; }   // reader: luôn nhất quán

    public void publish(PriceTable next) {              // "cập nhật" = thay nguyên holder
        current = next;
    }
}

Một bẫy nhỏ trong priceOf đáng một câu chú thích: viết gọn return prices.get(tier); với kiểu trả về long trông vô hại, nhưng Map.get trả về Long — tier không tồn tại thì nhận null, và phép auto-unboxing từ Long sang long trên null ném NullPointerException ngay tại dấu return, một NPE khó truy vì dòng code chẳng có chỗ nào trông như dereference. Nhận về biến Long rồi kiểm tra null tường minh (hoặc getOrDefault khi có giá trị mặc định hợp lý) tốn thêm hai dòng nhưng đổi được thông điệp lỗi nói thẳng vấn đề.

Mỗi PriceTable là một snapshot bất biến của toàn bộ bảng giá. Reader gọi snapshot() nhặt được một tham chiếu và đọc thoải mái, chắc chắn không bao giờ thấy một bảng giá nửa cũ nửa mới, vì cái nó cầm sẽ không đổi ruột dưới chân nó. Người cập nhật dựng một PriceTable mới hoàn chỉnh rồi gán một phát vào current.

Để việc "dựng phiên bản mới từ phiên bản cũ" bớt lắm lời, immutable object thường tự cung cấp các method biến đổi trả về instance mới — quen gọi là wither (theo cách đặt tên withX, họ hàng chức năng với toUpperCase của String):

public record PriceTable(Map<String, Long> prices) {
    // ... constructor va priceOf nhu tren ...

    public PriceTable withPrice(String tier, long price) {
        Map<String, Long> next = new HashMap<>(prices);   // copy ban cu
        next.put(tier, price);                            // sua tren ban copy
        return new PriceTable(next);                      // tra ve snapshot MOI
    }
}

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

Bản thân withPrice không có gì để đồng bộ — nó chỉ đọc một object bất biến và tạo một 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 — bảo đảm snapshot mới hiển thị kịp thời cho reader — còn tính immutable của PriceTable lo phần nhất quán: không có khoảnh khắc nào reader bắt gặp trạng thái dở dang. Hai mảnh ghép đó hợp lại cho ta một cách công bố trạng thái mới mà không cần một khóa nào.

Mẫu này gọn và hấp dẫn, nhưng nó có một ranh giới cứng, và chính ranh giới ấy đặt nền cho bài sau. Nó chỉ đúng khi việc gán current không phụ thuộc vào giá trị hiện tại của current, hoặc khi chỉ một thread duy nhất đượ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 dựa trên nó, rồi cùng ghi đè, ta lại rơi vào read-modify-write và một cập nhật sẽ bốc hơi — đúng kiểu lost update mà immutability tưởng đã loại bỏ.

Đây cũng là lý do immutability một mình không vá được race condition của BookingService trong TicketFlow. Số vé đã bán phải tăng dần theo từng booking, nên nó vốn dĩ mutable theo thời gian; mỗi lần đặt vé là đọc số hiện tại, kiểm còn chỗ, rồi ghi tăng — một compound action trói sold với capacity của Event. Đổi HashMap thành ConcurrentHashMap cũng không cứu được: từng thao tác trên map trở nên thread-safe, nhưng cụm check-then-act vẫn không nguyên tử, và invariant "không vượt capacity" vẫn bắc cầu qua nhiều thao tác rời rạc mà không có gì quây chúng lại thành một khối. Hai thread vẫn có thể cùng đọc thấy còn một chỗ rồi cùng bán.

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

  • Thread Safety — định nghĩa atomicity và visibility, hai vấn đề mà immutability tước mất điều kiện phát sinh; bốn chiến lược cũng gọi tên ở đó.
  • Confinement — chiến lược thứ nhất, cắt tính "shared"; cây cầu BlockingQueue của bài đó chính 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.
  • Delegation & concurrent collections — phát triển tiếp nhận xét cuối mục 6: vì sao ConcurrentHashMap không cứu được check-then-act của BookingService.

8. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

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

9. Tổng kết

Immutability trả lời câu hỏi thread safety bằng cách tước mất tính "mutable": một dữ liệu không bao giờ đổi thì đọc lúc nào cũng cho cùng kết quả, an toàn với bất kỳ số thread nào mà không cần đồng bộ hóa. Một đối tượng thật sự immutable đòi ba điều cùng đúng — mọi field final, this không escape khi construct, và không rò tham chiếu tới bất kỳ trạng thái mutable nào nó giữ. final không chỉ cấm gán lại; nó còn cho không initialization safety, một ngoại lệ của safe publication khiến đối tượng construct đúng cách hiển thị đầy đủ cho mọi thread qua cả một field thường. record đóng gói khuôn value object immutable vào một dòng, nhưng vẫn để hở đúng cái bẫy cũ khi component là kiểu mutable, nên cần defensive copy ở lối vào và bản không sửa được ở lối ra. Và khi cần một dữ liệu vừa chia sẻ vừa biến thiên, immutable holder cho ta cách "cập nhật" bằng thay nguyên đối tượng sau một tham chiếu volatile, đạt nhất quán mà không tốn một khóa.

Immutability là đủ khi ta có thể dựng dữ liệu một lần rồi không bao giờ sửa, hoặc khi mỗi thay đổi là thay nguyên một snapshot và chỉ một thread công bố. Nhưng có những trạng thái không cho ta cái quyền đó. Số vé đã bán của một sự kiện phải được nhiều thread cùng nhìn thấy và phải tăng dần theo từng booking — nó vừa shared vừa mutable, không thể giam, không thể đóng băng. Khi cả hai con dao đầu tiên đều cùn, khi buộc phải giữ cả "shared" lẫn "mutable" cùng lúc, chỉ còn một đường: canh gác mọi truy cập bằng synchronization. Đó là chủ đề bài tiếp theo, bắt đầu từ hai cơ chế đồng bộ mà Java cài sẵn ngay trong ngôn ngữ — volatilesynchronized.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao một record chứa List vẫn có thể không immutable, dù mọi component của record đều final? Vá thế nào cho kín cả hai chiều?

final trên component chỉ khóa tham chiếu — mũi tên không trỏ đi đâu khác — chứ không khóa object ở đầu mũi tên. Một component kiểu List rò trạng thái sửa được ra cả hai đầu: caller vẫn cầm tham chiếu tới đúng cái list đã truyền vào và add được từ bên ngoài; accessor mặc định lại trao thẳng tham chiếu nội bộ cho người gọi.

Vá bằng compact constructor làm defensive copy lúc nhận — tags = List.copyOf(tags) — cắt dây với list của caller; vì List.copyOf cho ra một list bất biến, accessor mặc định trả nó ra cũng an toàn luôn. Với mảng thì không có bản bất biến sẵn, phải clone cả hai chiều hoặc đừng phơi mảng ra ngoài.

Q2
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: cuối constructor, các final field được đóng băng — 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.
Q3
Vì sao JVM dám cho hàng trăm class cùng chia sẻ đúng một instance String trong String pool, giữa mọi thread, mà không một khóa nào?
Vì String immutable tuyệt đối: không method nào sửa nội dung, mọi phép biến đổi đều trả về instance mới, và các field bên trong là final nên hưởng initialization safety — thread nào nhìn thấy tham chiếu cũng thấy nội dung hoàn chỉnh. Dữ liệu không bao giờ đổi thì đọc đồng thời từ bao nhiêu thread cũng cho cùng kết quả, không có cập nhật để mất, không có trạng thái dở dang để bắt gặp. Nếu String mutable, một chỗ sửa literal là mọi nơi dùng chung đổi theo, và mọi chỗ dùng String làm map key, tên class, đường dẫn file đều thành cửa sổ check-then-act.
Q4
Đ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.
Q5
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ũ.
Q6
Phiên bản đầu của priceOf viết gọn return prices.get(tier); với kiểu trả về long. Vì sao dòng này có thể ném NullPointerException dù không có dấu chấm dereference nào?
Map.get trả về Long (kiểu boxed), còn method khai báo trả về long (kiểu nguyên thủy), nên compiler chèn ngầm một phép auto-unboxing — thực chất là gọi longValue() trên kết quả. Khi tier không tồn tại, get trả về null, và lời gọi longValue() ngầm trên null ném NPE ngay tại dấu return. Lỗi khó truy vì dòng code không có dereference nào nhìn thấy được. Phòng bằng cách nhận về biến Long rồi kiểm tra null tường minh, hoặc getOrDefault khi có giá trị mặc định hợp lý — đừng unbox kết quả Map.get khi key có thể vắng mặt.
Q7
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 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