99 Ngày Java — Ngày 29: Immutable & defensive copy
Ngày 20 ta gặp record — lớp bất biến do compiler viết hộ; Ngày 28 vừa khen Pattern thread-safe vì nó bất biến. Hôm nay ta trả lời câu hỏi nền tảng phía sau: immutability thực chất là gì, công thức nào để tự thiết kế một lớp bất biến đúng chuẩn, vì sao final một mình chưa đủ và phải có defensive copy — rồi khép lại bằng họ List.copyOf và món quà lớn nhất: thread-safe không cần lock. Đây là bài cuối về thiết kế lớp trước khi bước sang mini project tổng kết OOP.

Bất biến là gì và vì sao đáng theo đuổi
// Lớp "ngây thơ": mutable — ai giữ tham chiếu cũng SỬA ĐƯỢC
public class KhoangGia {
private BigDecimal min;
private BigDecimal max;
// getter + setter đầy đủ...
}
KhoangGia khuyenMai = new KhoangGia(100_000, 500_000);
giamGiaService.apDung(khuyenMai); // truyền vào một service
// ... 3 tuần sau, ở một file khác:
khuyenMai.setMax(1); // 💥 MỌI nơi đang giữ tham chiếu đều thấy max = 1
// Đây là "aliasing bug": một đối tượng, nhiều chủ — không ai biết ai đã sửa.
// Debug kiểu này chỉ trả lời được "giá sai" — không trả lời được "AI làm sai".
// Java đã chọn bất biến cho các kiểu nền tảng — không phải ngẫu nhiên:
String s = "abc"; // Ngày 9 — mọi "sửa" đều trả CHUỖI MỚI
LocalDate d = LocalDate.now(); // Ngày 26 — plusDays() trả NGÀY MỚI
BigDecimal b = BigDecimal.TEN; // Ngày 27 — add() trả SỐ MỚI
d.plusDays(7); // bẫy quen thuộc: KHÔNG đổi d — phải hứng kết quả
// Bất biến = trạng thái CHỐT tại thời điểm tạo. Muốn khác? Tạo đối tượng khác.Đối tượng mutable có nhiều chủ sở hữu ngầm: mọi nơi giữ tham chiếu đều sửa được, và mỗi chỗ sửa là một nghi phạm khi dữ liệu sai — lỗi aliasing khét tiếng khó truy vết vì nơi gây lỗi và nơi lộ lỗi cách nhau rất xa.
Đối tượng bất biến chốt trạng thái tại thời điểm tạo: đọc ở đâu, lúc nào, thread nào cũng thấy đúng một giá trị — suy luận về code trở thành cục bộ: chỉ cần đọc constructor là biết mọi điều về vòng đời đối tượng.
Java đặt cược vào bất biến từ lâu:
String(Ngày 9), toàn bộjava.time(Ngày 26),BigDecimal(Ngày 27) — và bẫy quen thuộcd.plusDays(7)không đổidchính là dấu hiệu nhận diện: API bất biến luôn trả kết quả mới, phải hứng lấy.
Công thức lớp immutable
// Công thức lớp immutable — 5 nguyên liệu
public final class KhoangGia { // 1. final class — chặn subclass phá luật
private final BigDecimal min; // 2. mọi field private final
private final BigDecimal max;
public KhoangGia(BigDecimal min, BigDecimal max) {
if (min.compareTo(max) > 0) // 3. validate NGAY trong constructor
throw new IllegalArgumentException("min > max");
this.min = min; // → không tồn tại đối tượng sai
this.max = max;
}
public BigDecimal min() { return min; } // 4. chỉ getter — KHÔNG setter
public BigDecimal max() { return max; }
public KhoangGia doiMax(BigDecimal maxMoi) { // 5. "sửa" = trả bản MỚI (wither)
return new KhoangGia(this.min, maxMoi);
}
}
// Record (Ngày 20) chính là công thức trên do compiler viết hộ:
public record KhoangGiaRecord(BigDecimal min, BigDecimal max) {
public KhoangGiaRecord { // compact constructor để validate
if (min.compareTo(max) > 0) throw new IllegalArgumentException("min > max");
}
}
// final class ✓ private final field ✓ không setter ✓ equals/hashCode/toString ✓ (Ngày 24)
// → Kiểu DỮ LIỆU thuần túy: mặc định chọn record; class thường khi cần giấu biểu diễn nội bộNăm nguyên liệu:
final class(chặn subclass thêm trạng thái mutable — Ngày 14), mọi fieldprivate final(Ngày 13, 18), không setter, validate trong constructor để đối tượng sai không thể tồn tại, và wither — method "sửa" trả bản sao mới thay vì đổi tại chỗ.Validate-trong-constructor là nâng cấp tư duy quan trọng: thay vì kiểm tra
if (range.min() > range.max())rải rác ở nơi dùng, bất biến cho phép kiểm một lần duy nhất ở nơi tạo — mọiKhoangGiađang sống đều hợp lệ, theo đúng nghĩa đen.Record (Ngày 20) là công thức này ở dạng một dòng — kèm
equals/hashCode/toStringchuẩn (Ngày 24). Quy tắc chọn: kiểu dữ liệu thuần → record; cần giấu biểu diễn nội bộ hoặc thêm invariant phức tạp → class thường theo công thức.
final chưa đủ — defensive copy
// final CHƯA ĐỦ: final chỉ khóa THAM CHIẾU, không khóa đối tượng nó trỏ tới
public final class DonHang {
private final List<String> sanPham; // final... nhưng List thì mutable!
public DonHang(List<String> sanPham) {
this.sanPham = sanPham; // ❌ giữ THẲNG list của người gọi
}
public List<String> sanPham() {
return sanPham; // ❌ phát THẲNG list nội bộ ra ngoài
}
}
List<String> gio = new ArrayList<>(List.of("Bàn phím"));
DonHang don = new DonHang(gio);
gio.add("Màn hình 49 inch"); // 💥 cửa sau 1: sửa qua tham chiếu ĐƯA VÀO
don.sanPham().clear(); // 💥 cửa sau 2: sửa qua tham chiếu LẤY RA
// Defensive copy — đóng cả hai cửa:
public final class DonHang {
private final List<String> sanPham;
public DonHang(List<String> sanPham) {
this.sanPham = List.copyOf(sanPham); // ✅ copy khi VÀO
}
public List<String> sanPham() {
return sanPham; // ✅ đã bất biến — trả thẳng, khỏi copy
}
}
// Mảng không có bản bất biến — copy cả hai chiều:
this.diem = diem.clone(); // vào
public int[] diem() { return diem.clone(); } // ra
// Record cũng KHÔNG miễn nhiễm — component mutable thì tự copy như thường:
public record DonHang(List<String> sanPham) {
public DonHang { sanPham = List.copyOf(sanPham); } // compact constructor copy khi vào
}finalchỉ khóa tham chiếu — không khóa đối tượng phía sau: fieldfinal Listvẫnadd/clearđược như thường. Lớp chứa field mutable có hai cửa sau: tham chiếu đưa vào (constructor) và tham chiếu lấy ra (getter) — đóng cả hai mới thật sự bất biến.Copy khi vào:
List.copyOftrong constructor cắt đứt liên hệ với collection của người gọi; nhờ bản copy đã bất biến, getter trả thẳng field không cần copy lần nữa. Với mảng — không có dạng bất biến — phảiclone()cả hai chiều.Record không miễn nhiễm: record chỉ làm field
final, component kiểuList/Date/mảng vẫn cần compact constructor tự copy — đây là câu hỏi phỏng vấn ưa thích để kiểm tra hiểu record thật hay chỉ thuộc cú pháp.
List.copyOf và các bản sao bất biến
// Ba cấp độ "danh sách không sửa được" — khác nhau ở chỗ QUAN TRỌNG
List<String> goc = new ArrayList<>(List.of("a", "b"));
// 1. Collections.unmodifiableList — chỉ là TẤM KÍNH nhìn vào list gốc
List<String> kinh = Collections.unmodifiableList(goc);
kinh.add("c"); // UnsupportedOperationException — qua kính không sửa được
goc.add("c"); // 💥 nhưng list GỐC sửa được — và kính THẤY thay đổi!
kinh.size(); // 3 — "unmodifiable" ≠ "immutable"
// 2. List.copyOf — BẢN SAO bất biến thật sự (Java 10+)
List<String> copy = List.copyOf(goc);
goc.add("d"); // copy vẫn y nguyên — đã CẮT ĐỨT khỏi gốc
// Thông minh: nếu nguồn ĐÃ là immutable list thì trả luôn chính nó — copy lặp = miễn phí
List.copyOf(copy) == copy; // true
// 3. List.of (Ngày 8) — tạo mới từ phần tử; cùng họ bất biến với copyOf
List<String> of = List.of("x", "y");
// Cả họ List.of / List.copyOf / Map.copyOf / Set.copyOf đều GHÉT null:
List.copyOf(Arrays.asList("a", null)); // NullPointerException — lộ bug sớm
// Stream.toList() (Ngày sau, phần Stream) cũng trả list KHÔNG SỬA ĐƯỢC — đừng .add() vào
// Quy tắc chọn: nhận collection từ ngoài → List.copyOf khi VÀO;
// unmodifiableList chỉ khi CẦN "cửa sổ sống" nhìn dữ liệu đang thay đổiCollections.unmodifiableListchỉ là tấm kính: chặn sửa qua nó nhưng list gốc đổi thì kính thấy theo — "unmodifiable" nói về cái view, không nói về dữ liệu. Rất nhiều bug sinh ra từ việc nhầm nó với bất biến thật.List.copyOf(Java 10) là bản sao bất biến đúng nghĩa — cắt đứt khỏi nguồn, và miễn phí khi nguồn đã bất biến (trả chính nó, không copy lặp). Cả họof/copyOftừ chốinullngay lúc tạo — triết lý lộ bug sớm thay vì NPE mờ ám về sau.Chọn theo nhu cầu: nhận dữ liệu từ ngoài vào lớp bất biến →
copyOf; cần cửa sổ sống nhìn dữ liệu đang thay đổi (hiếm) →unmodifiableview; và nhớStream.toList()trả list không sửa được — đừngaddvào kết quả stream.
Lợi ích lớn nhất — và cái giá
// 1. Thread-safe MIỄN PHÍ — bất biến thì không có "ghi", nên không có tranh chấp
private static final KhoangGia GIA_CHUAN = // chia sẻ giữa mọi thread, không lock
new KhoangGia(BigDecimal.ZERO, BigDecimal.valueOf(999));
// (Pattern của Ngày 28 thread-safe cũng chính vì Pattern bất biến)
// 2. Key an toàn cho HashMap/HashSet (Ngày 24)
Map<DiemToaDo, String> banDo = new HashMap<>();
DiemToaDo p = new DiemToaDo(10, 20);
banDo.put(p, "kho hàng");
// Nếu DiemToaDo mutable: p.setX(99) → hashCode đổi → entry "LẠC" trong map vĩnh viễn
// Bất biến → hashCode đóng băng → không bao giờ lạc
// 3. Truyền qua tầng lớp thoải mái — không cần copy phòng thủ ở MỌI ranh giới
// (chỉ lớp immutable phải copy MỘT lần lúc nhận; sau đó ai cầm cũng vô hại)
// Cái giá phải trả — và cách xử lý:
// - Mỗi thay đổi = một object mới → GC làm việc nhiều hơn.
// Thực tế: object trẻ chết sớm là món GC xử lý RẺ NHẤT; đo trước khi tối ưu.
// - "Sửa 5 field" = chuỗi wither dài → dùng Builder khi khởi tạo phức tạp
// - Vòng lặp nối chuỗi ngàn lần → về mutable CÓ KIỂM SOÁT: StringBuilder (Ngày 21)
// quy tắc: mutable làm biến CỤC BỘ trong một method thì vô hại —
// nguy hiểm chỉ bắt đầu khi tham chiếu mutable RÒ RỈ ra ngoàiThread-safe miễn phí là phần thưởng lớn nhất: không có thao tác ghi thì không có tranh chấp — chia sẻ đối tượng bất biến giữa các thread không cần lock, không cần hiểu memory model. Đây là lý do
Pattern,LocalDate,Stringdùng chung được thoải mái, và là nền móng ta sẽ dựa vào ở phần concurrency sau này.Bất biến còn là điều kiện để làm key tử tế trong
HashMap/HashSet(Ngày 24): key mutable đổihashCodesau khi put là entry lạc vĩnh viễn — key bất biến đóng bănghashCode, loại hẳn lớp bug này.Cái giá — nhiều object hơn cho GC — thường rẻ hơn lo ngại: object trẻ chết sớm là món GC xử lý nhanh nhất; chỉ tối ưu khi đã đo. Điểm cân bằng thực dụng: bất biến ở ranh giới (tham số, kết quả, field chia sẻ), mutable cục bộ trong thân method (
StringBuilder— Ngày 21) — miễn tham chiếu không rò rỉ ra ngoài.
Bài tập nhỏ
Viết lớp
TaiKhoanmutable vớisetSoDu, tạo bug aliasing: hai service cùng giữ một tham chiếu, một bên sửa — rồi chuyển sang immutable với withercongThem(BigDecimal)và quan sát bug biến mất.Lớp
LichHoccó fieldList<LocalDate>: viết phiên bản rò rỉ cả hai cửa (giữ thẳng list vào, trả thẳng list ra), chứng minh sửa được từ ngoài, rồi vá bằngList.copyOf— bao nhiêu chỗ phải copy?Chứng minh
unmodifiableListlà tấm kính: sửa list gốc và in view; lặp lại vớiList.copyOfđể thấy khác biệt.Tạo
record DiemToaDo(int x, int y)làm keyHashMap; viết thêm bản class mutable tương đương, put rồisetX, và chỉ ra entry "lạc" bằngcontainsKey.Record
DonHang(List<String> sanPham)— viết test chứng minh record chưa bất biến sâu, rồi thêm compact constructor copy và chạy lại test.
Kết luận
Gói lại trong bốn ý: bất biến nghĩa là trạng thái chốt lúc tạo — xóa cả họ bug aliasing; công thức gồm final class + private final + không setter + validate constructor + wither, và record là dạng rút gọn; final không khóa được đối tượng phía sau tham chiếu nên field mutable cần defensive copy khi vào (List.copyOf — bản sao thật, khác unmodifiable — tấm kính); đổi lại ta được thread-safe không lock và key an toàn cho HashMap. Ngày 30 — bài cuối giai đoạn OOP — ta gom toàn bộ 4 trụ cột vào một mini project quản lý thư viện: thiết kế domain, tổ chức package, và áp dụng mọi thứ từ Ngày 11 đến hôm nay. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


