Backend

99 Ngày Java — Ngày 29: Immutable & defensive copy

SSite Admin
25 tháng 08, 2026 11 phút đọc 0 lượt xem
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.

Sketchnote Ngày 29: Immutable & defensive copy — công thức lớp bất biến, defensive copy hai chiều, List.copyOf vs unmodifiableList và lợi ích thread-safe

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ộc d.plusDays(7) không đổi d chí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 field private 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ọi KhoangGia đ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/toString chuẩ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
}
  • final chỉ khóa tham chiếu — không khóa đối tượng phía sau: field final List vẫn add/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.copyOf trong 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ải clone() cả hai chiều.

  • Record không miễn nhiễm: record chỉ làm field final, component kiểu List/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 đổi
  • Collections.unmodifiableList chỉ 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/copyOf từ chối null ngay 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) → unmodifiable view; và nhớ Stream.toList() trả list không sửa được — đừng add và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ài
  • Thread-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, String dù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 đổi hashCode sau khi put là entry lạc vĩnh viễn — key bất biến đóng băng hashCode, 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 TaiKhoan mutable với setSoDu, 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 wither congThem(BigDecimal) và quan sát bug biến mất.

  • Lớp LichHoc có field List<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ằng List.copyOf — bao nhiêu chỗ phải copy?

  • Chứng minh unmodifiableList là tấm kính: sửa list gốc và in view; lặp lại với List.copyOf để thấy khác biệt.

  • Tạo record DiemToaDo(int x, int y) làm key HashMap; viết thêm bản class mutable tương đương, put rồi setX, và chỉ ra entry "lạc" bằng containsKey.

  • 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!

S

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.

Bài viết liên quan

99 Ngày Spring — Ngày 29: Auditing với Spring Data JPA

BaseEntity với @CreatedDate/@LastModifiedDate, AuditorAware cho @CreatedBy, những chỗ auditing không chạy (bulk update, JDBC) — và cách test tất định bằng DateTimeProvider.

25 thg 8, 20269 phút0
99 Ngày Java — Ngày 28: Regex trong Java

Pattern & Matcher, cú pháp dùng hằng ngày, group có tên để trích xuất, replaceAll/split với các bẫy — và ranh giới nơi regex nên dừng lại.

24 thg 8, 202611 phút7
99 Ngày Spring — Ngày 28: Transaction & @Transactional

ACID, proxy đứng sau @Transactional, propagation REQUIRED vs REQUIRES_NEW, quy tắc rollback với bất ngờ checked exception — và bẫy self-invocation.

24 thg 8, 202612 phút10