Immutability: Thread safety bằng cách không thay đổi
Thread safety bằng triệt tính mutable: điều kiện immutable, initialization safety của final, cạm bẫy field mutable trong record, và immutable holder.
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. String pool và Integer cache chia sẻ được toàn JVM chính nhờ bất biến, còn record cho cú pháp gọn nhưng không miễn trừ defensive copy.
1. Vì sao một object không bao giờ đổi thì an toàn với mọi thread?
Bài trước khép lại bằng một thừa nhận: confinement chỉ đủ khi ta thật sự có thể không chia sẻ một dữ liệu. Nhưng có những dữ liệu 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. Với chúng, con dao thứ nhất cùn; vẫn còn con dao thứ hai, cắt vào tính từ còn lại.
Gốc của mọi rắc rối nằm ở hai tính từ "shared" và "mutable". Confinement triệt cái thứ nhất; immutability triệt cái thứ hai. Dữ liệu chia sẻ mà không bao giờ đổi thì đọc 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 thread nào bắt gặp thread khác đang sửa giữa chừng. Atomicity và visibility — hai vấn đề nền tảng của bài Thread Safety — 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. Đây là chiến lược thứ hai trong bốn, và phần thưởng không kèm cái giá thường trực của khóa.
Nhưng Java không có từ khóa nào để đóng băng một object; tính immutable dựng nên từ ba ràng buộc phải đồng thời đúng. Thứ nhất, mọi field khai báo final, không gì sửa state sau constructor. Thứ hai, this không escape khi constructor còn chạy, vì thread bắt được tham chiếu tới một object nửa vời có thể thấy nó chưa hoàn chỉnh. Thứ ba, dễ quên nhất: nếu object giữ tham chiếu tới object mutable khác, không thread nào được sửa object ấy, và nó cũng không được rò tham chiếu đó ra ngoài.
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à "object không đổi": final chỉ bảo đảm tham chiếu không trỏ đi đâu khác, không nói gì về việc object đượ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; // RO: giu nguyen tham chieu cua caller
}
public List<String> tags() {
return tags; // RO: trao thang tham chieu noi bo ra ngoai
}
}
Caller vẫn giữ tham chiếu tới đúng cái List đã truyền vào và add được bất cứ lúc nào; người gọi tags() cũng nhận về tham chiếu sống. final khóa mũi tên, không khóa thứ ở đầu mũi tên — mọi đường ra-vào của trạng thái mutable đều phải bị bịt.
2. String và Integer — immutable quen thuộc quanh ta
Bạn đã hưởng lợi từ immutable object từ ngày đầu học Java. String là ví dụ nổi tiếng nhất: không method nào sửa nội dung — toUpperCase, substring, concat đều trả về một instance mới, 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 — hàng trăm class cùng viết "OK" — được JVM intern thành đúng một instance dùng chung, giữa mọi thread, không một khóa nào. 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, tên class, đường dẫn file đều thành lỗ hổng check-then-act của bài Thread Safety. Cũng nhờ giá trị không đổi mà String dám cache hashCode: 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 giá trị từ -128 đến 127. Hàng nghìn thread cùng cầm 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. Điều gì cho chúng đặc quyền đó ở mức memory model? final.
3. final và initialization safety
final không chỉ 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 — ngoại lệ hiếm hoi của quy tắc safe publication.
3.1 Unsafe publication — khi reader thấy object dở dang
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 field bên trong còn giá trị mặc định (0, null), 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.
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); // 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 — 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. 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.
3.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: cuối constructor, mọi final field được "đóng băng" — 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
style V1 fill:#FCA5A5
style V2 fill:#6EE7B7public 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. Object mutable không bao giờ có điều đó.
3.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. 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. Từ Java 16, record gói trọn khuôn đó vào một dòng: component final, một canonical constructor, các accessor, và equals/hashCode/toString dựa trên giá trị.
public record Seat(String section, int row, int number) { }
Vỏn vẹn dòng đó cho ta một object bất biến, so sánh theo giá trị, an toàn để hàng nghìn thread cùng đọc — và vì component là final, nó thừa hưởng nguyên initialization safety ở mục trước.
Nhưng record chỉ đóng băng tham chiếu component, không đóng băng thứ chúng trỏ tới — đúng cái bẫy ở mục 1, nay đội lốt cú pháp gọn đến mức dễ ru ngủ. Component kiểu mutable như List, Map, hay 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"); // sua tu ben ngoai -- info.tags() da doi
info.tags().add("hacked"); // sua qua accessor -- cung mot List song
info trông như immutable, nhưng info.tags() trả về đúng cái ArrayList caller vẫn cầm. Chỉ một component mutable là cả record mất tính bất biến. Lời giải: compact constructor defensive copy lúc nhận, accessor trả 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 luc nhan: cat day voi List cua caller
} // copyOf bat bien -> accessor mac dinh tra ra cung an toan
}
List.copyOf (Java 10+) tạo bản sao bất biến, cắt đứt liên hệ với List caller truyền vào, đồng thời khiến accessor trả ra thứ không ai add được. Với mảng thì không có bản bất biến sẵn, phải clone cả lúc nhận lẫn lúc trả, hoặc đừng phơi mảng ra ngoài. Nguyên tắc không đổi: 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.
5. 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); // tier la -> null: KHONG unbox mu quang
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ột bẫy nhỏ trong priceOf: 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 sang long trên null ném NullPointerException ngay tại dấu return, 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 null tường minh (hoặc getOrDefault) đổ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ả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, và chính nó đặt nền cho bài sau: 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 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 tăng dần theo từng booking nên vốn dĩ mutable: mỗi lần đặt vé là đọc số hiện tại, kiểm còn chỗ, rồi ghi tăng — compound action trói sold với capacity của Event. Đổi sang ConcurrentHashMap cũng không cứu: từng thao tác trên map thành thread-safe, nhưng cụm check-then-act vẫn không nguyên tử, invariant "không vượt capacity" vẫn bắc cầu qua nhiều thao tác rời rạc.
6. Liên hệ các bài khác
- Thread Safety — nơi gọi tên hai vấn đề mà immutability tước mất điều kiện phát sinh, và điểm mặt bốn chiến lược; Atomicity và Visibility mổ từng vấn đề.
- Confinement — chiến lược thứ nhất, cắt tính "shared"; cây cầu
BlockingQueuecủa bài đó chính là một cách safe-publish cho effectively immutable object. - volatile & synchronized —
volatilelo 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 & CAS —
AtomicReference.compareAndSetvá đú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 5: vì sao
ConcurrentHashMapkhông cứu được check-then-act củaBookingService.
7. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JLS §17.5 — final Field Semantics — đặc tả freeze action và điều kiện "không đọc được tham chiếu trước khi constructor kết thúc".
- JLS §17.5.3 — Subsequent Modification of final Fields — vì sao sửa final field qua reflection phá mọi bảo đảm.
- Java Concurrency in Practice (Goetz et al.), §3.4-3.5 — immutability, safe publication và immutable holder pattern.
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 3.1, đáng đọc để thấy spec nói gì bằng ngôn ngữ hình thức.
8. Tóm tắt
- Immutability tước mất tính "mutable": dữ liệu không bao giờ đổi thì an toàn với mọi thread, không cần đồng bộ hóa.
- Ba điều kiện phải cùng đúng: mọi field
final;thiskhông escape khi construct; không rò tham chiếu tới trạng thái mutable bên trong. finalcho không initialization safety (freeze action, JLS §17.5) — object construct đúng cách hiển thị đầy đủ cho mọi thread qua cả một field thường.Stringpool vàIntegercache chia sẻ được toàn JVM chính vì bất biến;recordvẫn cần defensive copy khi component là kiểu mutable.- Immutable holder "cập nhật" bằng thay nguyên snapshot sau một tham chiếu
volatile; nó vỡ khi nhiều thread cùng dựng phiên bản kế tiếp từ giá trị hiện tại.
Nhưng số vé đã bán của một sự kiện vừa shared vừa mutable: không thể giam, không thể đóng băng. Khi cả hai con dao đầu đều cùn, chỉ còn một đường — canh gác mọi truy cập bằng synchronization, chủ đề bài tiếp theo: volatile và synchronized.
9. Tự kiểm tra
Q1Vì 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.
Q2final 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?▸
Q3Vì 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?▸
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?▸
Q5Trong 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ỡ?▸
Q6Phiê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?▸
Q7Effectively immutable khác immutable thật ở điểm nào về yêu cầu khi publish object cho thread khác?▸
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
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