ConcurrentHashMap khác gì HashMap và Collections.synchronizedMap

1. Tổng quan

Khi nhiều thread cùng đọc và ghi vào một Map, bạn có ba lựa chọn quen thuộc: HashMap, Collections.synchronizedMap và ConcurrentHashMap. Ba lựa chọn này khác nhau rõ rệt về độ an toàn khi dùng đa luồng, về cách lock và về hành vi khi duyệt.

Bài này giải thích từng loại qua ví dụ chạy được. Đọc xong, bạn sẽ biết nên chọn loại nào, viết thao tác phức hợp sao cho atomic, và tránh được những lỗi hay gặp.

2. Chuẩn bị

Các ví dụ dùng Java 21. Phần test cần JUnit 5 và AssertJ:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Nếu dự án là Spring Boot 3.x, spring-boot-starter-test đã có sẵn cả hai thư viện này.

3. HashMap: nhanh nhưng không thread-safe

HashMap không có bất kỳ cơ chế đồng bộ nào. Trong một thread, nó là lựa chọn nhanh nhất. Khi nhiều thread cùng ghi, dữ liệu có thể bị mất.

3.1. Mất cập nhật khi đếm đồng thời

Ví dụ sau đếm số đơn hàng của một khách hàng bằng 8 thread:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class HashMapRaceDemo {

    public static void main(String[] args) {
        Map<String, Integer> orderCount = new HashMap<>();

        try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum));
            }
        } // close() chờ mọi task chạy xong

        System.out.println("Số đơn: " + orderCount.get("customer-1"));
    }
}

Ta gửi 10.000 task, mỗi task cộng thêm 1 bằng merge. Từ Java 19, ExecutorService là AutoCloseable, nên try-with-resources sẽ chờ mọi task hoàn tất. Kết quả thay đổi theo từng lần chạy:

Số đơn: 9817

Lý do là merge của HashMap gồm nhiều bước: đọc giá trị cũ, tính giá trị mới rồi ghi lại. Hai thread có thể cùng đọc 100, cùng ghi 101, và một lần cộng bị mất. Tệ hơn, nếu hai thread cùng kích hoạt resize, cấu trúc bên trong có thể bị hỏng.

3.2. ConcurrentModificationException khi duyệt

Iterator của HashMap là fail-fast. Nếu map bị sửa trong lúc đang duyệt mà không đi qua chính iterator đó, lần gọi next() tiếp theo có thể ném ConcurrentModificationException. Lỗi này xảy ra cả trong một thread, chẳng hạn khi bạn gọi map.remove() bên trong vòng for-each.

Lưu ý: Cơ chế fail-fast chỉ là “cố gắng hết sức” (best-effort). Không có exception không có nghĩa là code của bạn đúng.

4. Collections.synchronizedMap: một lock cho tất cả

Collections.synchronizedMap bọc một Map bất kỳ. Mọi method của wrapper đều chạy trong synchronized (mutex), và mặc định mutex chính là đối tượng wrapper.

Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());

Nếu thay dòng khởi tạo trong ví dụ ở mục 3.1 bằng dòng trên, kết quả luôn đúng:

Số đơn: 10000

Từ Java 8, wrapper này override cả các method mặc định như merge, compute và putIfAbsent, nên mỗi lời gọi đơn lẻ là atomic.

4.1. Thao tác phức hợp vẫn không atomic

Mỗi method được đồng bộ riêng. Khi bạn ghép hai lời gọi lại với nhau, chuỗi đó không còn atomic:

Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());

// SAI: hai thread có thể cùng thấy "chưa có" và cùng put
if (!customers.containsKey(id)) {
    customers.put(id, new Customer(id));
}

// ĐÚNG: giữ lock trong suốt check-then-act
synchronized (customers) {
    if (!customers.containsKey(id)) {
        customers.put(id, new Customer(id));
    }
}

Cách đơn giản hơn là dùng customers.putIfAbsent(id, new Customer(id)), vì đây là một lời gọi duy nhất.

4.2. Phải tự lock khi duyệt

Iterator của wrapper không được đồng bộ. Javadoc yêu cầu bạn tự lock trên chính map:

synchronized (customers) {
    for (Customer c : customers.values()) {
        System.out.println(c);
    }
}

Nếu quên khối synchronized, bạn vẫn có thể gặp ConcurrentModificationException. Khi đã lock, mọi thread khác muốn đọc hoặc ghi đều phải chờ cho tới khi vòng lặp chạy xong.

4.3. Điểm nghẽn hiệu năng

Chỉ có một lock cho toàn bộ map. Hai thread đọc hai key khác nhau vẫn phải xếp hàng. Khi số thread tăng, mức tranh chấp lock (contention) tăng theo, và throughput giảm rõ rệt.

5. ConcurrentHashMap: thiết kế cho đa luồng

ConcurrentHashMap (gói java.util.concurrent) được thiết kế riêng cho truy cập đồng thời. Từ Java 8, cách cài đặt của nó như sau:

  • Đọc không lock. get dùng đọc volatile, nên gần như không bao giờ bị chặn.
  • Ghi lock theo từng bucket. Khi bucket còn trống, nó chèn bằng CAS (compare-and-swap). Khi bucket đã có phần tử, nó chỉ synchronized trên node đầu của bucket đó. Các thread ghi vào bucket khác nhau chạy song song.
  • Resize hợp tác. Nhiều thread cùng tham gia chuyển dữ liệu sang bảng mới, thay vì khóa toàn bộ map.

5.1. Thao tác phức hợp atomic

Các method putIfAbsent, computeIfAbsent, computeIfPresent, compute và merge đều atomic trên từng key:

import java.util.concurrent.ConcurrentHashMap;

public class InventoryService {

    private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();

    public void addStock(String product, int quantity) {
        stock.merge(product, quantity, Integer::sum);
    }

    public boolean reserve(String product) {
        Integer result = stock.computeIfPresent(product,
                (key, current) -> current > 0 ? current - 1 : current);
        return result != null && result >= 0;
    }

    public int available(String product) {
        return stock.getOrDefault(product, 0);
    }
}

addStock cộng dồn số lượng bằng merge. Nếu key chưa có, merge dùng luôn quantity làm giá trị đầu. reserve giảm tồn kho đi 1 trong computeIfPresent. Toàn bộ thao tác đọc, kiểm tra rồi ghi xảy ra trong một bước atomic cho key đó, nên không có hai thread nào cùng giữ chỗ đơn vị cuối cùng.

Để đếm với tần suất rất cao, kết hợp với LongAdder sẽ giảm tranh chấp hơn nữa:

ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();

5.2. Iterator weakly consistent

Iterator của ConcurrentHashMap không bao giờ ném ConcurrentModificationException. Bạn có thể sửa map ngay trong lúc duyệt:

ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(
        Map.of("apple", 10, "banana", 0, "cherry", 3));

for (String product : stock.keySet()) {
    if (stock.get(product) == 0) {
        stock.remove(product);
    }
}

System.out.println(stock);
{apple=10, cherry=3}

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. Iterator có thể thấy, hoặc không thấy, các thay đổi xảy ra sau đó. Mỗi phần tử được trả về tối đa một lần. Đây là đánh đổi có chủ đích: bạn không bị chặn và không bị exception, nhưng cũng không có snapshot chính xác.

5.3. Không chấp nhận null

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1);       // NullPointerException

Với HashMap, get(key) trả về null có thể mang hai nghĩa: không có key, hoặc key đang ánh xạ tới null. Trong một thread, bạn gọi thêm containsKey để phân biệt. Trong môi trường đồng thời, giữa hai lời gọi đó map có thể đã thay đổi. Vì vậy ConcurrentHashMap cấm null để get trả về null chỉ còn một nghĩa duy nhất: key không tồn tại.

5.4. size() chỉ là ước lượng

Khi các thread khác đang ghi, size() và isEmpty() chỉ phản ánh một trạng thái gần đúng. Nếu map có thể vượt Integer.MAX_VALUE phần tử, hãy dùng mappingCount(), vì method này trả về long. Đừng viết logic nghiệp vụ dựa trên việc size() phải chính xác tuyệt đối tại một thời điểm.

6. Kiểm thử hành vi đồng thời

Test sau xác nhận ConcurrentHashMap đếm đúng dưới tải đồng thời và từ chối giá trị null:

import org.junit.jupiter.api.Test;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ConcurrentMapTest {

    @Test
    void givenManyThreads_whenMerge_thenNoUpdateIsLost() {
        Map<String, Integer> orderCount = new ConcurrentHashMap<>();

        try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
            IntStream.range(0, 10_000).forEach(i ->
                    executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum)));
        }

        assertThat(orderCount).containsEntry("customer-1", 10_000);
    }

    @Test
    void givenNullValue_whenPut_thenThrowNpe() {
        Map<String, Integer> stock = new ConcurrentHashMap<>();

        assertThatThrownBy(() -> stock.put("apple", null))
                .isInstanceOf(NullPointerException.class);
    }
}

Test đầu tiên luôn pass. Nếu đổi sang new HashMap<>(), test sẽ fail ở phần lớn các lần chạy, với giá trị thực tế nhỏ hơn 10.000.

Lưu ý: Một test đa luồng pass không chứng minh code đúng. Race condition có thể chỉ xuất hiện dưới tải thật. Hãy dựa vào đảm bảo của API, như tính atomic của merge, chứ đừng chỉ dựa vào việc test xanh.

7. Lỗi hay gặp với ConcurrentHashMap

Ghép get rồi put. Dùng ConcurrentHashMap không làm code của bạn tự động an toàn:

// SAI: race condition giữa get và put
Integer current = stock.get("apple");
stock.put("apple", current - 1);

// ĐÚNG
stock.computeIfPresent("apple", (k, v) -> v - 1);

Hàm trong compute* chạy lâu hoặc có side effect. Hàm mapping chạy trong khi bucket đang bị lock. Nếu bạn gọi HTTP hay truy vấn database trong computeIfAbsent, mọi thread khác ghi vào bucket đó sẽ bị chặn. Hãy giữ hàm ngắn gọn và không có side effect.

Sửa chính map bên trong compute*. Gọi map.put hoặc map.computeIfAbsent lồng trong hàm mapping của cùng map có thể ném IllegalStateException: Recursive update, hoặc gây deadlock. Ví dụ điển hình là cache đệ quy kiểu memoize Fibonacci bằng computeIfAbsent.

Cần atomic trên nhiều key. ConcurrentHashMap chỉ đảm bảo atomic trên từng key. Ví dụ chuyển tồn kho từ kho A sang kho B là thao tác trên hai key. Nó cần lock riêng, hoặc cần thiết kế lại dữ liệu để gom về một key.

Cần thứ tự. ConcurrentHashMap không giữ thứ tự chèn. Nếu cần key được sắp xếp, hãy dùng ConcurrentSkipListMap.

8. So sánh tổng hợp

Tiêu chí              | HashMap          | synchronizedMap          | ConcurrentHashMap
----------------------|------------------|--------------------------|--------------------------
Thread-safe           | Không            | Có                       | Có
Cơ chế lock           | Không có         | Một lock cho cả map      | CAS + lock theo bucket
Đọc đồng thời         | Không an toàn    | Tuần tự (bị chặn)        | Không lock
Thao tác phức hợp     | Không atomic     | Cần synchronized ngoài   | compute/merge atomic
Iterator              | Fail-fast        | Fail-fast, phải tự lock  | Weakly consistent
Key/value null        | Cho phép         | Theo map được bọc        | Không cho phép
Hiệu năng đa luồng    | —                | Kém khi tranh chấp cao   | Tốt

Vậy nên chọn loại nào?

  • HashMap: map chỉ được truy cập từ một thread, hoặc được tạo xong rồi publish an toàn và chỉ đọc. Với trường hợp chỉ đọc, Map.copyOf còn tốt hơn.
  • Collections.synchronizedMap: bạn cần bọc một Map sẵn có như LinkedHashMap hoặc TreeMap để giữ thứ tự hay hành vi đặc thù, tải đồng thời thấp, hoặc bạn cần lock toàn bộ map cho một chuỗi thao tác dài.
  • ConcurrentHashMap: lựa chọn mặc định cho cache, bộ đếm, registry và mọi map dùng chung giữa nhiều thread.

9. Kết luận

HashMap nhanh nhưng mất dữ liệu khi nhiều thread cùng ghi. Collections.synchronizedMap an toàn nhờ một lock duy nhất. Đổi lại, nó tạo điểm nghẽn, và bạn vẫn phải tự lock khi duyệt hoặc khi ghép nhiều thao tác. ConcurrentHashMap cho phép đọc không lock và ghi song song theo bucket. Nó cung cấp các thao tác atomic như merge hay computeIfAbsent, và iterator của nó không ném exception khi map thay đổi.

Hãy nhớ hai nguyên tắc. Thứ nhất, collection thread-safe không làm chuỗi thao tác của bạn tự động atomic, nên hãy dùng compute* và merge thay cho get rồi put. Thứ hai, giữ hàm mapping ngắn gọn và không sửa chính map bên trong nó.

javaconcurrencycollectionsconcurrenthashmapmultithreading