Java Internals & Concurrency/Delegation & Concurrent Collections: Tái dùng lớp đã thread-safe
11/39
Bài 11 / 39~13 phútConcurrency cơ bảnMiễn phí lượt xem

Delegation & Concurrent Collections: Tái dùng lớp đã thread-safe

Đạt thread safety bằng cách ủy thác cho component có sẵn: khái niệm delegation (đủ vs vỡ), ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentSkipList.

TL;DR: Cách đạt thread safety ít rủi ro nhất là delegation — giao việc đồng bộ cho component đã thread-safe (ConcurrentHashMap, CopyOnWriteArrayList...) thay vì tự cầm khóa. Delegation đủ khi mỗi method chỉ là một thao tác đơn lên một component độc lập; nó vỡ khi xuất hiện invariant bắc cầu nhiều component, hoặc khi nghiệp vụ là compound action ghép từ nhiều lời gọi đơn. ConcurrentHashMap khóa mịn theo từng bin cộng CAS, nên reader và writer trên các key khác nhau không chặn nhau, lại cung cấp sẵn compound action nguyên tử (putIfAbsent, compute, merge) — đổi lại size() chỉ gần đúng và iterator là weakly consistent. Pitfall lớn nhất: tưởng rằng collection thread-safe thì chuỗi thao tác bạn ghép trên nó cũng nguyên tử — không hề.

1. Giới thiệu

Bài trước khép lại khối Synchronization bằng một cảm giác hơi nặng nề. ReentrantLock, Condition, ReadWriteLock, StampedLock cho ta đủ thứ mà synchronized không có - tryLock, timeout, interruptible, fairness, optimistic read - nhưng cái giá là ta phải tự cầm lấy lock, tự unlock trong finally, tự suy luận xem biến nào canh bởi khóa nào, tự lo không để một đường truy cập nào lọt khóa. Tự canh gác thì đúng được, nhưng dễ sai và tốn công. Và mỗi lần một class mới ra đời, ta lại phải lặp lại toàn bộ bài toán đó từ đầu.

Có một lối đi khác, và nó thường là lối bền nhất: đừng tự viết đồng bộ. Cách đạt thread safety ít rủi ro nhất không phải là khóa cho khéo hơn, mà là giao trách nhiệm đồng bộ cho một component đã thread-safe rồi, để class của ta thừa hưởng sự an toàn đó mà không phải tự dựng giao thức khóa nào. Ý tưởng này có tên: delegation - ủy thác. Và building block hay dùng nhất của hướng ủy thác nằm sẵn trong java.util.concurrent: các concurrent collection.

Series đã chạm vào delegation một lần, rất ngắn, ở bài thread safety: khi BookingService quản lý state của bộ đếm bằng một AtomicLong, cả class trở nên thread-safe mà không cần một dòng synchronized nào, vì nó đã giao việc đồng bộ cho một đối tượng vốn đã được kiểm chứng. Bài này lấy chính ý đó, đẩy nó thành một chiến lược thiết kế đầy đủ, và chỉ ra ranh giới mà tại đó nó vỡ.

2. Delegation: giao việc đồng bộ cho component đã thread-safe

Nguyên tắc gọn đến mức dễ bị xem nhẹ: nếu toàn bộ state mutable của một class được giữ trong những đối tượng đã thread-safe, và class không áp thêm bất kỳ invariant nào lên cách các đối tượng đó phối hợp với nhau, thì class tự động thread-safe mà không cần thêm khóa nào của riêng nó. Nó "mượn" thread safety từ thành phần bên trong.

Hãy lấy một ví dụ thật trong TicketFlow. Phần catalog sự kiện chỉ làm hai việc: đăng ký một Event mới, và tra một Event theo id. State của nó vỏn vẹn một map.

public class EventRegistry {
    private final ConcurrentMap<String, Event> events = new ConcurrentHashMap<>();

    public void register(Event event) {
        Objects.requireNonNull(event, "event must not be null");
        if (events.putIfAbsent(event.id(), event) != null) {
            throw new IllegalArgumentException("Sự kiện đã tồn tại: " + event.id());
        }
    }

    public Event find(String eventId) {
        return events.get(eventId);
    }

    public boolean exists(String eventId) {
        return events.containsKey(eventId);
    }
}

Không một từ khóa synchronized nào ở đây, cũng không một ReentrantLock nào. EventRegistry thread-safe vì nó giao trọn việc đồng bộ cho ConcurrentHashMap. Mọi method của nó chỉ là một lời gọi đơn xuống map, mà mỗi lời gọi đó tự nó đã nguyên tử và an toàn. Class không thêm chút state nào ngoài cái map, nên cũng không có gì để nó tự canh.

So với phiên bản Monitor Pattern ở bài volatile & synchronized - nơi BookingService quây mọi method bằng một private lock object - đây là một sự nhẹ nhõm thấy rõ. Ta không phải nghĩ về khóa, không phải lo find có quên đồng bộ hay không, không phải sợ một method tương lai vô tình đọc events mà không giữ khóa. Cái map đã lo hết.

Có thể hình dung delegation như một nhà hàng thuê bảo vệ chuyên nghiệp đứng cửa. Chủ nhà hàng không cần tự học võ, không cần đích thân kiểm soát đám đông; ông giao việc canh cửa cho người đã được huấn luyện, và an ninh của cả quán dựa trên năng lực của người đó. Chừng nào việc giữ trật tự chỉ gói trong phạm vi "ai được vào cửa nào", người bảo vệ lo trọn. Vấn đề chỉ nảy sinh khi an ninh đòi hỏi phối hợp nhiều cửa cùng lúc - và đó đúng là chỗ delegation bắt đầu hụt hơi.

3. Khi delegation đủ, và khi nó vỡ

Điều kiện để delegation đủ đã ẩn trong phát biểu ở trên, nhưng cần lôi ra cho rõ, vì đây chính là ranh giới hay bị vượt mà không hay biết: class không được áp thêm invariant nào lên các component đã thread-safe của nó. Nói cách khác, mỗi component phải độc lập, và mỗi method công khai phải hoàn thành công việc chỉ bằng một thao tác đơn lên một component.

EventRegistry thỏa điều kiện đó. State của nó là đúng một map độc lập; không có ràng buộc nào nói rằng giá trị trong map này phải khớp với một biến nào khác. Vì vậy delegation đủ.

Nhưng hãy thử thêm một tính năng tưởng vô hại. Giả sử ta muốn EventRegistry vừa giữ map sự kiện, vừa giữ một biến count đếm số sự kiện đang mở bán, để dashboard đọc cho nhanh.

public class CountingRegistry {                     // @NotThreadSafe — đừng làm thế này
    private final ConcurrentMap<String, Event> events = new ConcurrentHashMap<>();
    private final AtomicInteger count = new AtomicInteger(0);
    // INVARIANT: count == events.size() (luôn luôn)

    public void register(Event event) {
        if (events.putIfAbsent(event.id(), event) == null) {
            count.incrementAndGet();                // cập nhật biến thứ hai, ở một bước riêng
        }
    }
}

Mỗi component ở đây tự nó vẫn thread-safe: eventsConcurrentHashMap, countAtomicInteger. Vậy mà class không còn thread-safe nữa. Vấn đề là ta vừa áp một invariant bắc cầu hai component: count phải luôn bằng events.size(). Giữa lệnh putIfAbsent và lệnh incrementAndGet tồn tại một cửa sổ, dù chỉ vài nano giây, trong đó map đã có entry mới còn count thì chưa tăng. Một thread khác đọc đúng vào cửa sổ đó sẽ thấy invariant bị vi phạm. Đây chính xác là bài học "atomic của một biến không phải atomic của invariant" từ bài thread safety, chỉ thay AtomicInteger đơn lẻ bằng các component thread-safe đơn lẻ - bản chất không đổi.

Khi delegation hụt hơi vì một invariant bắc cầu như thế, ta phải tự bổ sung đồng bộ cho riêng cụm thao tác đó, thường bằng cách bọc nó trong một khóa của chính class - vẫn là tư duy composition quen thuộc: lắp thêm một lớp đồng bộ quanh các mảnh có sẵn, thay vì viết lại chúng. Lúc này ta quay về Monitor Pattern cho phần lõi liên đới, và chỉ phần đó.

Một dạng hụt hơi tinh vi hơn không nằm ở chỗ ta thêm biến, mà ở chỗ một thao tác nghiệp vụ vốn dĩ là compound action trên cùng một component. Đây là kiểu lỗi che giấu kỹ nhất, vì nó trông y hệt delegation hợp lệ.

// check-then-act trên ConcurrentHashMap — vẫn dính race dù map thread-safe
Integer current = sold.get(eventId);                 // (1) đọc
if (current == null || current < event.capacity()) { // (2) kiểm tra
    sold.put(eventId, (current == null ? 0 : current) + 1);  // (3) ghi
}

Mỗi lời gọi getput ở đây đều nguyên tử và an toàn xét riêng lẻ. Nhưng ba bước ghép lại không tự thành một khối nguyên tử. Hai thread cùng đọc current = 0, cùng thấy còn chỗ, cùng put giá trị 1: lại đúng kịch bản check-then-act của BookingService.book ở bài thread safety, lost update tái diễn, dù collection đã thread-safe. Một collection thread-safe bảo đảm mỗi thao tác đơn của nó nguyên tử; nó không bảo đảm rằng một chuỗi nhiều thao tác do bạn ghép lại cũng nguyên tử. Ranh giới giữa "một object thread-safe" và "một thao tác thread-safe ở cấp client" mà bài thread safety đã cảnh báo, ở đây hiện ra một lần nữa.

May thay, với ConcurrentHashMap, ta không cần khóa ngoài để vá compound action loại này. Bản thân nó cung cấp sẵn các thao tác compound nguyên tử - và đó là lý do nó hơn hẳn các synchronized collection đời cũ.

4. synchronized collection vì sao không đủ

Trước khi java.util.concurrent ra đời ở Java 5, cách duy nhất để có một collection dùng được giữa nhiều thread là bọc collection thường qua các factory Collections.synchronizedMap, synchronizedList, synchronizedSet. Những wrapper này làm đúng một việc: đặt mỗi method công khai vào một khối synchronized khóa trên cùng một đối tượng. VectorHashtable đời cổ cũng theo đúng nguyên lý đó.

Cách này thread-safe theo nghĩa hẹp - mỗi thao tác đơn nguyên tử - nhưng nó vướng hai giới hạn nghiêm trọng đến mức trong code mới gần như không còn lý do để chọn nó.

Giới hạn thứ nhất là compound action, đúng vấn đề ở mục 3. Một synchronizedMap không cung cấp thao tác "kiểm tra rồi đặt nếu vắng" nguyên tử nào ở cấp dùng được; nếu muốn một check-then-act đúng, ta phải tự khóa từ phía client trên chính đối tượng wrapper, mà như bài Thread safety đã chỉ ra, client-side locking mong manh và dễ khóa nhầm khóa.

Giới hạn thứ hai, và là cái đau hơn trong thực tế, là iteration. Khi bạn duyệt một synchronizedMap bằng for-each hay iterator, từng lời gọi next được đồng bộ, nhưng khoảng giữa hai lời gọi thì không. Nếu một thread khác sửa map giữa chừng, iterator ném ConcurrentModificationException theo cơ chế fail-fast.

Map<String, Integer> sold = Collections.synchronizedMap(new HashMap<>());
// ...
for (var entry : sold.entrySet()) {        // có thể ném ConcurrentModificationException
    report(entry.getKey(), entry.getValue());   //   nếu thread khác put() giữa chừng
}

Để duyệt an toàn, bạn buộc phải khóa trên toàn bộ map suốt cả vòng lặp - synchronized (sold) { for (...) ... } - và trong khoảng đó mọi thread khác muốn chạm vào map đều bị chặn cứng. Với một map nóng phục vụ hàng nghìn request, khóa toàn cục suốt một vòng duyệt là một thảm họa về throughput. Một wrapper khóa toàn cục như vậy biến mọi truy cập, dù đọc hay ghi, dù vào key nào, thành một hàng đợi nối đuôi nhau qua đúng một khóa duy nhất.

Các hệ thống hiện đại thì khác. java.util.concurrent từ Java 5 mang đến một dòng collection được thiết kế lại từ gốc cho concurrency, chấp nhận một sự đánh đổi sòng phẳng về ngữ nghĩa để đổi lấy throughput thật.

5. ConcurrentHashMap: khóa per-bin và compound action nguyên tử

ConcurrentHashMap là concurrent collection được dùng nhiều nhất, và đáng để hiểu vì sao nó nhanh. Thay vì một khóa duy nhất canh toàn bộ map như synchronizedMap, nó áp dụng một cơ chế khóa mịn hơn rất nhiều. Nội bộ map chia thành nhiều bin (bucket), và phần lớn thao tác chỉ cần đồng bộ trên đúng cái bin chứa key liên quan, thường bằng compare-and-swap không khóa cho trường hợp không tranh, và chỉ khóa trên đầu bin khi thật sự có va chạm.

Hệ quả thực tế là hai thread thao tác trên hai key rơi vào hai bin khác nhau gần như không chặn nhau chút nào. Nhiều reader luôn chạy song song với nhau và với cả writer. Đây là khác biệt căn bản với synchronizedMap, nơi mọi truy cập đều nối đuôi qua một khóa. Trên một map nóng có nhiều thread cùng chạm, chênh lệch throughput giữa hai cách là rất lớn.

Cái giá của khóa mịn là ngữ nghĩa của vài thao tác toàn cục yếu đi, và đây là chỗ một senior cần đọc kỹ. size()isEmpty() của ConcurrentHashMap trả về một con số gần đúng, có thể đã cũ ngay khi bạn nhận được, vì map không khóa toàn cục để đếm chính xác. Quan trọng hơn, iterator của nó là weakly consistent chứ không fail-fast: bạn duyệt được mà không bao giờ bị ném ConcurrentModificationException, iterator phản ánh trạng thái map tại một thời điểm nào đó kể từ khi nó được tạo và có thể - nhưng không bắt buộc - thấy các thay đổi xảy ra sau đó. Với phần lớn nhu cầu thực tế như duyệt để báo cáo hay quét định kỳ, đánh đổi này là hời: bạn được duyệt mà không cần khóa và không sợ exception, chỉ phải chấp nhận rằng ảnh chụp không phải một snapshot đông cứng tuyệt đối.

Phần thực sự khiến ConcurrentHashMap thay thế được cả synchronizedMap lẫn nhu cầu tự khóa là tập thao tác compound nguyên tử nó cung cấp sẵn. Đây chính là lời giải cho check-then-act ở mục 3. putIfAbsent gói trọn "nếu vắng thì đặt" thành một bước nguyên tử. compute, computeIfAbsent, computeIfPresentmerge cho phép bạn đọc-sửa-ghi một entry trong một thao tác duy nhất, với lambda được chạy nguyên tử per-key.

💡 Cách nhớ

Component thread-safe bảo đảm từng câu nguyên tử, không bảo đảm cả đoạn văn bạn ghép từ nhiều câu. Muốn cả đoạn nguyên tử: hoặc dùng compound action có sẵn của component (putIfAbsent, compute, merge), hoặc tự khóa quanh cả đoạn.

Với nhu cầu đếm thuần túy - tăng một counter per-key, không có invariant nào phải kiểm tra - thì merge còn gọn hơn cả compute:

// Dem luot xem trang chi tiet cua moi event — atomic per-key, khong can khoa ngoai
viewCount.merge(eventId, 1L, Long::sum);

Nếu key chưa có, merge đặt giá trị 1L; nếu đã có, nó áp Long::sum lên giá trị cũ và 1L - tất cả trong một thao tác nguyên tử trên đúng bin của key. Cả bài toán "thống kê đếm theo key giữa nhiều thread" thu về một dòng. Lưu ý phân vai: merge cho đếm vô điều kiện; còn khi phải kiểm tra rồi mới ghi - như chặn bán vượt capacity - thì dùng compute để nhét cả bước kiểm tra vào trong thao tác nguyên tử, như ví dụ dưới đây.

EventRegistry ở mục 2 đã dùng putIfAbsent để biến đăng ký sự kiện thành một check-then-act nguyên tử. Còn phần lõi đặt vé, vốn là check-then-act kinh điển từng phá hỏng v0, giờ gói gọn trong một lời gọi compute:

public Booking book(String eventId, String userId) {
    Event event = registry.find(eventId);
    if (event == null) {
        throw new IllegalArgumentException("Không có sự kiện: " + eventId);
    }

    int seatNumber = sold.compute(eventId, (k, current) -> {
        int cur = (current == null) ? 0 : current;
        if (cur >= event.capacity()) {        // check ...
            throw new SoldOutException(eventId);
        }
        return cur + 1;                       // ... và act, cùng nằm trong một thao tác nguyên tử
    });

    return new Booking(eventId, userId, seatNumber);
}

Lambda truyền vào compute được ConcurrentHashMap chạy dưới khóa của đúng cái bin chứa eventId. Trong suốt thời gian lambda chạy, không thread nào khác chen vào được giữa bước kiểm tra cur >= capacity và bước ghi cur + 1 cho cùng key đó. Kịch bản hai thread cùng đọc 0 rồi cùng ghi 1 không còn xảy ra: thread thứ hai chỉ chạy lambda sau khi thread thứ nhất đã ghi xong, nên nó đọc đúng giá trị mới và đúng đắn ném SoldOutException khi hết chỗ. Invariant "không vượt capacity" được giữ, mà ta không viết lấy một dòng synchronized.

So với Monitor Pattern ở v1, nơi cả book khóa this và do đó hai khách đặt vé cho hai sự kiện khác nhau vẫn phải xếp hàng chờ nhau, lời giải compute này còn cho ta thứ mà khóa thô không có: hai sự kiện rơi vào hai bin khác nhau không chặn nhau. Ta vừa đúng vừa song song, chỉ bằng cách giao việc đồng bộ per-key cho map.

Đổi lại có một kỷ luật phải giữ với lambda của compute và họ hàng. Nó chạy bên trong khóa của bin, nên nó phải ngắn, thuần, và tuyệt đối không được làm hai việc nguy hiểm: không tái vào chính map đó với một key khác, và không đi giành thêm một khóa nào khác. Vi phạm điều thứ nhất, javadoc nói thẳng là hành vi không được phép: tùy thao tác, JVM hoặc ném IllegalStateException, hoặc rơi vào vòng lặp retry không bao giờ dừng; vi phạm điều thứ hai mở đường cho deadlock. Lambda lý tưởng chỉ tính một giá trị mới từ giá trị cũ rồi trả về, không I/O, không tính toán dài hơi, không side-effect ra ngoài.

6. Cơ chế bên dưới ConcurrentHashMap

"Khóa per-bin" nghe gọn, nhưng đáng mở nắp capo xem nó được hiện thực thế nào, vì chính các chi tiết này giải thích mọi ngữ nghĩa lạ của ConcurrentHashMap - từ get không khóa cho tới size gần đúng.

Nội bộ map là một mảng Node[] - mỗi ô là một bin, mỗi bin là một danh sách liên kết các Node chứa cặp key-value có hash rơi vào ô đó. Cấu trúc này giống HashMap thường, nhưng hai field then chốt được khai báo đặc biệt: mảng table được đọc qua các thao tác volatile-read, và field val cùng next của mỗi Node đều là volatile. Đó là nền tảng cho điều đầu tiên.

Vì sao get() không cần khóa. Đường đọc của get chỉ gồm: đọc mảng table, nhảy tới bin theo hash, lần theo chuỗi next so khóa, trả về val. Mọi mắt xích trên đường đó đều là volatile read - và như bài volatile & synchronized đã dựng nền, volatile read thiết lập happens-before với volatile write tương ứng phía writer. Một writer vừa put xong giá trị mới vào val, mọi reader sau đó nhìn thấy ngay, không cần giành bất kỳ khóa nào. Reader vì thế chạy hoàn toàn song song với nhau và với writer - không phải vì "may mắn không va nhau", mà vì mô hình bộ nhớ bảo đảm điều đó.

Đường ghi: CAS trước, khóa sau. put xử lý theo hai nấc leo thang:

  1. Bin rỗng - trường hợp phổ biến nhất khi map đủ rộng: tạo Node mới rồi cắm vào đầu bin bằng một lệnh compare-and-swap, đúng cơ chế CAS mà bài Atomic & CAS đã mổ. Không khóa nào được giành; nếu CAS thất bại vì một thread khác vừa cắm trước, vòng lặp thử lại với trạng thái mới.
  2. Bin đã có node - tức hai key va nhau ở cùng ô: lúc này mới cần khóa, và khóa được giành bằng synchronized trên chính node đầu bin. Phạm vi khóa nhỏ tối đa: chỉ thread nào đụng đúng bin này mới phải xếp hàng, các bin còn lại không hề hay biết.

Nghĩa là ConcurrentHashMap hiện đại (từ Java 8) không có "mảng 16 khóa" như lời đồn từ thời segment cũ - đơn vị tranh chấp là từng bin một, và fast path thậm chí không khóa. Phác lại đường put dưới dạng giản lược:

// So do duong put cua ConcurrentHashMap (gian luoc tu putVal, Java 8+)
for (;;) {                                       // retry loop quen thuoc cua CAS
    Node<K,V>[] tab = table;                     // volatile read
    int i = (tab.length - 1) & hash;
    if (tab[i] == null) {
        if (casTabAt(tab, i, null, newNode)) {   // bin rong: CAS dau bin, khong khoa
            break;                               // thanh cong -> xong
        }
        // CAS truot: thread khac vua cam truoc -> lap lai voi trang thai moi
    } else {
        synchronized (tab[i]) {                  // va cham: khoa DUNG node dau bin nay
            // duyet chuoi: update val neu trung key, hoac noi node moi vao cuoi
        }
        break;
    }
}

Cái vòng for (;;) cộng CAS-thất-bại-thì-thử-lại chính là optimistic loop của bài Atomic & CAS, chỉ khác đích đến là một ô trong mảng thay vì một biến đơn.

Treeify khi bin quá dài. Nếu hash xấu dồn nhiều key vào một bin, danh sách liên kết dài ra và mỗi lần tra phải duyệt tuyến tính. Khi một bin vượt 8 node (và mảng đủ lớn), bin đó được chuyển thành cây đỏ-đen, đưa thao tác trên bin từ O(n) về O(log n) - tấm lưới an toàn trước cả hash collision vô tình lẫn cố ý.

Vì sao size() chỉ xấp xỉ. Đếm chính xác đòi hỏi đứng yên cả map - tức một khóa toàn cục, đúng thứ thiết kế này từ chối. Thay vào đó, số phần tử được cộng dồn vào một dải counter cell phân tán (cùng kỹ thuật striping của LongAdder): mỗi thread cập nhật một cell ít tranh chấp, size() chỉ cộng các cell lại tại một thời điểm không có gì đảm bảo đứng yên. Con số trả về đúng "cỡ đó", và có thể đã cũ ngay khi bạn cầm được nó - một đánh đổi có chủ đích, vì hiếm nghiệp vụ nào cần size chính xác tuyệt đối trên một map đang bị ghi dồn dập.

Đặt hai thiết kế cạnh nhau, khác biệt hiện rõ:

flowchart TB
    subgraph SM["synchronizedMap: 1 khoa ca map"]
        A1["Thread A: get(k1)"] --> LK["khoa duy nhat - moi thao tac xep hang"]
        A2["Thread B: put(k2)"] --> LK
        A3["Thread C: get(k3)"] --> LK
        LK --> HM["HashMap ben trong"]
    end
    subgraph CM["ConcurrentHashMap: khoa theo bin"]
        B1["Thread A: get(k1)"] -->|"volatile read, khong khoa"| BIN1["bin 3"]
        B2["Thread B: put(k2)"] -->|"CAS dau bin rong"| BIN2["bin 7"]
        B3["Thread C: put(k3)"] -->|"synchronized dau bin khi va cham"| BIN2
    end

Bên trái, ba thread nối đuôi qua một cổ chai dù chạm ba key khác nhau. Bên phải, hai thread chỉ thực sự tranh nhau khi cùng rơi vào bin 7 - và ngay cả khi đó, phần còn lại của map vẫn thông suốt. Đó là toàn bộ lý do chênh lệch throughput giữa hai cách tiếp cận trên một map nóng.

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: phần mở đầu javadoc của ConcurrentHashMap là một trong những đoạn javadoc đáng đọc nhất JDK — nó phát biểu chính xác các bảo đảm consistency mà bài này tóm tắt, kể cả những gì không được bảo đảm.

7. CopyOnWriteArrayListCopyOnWriteArraySet: snapshot khi ghi

Không phải workload nào cũng là một map đọc-ghi cân bằng. Có một mẫu rất phổ biến mà ConcurrentHashMap không phải lựa chọn tự nhiên nhất: một danh sách được đọc rất nhiều và sửa rất hiếm. Một danh sách listener đăng ký nhận sự kiện là ví dụ kinh điển - nó bị duyệt mỗi lần phát sự kiện, có thể hàng nghìn lần mỗi giây, nhưng chỉ thay đổi khi ai đó đăng ký hoặc hủy đăng ký, vốn họa hoằn lắm mới xảy ra.

Cho đúng mẫu read-heavy đó, java.util.concurrentCopyOnWriteArrayList và phiên bản set của nó là CopyOnWriteArraySet. Cơ chế nằm ngay trong cái tên: copy-on-write. Đối tượng giữ một mảng nội bộ bất biến; mọi thao tác sửa - add, set, remove - không sửa tại chỗ mà tạo hẳn một bản sao mới của mảng, áp thay đổi lên bản sao, rồi thay con trỏ trỏ sang mảng mới.

Cách này có một hệ quả đẹp cho phía đọc: đọc hoàn toàn không cần khóa, vì mảng mà reader đang nhìn là bất biến, không ai sửa được nó nữa. Và iterator của nó làm việc trên đúng cái mảng tại thời điểm iterator được tạo - một snapshot thật sự, đông cứng. Vì vậy duyệt không bao giờ ném ConcurrentModificationException, và bạn không cần khóa quanh vòng lặp dù có thread khác đang sửa danh sách giữa chừng; những thay đổi đó chỉ đơn giản không xuất hiện trong vòng duyệt hiện tại.

// Danh sách listener: đọc (duyệt để phát sự kiện) cực nhiều, ghi (đăng ký) cực hiếm
private final List<SaleListener> listeners = new CopyOnWriteArrayList<>();

public void addListener(SaleListener l) { listeners.add(l); }   // hiếm khi gọi

public void fireSale(Booking b) {
    for (SaleListener l : listeners) {     // không khóa, không CME, kể cả khi addListener chạy song song
        l.onSale(b);
    }
}

Cái giá phải trả lộ ngay từ cơ chế: mỗi lần ghi sao chép nguyên cả mảng. Với một danh sách lớn bị sửa thường xuyên, chi phí copy này nhanh chóng trở nên đắt đỏ và lượng rác sinh ra gây áp lực lên garbage collector. Vì thế copy-on-write chỉ hời khi số lần đọc áp đảo số lần ghi. Đặt nó vào một chỗ ghi nhiều, bạn sẽ biến mỗi lần ghi thành một thao tác tốn kém một cách vô lý. Quy tắc chọn rất gọn: read-heavy, write-rare thì copy-on-write; còn ghi đáng kể thì quay về ConcurrentHashMap hoặc một cấu trúc khác.

8. Các concurrent collection còn lại: sorted và non-blocking

Hai cấu trúc trên phủ phần lớn nhu cầu, nhưng java.util.concurrent còn vài lựa chọn đáng biết cho các tình huống cụ thể hơn, và một senior nên nhận ra khi nào chúng là câu trả lời đúng.

Khi bạn cần một map hoặc set vừa thread-safe vừa giữ thứ tự sắp xếp theo key, ConcurrentSkipListMapConcurrentSkipListSet là lời giải. Chúng là phiên bản concurrent của TreeMap/TreeSet, cài đặt bằng skip list thay vì cây cân bằng, và cung cấp các thao tác navigable như firstKey, floorEntry, ceilingKey, subMap - tất cả đều thread-safe. ConcurrentHashMap không giữ thứ tự nào; nếu nghiệp vụ đòi duyệt theo thứ tự key, ví dụ một bảng giá theo mốc thời gian cần tra "giá tại thời điểm gần nhất trước t", thì skip list map là thứ bạn muốn, đổi lại độ phức tạp logarit thay vì hằng số như hash map.

Khi bạn cần một queue hoặc deque thread-safe nhưng không muốn ngữ nghĩa blocking, ConcurrentLinkedQueueConcurrentLinkedDeque là lựa chọn non-blocking, dựa trên thuật toán lock-free với compare-and-swap. Chúng không bao giờ chặn thread gọi: offer luôn thành công ngay (queue không có giới hạn dung lượng), poll trả về null ngay nếu queue rỗng thay vì chờ. Đây là điểm phân biệt then chốt cần ghi nhớ: nếu thứ bạn cần là một thread chờ cho tới khi có phần tử để lấy, hoặc chờ cho tới khi có chỗ để đặt - tức producer và consumer cần phối hợp nhịp với nhau - thì non-blocking queue không phải công cụ. Lúc đó bạn cần BlockingQueue, và đó đúng là chủ đề bài kế tiếp.

Gom cả họ lại thành một bảng chọn nhanh:

Tình huốngNên dùngVì sao
Map đọc-ghi nóng, nhiều threadConcurrentHashMapKhóa per-bin + compound action nguyên tử
Danh sách read-heavy, write-rareCopyOnWriteArrayList/SetĐọc không khóa, iterator snapshot
Map/set cần thứ tự theo keyConcurrentSkipListMap/SetNavigable + thread-safe, đổi lấy O(log n)
Queue thread-safe, không cần chờConcurrentLinkedQueue/DequeLock-free, không bao giờ chặn
Producer–consumer cần phối hợp nhịpBlockingQueueChặn khi đầy/rỗng — bài kế tiếp

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

  • Thread safety — delegation là câu trả lời "đừng tự viết" cho các race check-then-act mà bài đó mổ xẻ; cảnh báo về client-side locking mong manh cũng bắt nguồn từ đó.
  • Volatile & synchronizedget() không khóa của ConcurrentHashMap đứng trên happens-before của volatile read/write; Monitor Pattern với private lock là phương án ta quay về khi delegation vỡ.
  • Atomic & CAS — fast path của put là một CAS retry loop; hiểu optimistic loop ở bài đó là hiểu vì sao bin rỗng không cần khóa.
  • Blocking Queues & Producer–Consumer — khi bài toán cần phối hợp nhịp chứ không chỉ chia sẻ dữ liệu, ConcurrentLinkedQueue nhường chỗ cho BlockingQueue.

10. Tổng kết

Cách đạt thread safety bền nhất thường không phải là tự viết đồng bộ cho khéo, mà là không tự viết: giao trách nhiệm đồng bộ cho một component đã thread-safe và để class thừa hưởng sự an toàn đó. Đó là delegation, và concurrent collection trong java.util.concurrent từ Java 5 là building block hay dùng nhất của hướng này.

Những điểm cần mang theo:

  • Nếu toàn bộ state mutable của một class nằm trong các component đã thread-safe, và class không áp invariant nào lên cách chúng phối hợp, thì class tự động thread-safe mà không cần khóa riêng. EventRegistry ủy thác trọn cho ConcurrentHashMap là ví dụ.
  • Delegation vỡ khi có một invariant bắc cầu nhiều component, hoặc khi một thao tác nghiệp vụ là compound action ghép từ nhiều lời gọi đơn. Component thread-safe bảo đảm mỗi thao tác đơn nguyên tử, không bảo đảm chuỗi thao tác do bạn ghép cũng nguyên tử. Khi đó phải tự bổ sung đồng bộ cho riêng cụm liên đới.
  • Collections.synchronizedXxxVector/Hashtable khóa toàn cục: compound action vẫn cần khóa client, và iteration buộc khóa cả map suốt vòng lặp nếu không sẽ dính ConcurrentModificationException fail-fast. Trong code mới gần như không còn lý do chọn chúng.
  • ConcurrentHashMap khóa mịn per-bin, nhiều reader và writer trên các key khác nhau không chặn nhau. Đổi lại size/isEmpty chỉ gần đúng và iterator là weakly consistent. Quan trọng nhất, nó cung cấp compound action nguyên tử sẵn - putIfAbsent, compute, computeIfAbsent, merge - với lambda chạy nguyên tử per-key; lambda đó phải ngắn, thuần, không tái vào map, không giành thêm khóa.
  • CopyOnWriteArrayList/Set hợp đúng workload read-heavy, write-rare: đọc không khóa, iterator là snapshot đông cứng không bao giờ ném CME, đổi lại mỗi lần ghi sao chép cả mảng.
  • ConcurrentSkipListMap/Set cho nhu cầu sorted thread-safe; ConcurrentLinkedQueue/Deque cho queue non-blocking lock-free.

Quay lại TicketFlow: v2 thay map giữ chỗ và tồn kho bằng ConcurrentHashMap, và biến phần lõi check-then-act của book thành một lời gọi compute nguyên tử per-key, thay cho khóa thủ công coarse-grained của v1. Kết quả là vừa giữ đúng invariant, vừa cho hai sự kiện độc lập chạy song song không chặn nhau - đúng lời hứa của delegation, trong đúng phạm vi mà delegation đủ.

Nhưng concurrent collection mới giải quyết một nửa câu chuyện phối hợp giữa các thread. Chúng cho ta chia sẻ dữ liệu an toàn, nhưng không cho ta điều phối nhịp giữa thread tạo ra việc và thread xử lý việc. Khi một thread sản xuất nhanh hơn thread tiêu thụ kịp xử lý, ai sẽ bảo nó chờ? Khi không còn việc để làm, thread tiêu thụ ngồi chờ bằng cách nào cho khỏi quay vòng đốt CPU? ConcurrentLinkedQueue cố tình không trả lời câu đó - nó không bao giờ chặn. Câu trả lời nằm ở một component thread-safe chuyên biệt, được thiết kế để chặn và đánh thức đúng lúc: BlockingQueue, xương sống của mẫu producer–consumer, và là chủ đề của bài kế tiếp.

11. Tự kiểm tra

Tự kiểm tra
Q1
Hai field của một class đều là component thread-safe (ConcurrentHashMap và AtomicInteger). Vì sao class chứa chúng vẫn có thể không thread-safe?
Vì delegation chỉ đứng vững khi class không áp thêm invariant nào bắc cầu giữa các component. Mỗi component bảo đảm từng thao tác đơn của chính nó nguyên tử, nhưng nếu class đòi hỏi count == events.size() luôn đúng, thì giữa hai lần cập nhật hai component tồn tại một cửa sổ mà invariant bị vi phạm — thread khác đọc vào đúng cửa sổ đó sẽ thấy trạng thái không nhất quán. Đây là phiên bản nhiều-component của bài học "atomic của một biến không phải atomic của invariant". Khi đó phải tự bọc cụm thao tác liên đới bằng một khóa của chính class.
Q2
Vì sao get() của ConcurrentHashMap không cần giành khóa mà reader vẫn thấy giá trị mới nhất writer vừa ghi?
Vì toàn bộ đường đọc — mảng table, con trỏ next, field val của mỗi Node — đều được khai báo volatile hoặc đọc qua thao tác volatile-read. Volatile read thiết lập quan hệ happens-before với volatile write tương ứng của writer, nên giá trị writer vừa ghi vào val hiển thị ngay với mọi reader sau đó. Đây là bảo đảm của Java Memory Model, không phải may mắn — và là lý do nhiều reader chạy song song với writer mà không cần bất kỳ khóa nào.
Q3
Vì sao size() của ConcurrentHashMap chỉ trả về con số xấp xỉ, trong khi size() của synchronizedMap chính xác?
synchronizedMap khóa toàn cục nên lúc đếm không ai sửa được map — chính xác, nhưng trả giá bằng việc mọi thao tác nối đuôi qua một khóa. ConcurrentHashMap từ chối khóa toàn cục: số phần tử được cộng dồn vào một dải counter cell phân tán (kỹ thuật striping kiểu LongAdder) để các thread ghi không tranh nhau, còn size() chỉ cộng các cell tại một thời điểm map vẫn đang biến động. Kết quả là một ảnh chụp gần đúng, có thể đã cũ ngay khi nhận được — đánh đổi có chủ đích để giữ throughput ghi.
Q4
compute() cho ta guarantee gì mà cặp get() rồi put() không có? Lambda truyền vào nó phải tuân kỷ luật nào?
compute() chạy lambda dưới khóa của đúng cái bin chứa key, nên toàn bộ chuỗi đọc-kiểm tra-ghi cho key đó là một khối nguyên tử: không thread nào chen vào giữa, kịch bản hai thread cùng đọc giá trị cũ rồi cùng ghi đè (lost update) không xảy ra. Cặp get/put rời rạc thì mỗi lời gọi nguyên tử riêng lẻ nhưng khoảng giữa chúng hở. Đổi lại, lambda chạy bên trong khóa bin nên phải ngắn và thuần: không I/O, không tái vào chính map đó (hành vi không được phép — có thể ném IllegalStateException hoặc treo), không giành thêm khóa khác kẻo mở đường deadlock.
Q5
Khi nào CopyOnWriteArrayList là lựa chọn hợp lý, và dấu hiệu nào cho thấy nó đang bị dùng sai chỗ?
Hợp lý khi workload là read-heavy, write-rare — kinh điển là danh sách listener bị duyệt hàng nghìn lần mỗi giây nhưng chỉ thay đổi khi ai đó đăng ký/hủy. Khi đó đọc hoàn toàn không khóa và iterator là snapshot đông cứng, không bao giờ ném ConcurrentModificationException. Dấu hiệu dùng sai: tỷ lệ ghi đáng kể — mỗi lần ghi sao chép nguyên cả mảng, danh sách càng lớn ghi càng đắt, và lượng rác sinh ra dồn áp lực lên GC. Lúc đó nên quay về ConcurrentHashMap hoặc cấu trúc khác.
Q6
Duyệt một synchronizedMap và duyệt một ConcurrentHashMap trong khi thread khác đang ghi — hành vi khác nhau thế nào, và vì sao?
Iterator của synchronizedMap là fail-fast: phát hiện structural modification giữa chừng là ném ConcurrentModificationException, nên muốn duyệt an toàn phải khóa cả map suốt vòng lặp — chặn đứng mọi thread khác. Iterator của ConcurrentHashMap là weakly consistent: không bao giờ ném CME, phản ánh trạng thái map tại một thời điểm kể từ khi tạo, và có thể thấy hoặc không thấy các thay đổi sau đó. Đổi sự "đông cứng tuyệt đối" lấy việc duyệt không cần khóa — với duyệt báo cáo hay quét định kỳ, đây là đánh đổi hời.

Bài tiếp theo: Blocking Queues & Producer–Consumer — tách nhịp sản xuất khỏi tiêu thụ

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