Java Internals & Concurrency/Compound action ConcurrentMap: putIfAbsent, compute, merge
23/75
Bài 23 / 75~14 phútConcurrency cơ bảnMiễn phí lượt xem

Compound action ConcurrentMap: putIfAbsent, compute, merge

Vì sao synchronized collection không cứu được check-then-act tự ghép, client-side locking mong manh ra sao, và compute/merge/putIfAbsent thay khóa ngoài ra sao.

TL;DR: Component thread-safe bảo đảm từng lời gọi đơn nguyên tử, không bảo đảm cụm check-then-act bạn tự ghép từ nhiều lời gọi. Với Vector hay Collections.synchronizedMap, muốn cụm đó nguyên tử phải client-side locking — một kỹ thuật mong manh vì lệ thuộc chi tiết cài đặt của lớp khác. ConcurrentHashMap cho lối thoát sạch hơn: các compound action nguyên tử có sẵnputIfAbsent cho "vắng thì đặt", merge cho đếm vô điều kiện, compute khi phải kiểm tra rồi mới ghi — lambda chạy nguyên tử per-key, không cần một dòng synchronized nào. Đổi lại lambda phải ngắn, thuần, không tái vào map, không giành thêm khóa.

Bài trước dừng ở kết luận: delegation vỡ khi nghiệp vụ là một compound action ghép từ nhiều lời gọi đơn. Giờ đối mặt với chính tình huống đó: service bán vé phải bảo đảm "không bán vượt capacity" — một check-then-act kinh điển trên map đếm số vé đã bán. Khóa toàn bộ map thì hai sự kiện khác nhau xếp hàng chờ nhau; không khóa thì hai thread cùng đọc 99 rồi cùng bán chiếc vé thứ 100. Bài này đi qua ba nấc giải: vì sao wrapper synchronized không đủ, client-side locking đúng nhưng mong manh chỗ nào, và bộ compound action của ConcurrentHashMap gói lời giải vào một thao tác nguyên tử ra sao. Học xong, bạn chọn được đúng compound action — putIfAbsent, merge, hay compute — thay client-side locking cho từng dạng nghiệp vụ.

1. Vì sao synchronized collection không đủ?

Trước java.util.concurrent (Java 5) chỉ có hai lựa chọn sẵn: VectorHashtable từ JDK 1.0, hoặc các wrapper Collections.synchronizedMap, synchronizedList, synchronizedSet từ JDK 1.2. (Cách thứ ba luôn tồn tại: tự bọc collection trong khối synchronized của mình — Monitor Pattern.) Những wrapper này đặ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 — thread-safe theo nghĩa hẹp, nhưng 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.

Giới hạn thứ nhất là compound action. Map có sẵn putIfAbsent/compute/merge từ Java 8, nhưng đó là default method của interface — bản thân Collections.SynchronizedMap không override chúng cho tới Java 9 (JDK-8023275). Nghĩa là trên Java 8, synchronizedMap.putIfAbsent(k, v) chạy default method không khóa trên mutex nên không nguyên tử; chỉ từ Java 9 wrapper mới override từng cái, bọc trong synchronized (mutex) để lời gọi đơn đó thành nguyên tử. Mà kể cả khi đã nguyên tử, cái vẫn không cứu được là compound action do chính bạn ghép từ nhiều lời gọi rời — và List thì không hề có putIfAbsent nào để override, nên Vector vẫn là ví dụ sống. Muốn một check-then-act đúng trên chúng, ta phải tự khóa từ phía client.

Giới hạn thứ hai, đau hơn trong thực tế, là iteration. Iterator của synchronizedMap không được đồng bộ — wrapper trả thẳng iterator của map gốc, nên cả next() lẫn khoảng giữa các lời gọi đều hở: thread khác sửa map giữa chừng thì iterator ném ConcurrentModificationException theo cơ chế fail-fast. Để duyệt an toàn, phải tự khóa toàn bộ map suốt cả vòng lặp — và trong khoảng đó mọi thread khác chạm map đều bị chặn cứng. Với 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à thảm họa throughput: nó 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 qua đúng một khóa.

2. Client-side locking — khóa nào mới đúng?

Từng method của Vector đều đồng bộ bên trong nên mỗi lời gọi lẻ là nguyên tử, nhưng cụm "kiểm tra vắng rồi mới thêm" là một check-then-act bắc cầu hai lời gọi, muốn nguyên tử phải quây vào cùng một khóa.

Thử đoán

Một helper bọc Vector cần method putIfAbsent nguyên tử. Khối synchronized của helper phải khóa trên đối tượng nào thì mới chặn được mọi thao tác cạnh tranh lên chính cái Vector đó? Viết ra câu trả lời của bạn trước khi đọc tiếp.

Thử cách trông tự nhiên nhất — đồng bộ method của chính helper:

// SAI: lock cua helper khac lock noi bo cua Vector
public class ListHelper<E> {
    public final Vector<E> list = new Vector<>();      // luu y: list la PUBLIC

    public synchronized boolean putIfAbsent(E x) {     // lock tren ListHelper instance
        boolean absent = !list.contains(x);            // Vector tu lock tren CHINH NO
        if (absent) list.add(x);
        return absent;
    }
}

Chỗ này dễ tóm tắt sai. Hai thread cùng gọi helper.putIfAbsent(x) thì loại trừ nhau: cả hai đều phải giành khóa trên ListHelper. Cửa sổ chỉ mở ra với một thread thao tác thẳng lên list — mà list khai public, nên bất kỳ ai cũng gọi được list.add(x) mà không bị khóa helper chặn. Nói chính xác như JCiP: putIfAbsent không nguyên tử so với các thao tác khác trên chính List đó. Đây là lỗi "khóa nhầm khóa", nguy hiểm vì đọc lên rất giống code đúng.

Phiên bản đúng phải khóa trên đúng đối tượng mà Vector dùng để tự bảo vệ — tức chính list:

// DUNG: client-side locking -- lock tren chinh collection
public boolean putIfAbsent(E x) {
    synchronized (list) {                 // cung lock voi lock noi bo cua Vector
        boolean absent = !list.contains(x);
        if (absent) list.add(x);
        return absent;
    }
}

Nó đúng vì Vector cam kết mọi method đồng bộ trên this. Nhưng nhìn cái nền phiên bản "đúng" này đứng lên: nó phụ thuộc chính sách khóa nội bộ của một lớp khác — chi tiết cài đặt, không phải interface. Ba chỗ mong manh: lớp kia đổi cách khóa thì code ta sai ngay mà không cảnh báo compile nào; kỹ thuật không khái quát được (ConcurrentHashMap không có khóa duy nhất nào để client cầm — xem mục 3); và sự đúng đắn bị phân mảnh ra mọi nơi từng dùng collection. Vì vậy client-side locking là phương án cuối — ưu tiên vẫn là dùng collection có sẵn compound action nguyên tử.

3. ConcurrentHashMap — khóa per-bin và cái giá của nó

Thay vì một khóa duy nhất canh toàn bộ map như synchronizedMap, ConcurrentHashMap khóa mịn hơn: map chia thành nhiều bin (bucket), phần lớn thao tác chỉ đồng bộ trên đúng bin chứa key liên quan (chi tiết cơ chế ở bài 14). Hệ quả: hai thread thao tác trên hai key ở hai bin khác nhau gần như không chặn nhau, nhiều reader chạy song song với nhau và với writer. Trên map nóng, chênh lệch throughput với synchronizedMap là rất lớn.

Cái giá của khóa mịn: vì không khóa toàn cục, các thao tác toàn map yếu đi — size()/isEmpty() chỉ gần đúng, và iterator là weakly consistent (duyệt không bao giờ ném ConcurrentModificationException) chứ không fail-fast. Đủ để nhớ CHM không "miễn phí"; chi tiết ngữ nghĩa hai đánh đổi này — và vì sao chúng hợp với duyệt báo cáo/quét định kỳ — thuộc bài 14.

4. Compound action nguyên tử — merge và compute

Phần khiến ConcurrentHashMap thay thế được cả synchronizedMap lẫn nhu cầu tự khóa là tập compound action nguyên tử có sẵn — lời giải cho check-then-act ở mục 2. putIfAbsent gói "nếu vắng thì đặt" thành một bước nguyên tử; compute, computeIfAbsent, computeIfPresent, merge cho phép đọc-sửa-ghi một entry trong một thao tác duy nhất, lambda 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.

Ẩn dụConcurrency
Một câuMột lời gọi method đơn — tự nó nguyên tử
Cả đoạn văn ghép nhiều câuCompound action — không tự nguyên tử
Dùng cụm đúc sẵn cho cả ýCompound action có sẵn (putIfAbsent/compute/merge)
Tự canh viết trọn đoạnClient-side locking

Chọn method nào theo nghiệp vụ:

flowchart TD
    A["Compound action tren mot key"] --> B{"Chi dat khi VANG?"}
    B -->|"Dung"| C["putIfAbsent"]
    B -->|"Khong"| D{"Ghi vo dieu kien tu gia tri cu?"}
    D -->|"Dung (dem, cong don)"| E["merge"]
    D -->|"Phai KIEM TRA roi moi ghi"| F["compute"]

Với đếm thuần túy — tăng counter per-key, không invariant nào phải kiểm — merge 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 1L; nếu đã có, á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òn bài toán mở đầu — chặn bán vượt capacity — là kiểm tra rồi mới ghi, đất của compute. Trước khi nhìn lời giải, thử tự phác lambda cho sold.compute(eventId, ...): nó cần làm gì để hai thread không cùng bán chiếc vé cuối? Viết ra trước khi đọc tiếp.

public Booking book(String eventId, String userId) {
    Event event = registry.find(eventId);       // throw neu null (bo qua o day)
    int seatNumber = sold.compute(eventId, (k, current) -> {
        int cur = (current == null) ? 0 : current;
        if (cur >= event.capacity()) throw new SoldOutException(eventId);  // check ...
        return cur + 1;                          // ... va act, cung trong mot thao tac nguyen tu
    });
    return new Booking(eventId, userId, seatNumber);
}

Lambda truyền vào compute chạy dưới khóa của đúng bin chứa eventId. Trong suốt thời gian đó, không thread nào chen vào giữa bước kiểm tra cur >= capacity và bước ghi cur + 1 cho cùng key: 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 đọc đúng giá trị mới và ném SoldOutException khi hết chỗ. Invariant "không vượt capacity" được giữ mà không một dòng synchronized. So với Monitor Pattern — nơi hai khách đặt vé cho hai sự kiện khác nhau vẫn xếp hàng chờ nhau — compute còn cho hai sự kiện ở hai bin khác nhau không chặn nhau: vừa đúng vừa song song.

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

📚 Đào sâu
  • ConcurrentHashMap javadoc (Java 21) — contract của compute/computeIfAbsent/merge: remapping function "must not attempt to update any other mappings of this map", và ném IllegalStateException "if the computation detectably attempts a recursive update to this map that would otherwise never complete".
  • Java Concurrency in Practice (Goetz và cộng sự), §4.4.1 Client-side Locking — ví dụ ListHelper "khóa nhầm khóa" gốc (bản gốc bọc Collections.synchronizedList; bài này thay bằng Vector — cùng bản chất khóa trên chính collection).
  • Wrapper của Collections chỉ override các default method của Map (để bọc synchronized) từ Java 9 (JDK-8023275); trên Java 8 chúng chạy default không khóa.

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

  • Delegation & khi nó vỡ — bài trước dựng ranh giới delegation; bài này là lời giải cho đúng cái chỗ vỡ "compound action tự ghép".
  • Atomicity — check-then-act và read-modify-write là hai hình race mà putIfAbsent/compute/merge gói lại thành một bước nguyên tử.
  • Volatile & synchronized — Monitor Pattern với private lock là phương án quay về khi phải tự khóa; hiểu nó để thấy compute tiết kiệm được gì.
  • Nội thất ConcurrentHashMap: per-bin lock, CAS và snapshot — vì sao compute/merge/putIfAbsent chạy được nguyên tử per-key, và cách chọn concurrent collection khác (CopyOnWriteArrayList, ConcurrentSkipListMap, ConcurrentLinkedQueue) theo workload.

6. Tóm tắt

  • Collections.synchronizedXxxVector/Hashtable cho mọi truy cập nối đuôi qua một khóa duy nhất. Từ Java 9 wrapper mới override putIfAbsent/compute/merge thành nguyên tử (Java 8 vẫn chạy default không khóa), nhưng compound action tự ghép vẫn cần client-side locking, và iteration buộc khóa cả map suốt vòng lặp nếu không sẽ dính ConcurrentModificationException fail-fast.
  • Client-side locking đúng phải khóa trên đúng đối tượng collection tự bảo vệ (synchronized (list)), không phải khóa của helper — nhưng nó mong manh vì phụ thuộc chính sách khóa nội bộ của lớp khác, không khái quát, và làm sự đúng đắn phân mảnh. Vì thế nó là phương án cuối.
  • ConcurrentHashMap khóa per-bin nên compound action nguyên tử per-key, hai key ở bin khác không chặn nhau và reader chạy song song với writer — đổi lại vài thao tác toàn cục (size, iterator) yếu đi (chi tiết ở bài 14).
  • Chọn compound action theo nghiệp vụ: putIfAbsent cho "vắng thì đặt", merge cho đếm vô điều kiện, compute khi phải kiểm tra rồi mới ghi — lambda chạy nguyên tử per-key nên vá được check-then-act mà không cần khóa ngoài.
  • Kỷ luật lambda: ngắn, thuần, không I/O, không tái vào chính map, không giành thêm khóa.

Vì sao compute/merge/putIfAbsent làm được điều đó — reader và writer trên key khác nhau không chặn nhau, get() không khóa mà vẫn đúng — là chuyện của nội thất ConcurrentHashMap: khóa per-bin, CAS, và đếm phân tán. Bài kế tiếp mở nắp xem cơ chế đó.

7. Tự kiểm tra

Tự kiểm tra
Q1
Một helper bọc Vector làm putIfAbsent bằng cách khai public synchronized boolean putIfAbsent(...). Vì sao đây là "khóa nhầm khóa", và bản vá đúng khóa trên cái gì?
Method synchronized của helper khóa trên instance helper, còn mọi method của Vector lại tự đồng bộ trên chính nó — hai khóa khác nhau. Vì field list khai public, bất kỳ ai cũng gọi thẳng list.add(x) mà không bị khóa helper chặn, nên cụm check-then-act của helper không nguyên tử so với các thao tác khác trên chính List đó. Bản vá đúng là client-side locking khóa trên chính list (synchronized (list) {...}), trùng đúng đối tượng mà Vector dùng để tự bảo vệ.
Q2
Từ Java 9, Collections.synchronizedMap(m).putIfAbsent(k, v) mới nguyên tử (Java 8 thì chưa — wrapper chưa override). Vậy kể cả trên Java 9, vì sao vẫn nói synchronized collection "không đủ" cho compound action?
Vì wrapper (từ Java 9) chỉ override các method có sẵn tên (putIfAbsent, compute, merge), bọc mỗi cái trong synchronized (mutex) — mỗi lời gọi đơn đó nguyên tử. Nhưng compound action do chính bạn ghép từ nhiều lời gọi rời (ví dụ get rồi kiểm tra rồi put) thì không method đơn nào bảo vệ được — vẫn cần khóa client. Và List không hề có putIfAbsent để override, nên Vector vẫn là ví dụ sống của việc phải tự khóa. Ngoài ra iteration vẫn buộc khóa cả map suốt vòng lặp, nếu không sẽ dính ConcurrentModificationException fail-fast.
Q3
compute() của ConcurrentHashMap 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.
Q4
Đếm lượt xem per-key thì dùng merge hay compute? Còn chặn bán vé vượt capacity? Vì sao khác nhau?
Đếm lượt xem là read-modify-write vô điều kiện — không có nhánh kiểm tra nào — nên merge(key, 1L, Long::sum) gọn và đủ: vắng thì đặt giá trị khởi tạo, có thì áp hàm gộp. Chặn bán vượt capacity là check-then-act: phải đọc giá trị hiện tại, so với capacity, rồi mới quyết định ghi hay ném lỗi — nhánh kiểm tra đó phải nằm bên trong thao tác nguyên tử, nên cần compute với lambda chứa cả bước kiểm tra lẫn bước ghi.
Q5
Một service nhận stream sự kiện: mỗi sự kiện phải (a) tăng bộ đếm số lần xuất hiện theo type, và (b) đăng ký type vào một registry chỉ khi nó chưa có. Bạn chọn merge, compute, hay putIfAbsent cho từng việc? Vì sao?
(a) Đếm số lần xuất hiện là read-modify-write vô điều kiện — không có nhánh kiểm tra — nên counts.merge(type, 1L, Long::sum) gọn và đủ: vắng thì đặt 1L, có thì áp hàm gộp. (b) "Chỉ khi chưa có" đúng là ngữ nghĩa registry.putIfAbsent(type, meta): vắng thì đặt, đã có thì giữ nguyên và trả về giá trị cũ — một bước nguyên tử, khỏi phải đọc-rồi-kiểm-tra-rồi-ghi. Chỉ khi việc ghi phụ thuộc một điều kiện tính từ giá trị hiện tại (như chặn vượt capacity) mới cần compute với lambda chứa cả bước kiểm tra lẫn bước ghi.

Bài tiếp theo: Nội thất ConcurrentHashMap: per-bin lock, CAS và snapshot

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

Nội thất ConcurrentHashMap: per-bin lock, CAS và snapshot