CodeGym /Các khóa học /JAVA 25 SELF /Bộ sưu tập thread-safe: ConcurrentHashMap và các loại khá...

Bộ sưu tập thread-safe: ConcurrentHashMap và các loại khác

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Vì sao các bộ sưu tập thông thường không phù hợp cho đa luồng

Hãy nhớ lại cách chúng ta làm việc với các bộ sưu tập trong ứng dụng chính của mình (ví dụ, phòng chat):

List<String> messages = new ArrayList<>();
messages.add("Xin chào!");
messages.add("Bạn khỏe không?");

Trong chương trình đơn luồng thì mọi thứ đều ổn. Nhưng nếu nhiều luồng đồng thời thêm, xóa hoặc đọc phần tử từ cùng một bộ sưu tập — xin chào thế giới của race condition (race conditions), trạng thái không nhất quán và những bug bí ẩn.

Ví dụ, một luồng thêm phần tử, luồng khác xóa, luồng thứ ba duyệt — và đột nhiên nhận được ConcurrentModificationException, đôi khi thậm chí ArrayIndexOutOfBoundsException hoặc đơn giản là một bộ sưu tập “hỏng”.

Tình huống kinh điển:

List<String> list = new ArrayList<>();
Runnable writer = () -> {
    for (int i = 0; i < 1000; i++) {
        list.add("msg-" + i);
    }
};
Runnable reader = () -> {
    for (String msg : list) {
        // ...
    }
};
// Chạy writer và reader ở các luồng khác nhau — sẽ có bug!

Kết luận: Các bộ sưu tập thông thường (ArrayList, HashMap, HashSet v.v.) KHÔNG thread-safe. Không nên sử dụng chúng từ nhiều luồng mà không có đồng bộ hóa bổ sung (synchronized, khóa, v.v.).

2. Có những bộ sưu tập thread-safe nào trong Java

Java không bỏ mặc bạn. Cho các tác vụ đa luồng, trong gói java.util.concurrent có cả một “bộ sưu tập của các bộ sưu tập” (xin thứ lỗi vì nói lái) có thể sử dụng an toàn từ nhiều luồng.

Các bộ sưu tập thread-safe chính:

Bộ sưu tập Khi dùng Đặc điểm
ConcurrentHashMap
Map, cache, truy cập thường xuyên Hiệu năng cao, không có lock toàn cục
CopyOnWriteArrayList
List, ít thay đổi, đọc thường xuyên Đọc nhanh, cập nhật chậm
CopyOnWriteArraySet
Set, ít thay đổi, đọc thường xuyên Tương tự danh sách Copy-On-Write
ConcurrentLinkedQueue
Hàng đợi, FIFO Nhanh, không khóa, hàng đợi tác vụ
ConcurrentSkipListMap
Map có sắp xếp (NavigableMap) Tương đương thread-safe của TreeMap
ConcurrentSkipListSet
Set có sắp xếp Tương đương thread-safe của TreeSet
BlockingQueue
Hàng đợi có chặn (thread pool) Interface, nhiều triển khai

Quan trọng! Collections.synchronizedList(list) và các biến thể tương tự — không hoàn toàn giống với các bộ sưu tập hiện đại trong java.util.concurrent. Chi tiết — ở phần dưới.

3. ConcurrentHashMap: người bạn trong thế giới đa luồng

ConcurrentHashMap<K, V> — về bản chất là HashMap được “nâng cấp” cho đa luồng. Nó cho phép nhiều luồng đồng thời đọc và ghi dữ liệu một cách an toàn mà không khóa toàn bộ map.

Trong HashMap thông thường, nếu muốn an toàn luồng, bạn phải đặt một khóa lên toàn bộ cấu trúc — và nó lập tức trở thành “nút cổ chai”: khi một luồng làm việc, các luồng còn lại phải chờ.

ConcurrentHashMap giải quyết thông minh hơn. Ở các phiên bản cũ, map được chia thành các segment với khóa riêng; trong các bản mới, sử dụng các thao tác nguyên tử nhẹ (CAS) ở mức các bucket riêng lẻ. Nhờ vậy, các luồng có thể hoạt động song song nếu không đụng cùng một dữ liệu.

Ví dụ sử dụng ConcurrentHashMap

import java.util.concurrent.ConcurrentHashMap;

public class ChatStats {
    private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();

    public void increment(String user) {
        // Tăng giá trị một cách nguyên tử
        userMessageCount.merge(user, 1, Integer::sum);
    }

    public int getCount(String user) {
        return userMessageCount.getOrDefault(user, 0);
    }
}

Điều quan trọng ở đây:

  • Có thể gọi các phương thức từ nhiều luồng — mọi thứ vẫn chính xác.
  • Phương thức merge là nguyên tử: nếu nhiều luồng đồng thời tăng bộ đếm, kết quả sẽ đúng.
  • Đọc không cần đồng bộ hóa bổ sung.

ConcurrentHashMap tốt hơn synchronizedMap ở điểm nào?

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

Khi dùng synchronizedMap, mọi thao tác — đọc, ghi hay xóa — đều khóa toàn bộ map. Trong lúc một luồng làm việc với dữ liệu, các luồng khác buộc phải chờ đến lượt.

ConcurrentHashMap được thiết kế tinh tế hơn: cho phép nhiều luồng đồng thời đọc, thậm chí sửa đổi dữ liệu, miễn là chúng không đụng tới cùng một vùng (bucket) của map. Vì vậy, trong các hệ thống đa luồng thực tế, nó cho hiệu năng tốt hơn đáng kể — đôi khi chênh lệch tới hàng chục lần.

4. CopyOnWriteArrayList và CopyOnWriteArraySet

CopyOnWriteArrayListCopyOnWriteArraySet là các bộ sưu tập đặc biệt: mỗi lần thay đổi (ví dụ gọi add() hoặc remove()) sẽ tạo một bản sao hoàn toàn mới của mảng bên dưới. Đổi lại, việc đọc diễn ra không cần đồng bộ hóa và hoàn toàn an toàn cho luồng.

Hãy tưởng tượng bạn có danh sách khách mời cho một bữa tiệc. Mỗi lần có người đến hoặc rời đi, bạn chép lại danh sách từ đầu và phát cho mọi người bản mới nhất. Hơi tốn kém, nhưng đổi lại không ai nhầm lẫn hiện giờ ai đang có mặt.

Khi nào thực sự tiện lợi

  • Đọc diễn ra thường xuyên, còn thay đổi — ít.
  • Trường hợp kinh điển — danh sách listener sự kiện: handler được thêm không thường xuyên, còn sự kiện đến liên tục.

Ví dụ: listener của chat

import java.util.concurrent.CopyOnWriteArrayList;

public class ChatRoom {
    private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();

    public void addListener(ChatListener listener) {
        listeners.add(listener);
    }

    public void removeListener(ChatListener listener) {
        listeners.remove(listener);
    }

    public void sendMessage(String message) {
        // An toàn cho đa luồng, ngay cả khi có người đang đăng ký/hủy đăng ký ngay lúc này
        for (ChatListener listener : listeners) {
            listener.onMessage(message);
        }
    }
}

Đặc điểm quan trọng:

  • Duyệt qua CopyOnWriteArrayList sẽ không bao giờ ném ConcurrentModificationException.
  • Các thay đổi (add/remove) tốn thời gian và bộ nhớ (sao chép cả mảng!).
  • Không nên dùng cho bộ sưu tập lớn với thay đổi thường xuyên.

5. Các bộ sưu tập thread-safe khác

ConcurrentLinkedQueue

ConcurrentLinkedQueue là hàng đợi không khóa, hoạt động theo nguyên tắc FIFO. Nó cho phép nhiều luồng đồng thời thêm và lấy phần tử một cách an toàn mà không cần khóa tường minh. Thường dùng để chuyển giao tác vụ giữa các luồng — nhanh và không “tắc nghẽn”.

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // trả về null nếu hàng đợi rỗng

ConcurrentSkipListMap và ConcurrentSkipListSet

  • Các tương đương thread-safe của TreeMapTreeSet.
  • Các phần tử luôn được sắp xếp.
  • Dùng khi cần duy trì thứ tự khóa.
import java.util.concurrent.ConcurrentSkipListMap;

ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b

BlockingQueue và các triển khai của nó

  • Interface của hàng đợi hỗ trợ các thao tác chặn (chờ cho đến khi có mục/đủ chỗ).
  • Các triển khai: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue v.v.
  • Được dùng trong thread pool, cho mẫu “producer-consumer”.
import java.util.concurrent.ArrayBlockingQueue;

ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // Chặn nếu hàng đợi đầy
String t = blockingQueue.take(); // Chặn nếu hàng đợi rỗng

6. Ví dụ: các thao tác an toàn với bộ sưu tập

Ví dụ 1: Map thread-safe để đếm tin nhắn

import java.util.concurrent.ConcurrentHashMap;

ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();

// Luồng 1
messageCount.put("Anna", 1);
// Luồng 2
messageCount.put("Anna", messageCount.getOrDefault("Anna", 0) + 1); // Không nguyên tử!

// Đúng (nguyên tử):
messageCount.merge("Anna", 1, Integer::sum);

Ví dụ 2: Duyệt CopyOnWriteArrayList

import java.util.concurrent.CopyOnWriteArrayList;

CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Anton");
users.add("Mariya");

for (String user : users) {
    System.out.println(user);
    users.remove(user); // Sẽ không ném ConcurrentModificationException!
}
System.out.println(users); // []

Ví dụ 3: Hàng đợi tác vụ giữa các luồng

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

// Luồng sản xuất
queue.add("task-1");

// Luồng tiêu thụ
String task = queue.poll(); // null nếu rỗng

7. Những điểm hữu ích

Khi nào (và vì sao) dùng bộ sưu tập thread-safe

Việc dùng bộ sưu tập thread-safe là hợp lý nếu:

  • Cùng một bộ sưu tập được chia sẻ giữa nhiều luồng.
  • Không muốn tự đồng bộ hóa cho từng thao tác.
  • Quan trọng là tránh race condition và lỗi nhất quán.

Tình huống điển hình:

  • Cache trong hệ thống đa luồng (ví dụ, ConcurrentHashMap để lưu session người dùng).
  • Hàng đợi tác vụ giữa các luồng (ConcurrentLinkedQueue, BlockingQueue).
  • Danh sách listener sự kiện (CopyOnWriteArrayList).
  • Xử lý dữ liệu đa luồng (ví dụ, phong cách MapReduce).

Giới hạn và cạm bẫy

  • Các thao tác trên nhiều phần tử không nguyên tử. Cấu trúc như if (!map.containsKey(k)) map.put(k, v) là không nguyên tử. Hãy dùng putIfAbsent, computeIfAbsent, merge.
  • CopyOnWriteArrayList kém hiệu quả khi thay đổi thường xuyên. Với kích thước lớn và các thao tác add/remove thường xuyên, chi phí sẽ tăng vọt.
  • Duyệt ConcurrentHashMap là “yếu”. Việc duyệt cho ảnh chụp nhất quán yếu: có thể không thấy một phần các thay đổi song song.
  • Bộ sưu tập thread-safe không giải quyết mọi vấn đề đồng bộ. Nếu logic đụng tới nhiều bộ sưu tập/biến cùng lúc, sẽ cần đồng bộ hóa bên ngoài (synchronized, khóa, các lớp nguyên tử).

8. Những lỗi thường gặp khi làm việc với bộ sưu tập thread-safe

Lỗi №1: Trông chờ “phép màu” từ bộ sưu tập thread-safe. “Vì bộ sưu tập là thread-safe, có thể làm gì cũng được và không cần nghĩ về đồng bộ.” Tiếc là chuỗi nhiều thao tác (kiểm tra + thêm) không nguyên tử. Hãy dùng các phương thức chuyên biệt: putIfAbsent, compute, merge.

Lỗi №2: Dùng CopyOnWriteArrayList cho bộ sưu tập lớn và thay đổi thường xuyên. Phù hợp cho danh sách listener, nhưng với 10 000+ phần tử và thay đổi thường xuyên, bạn sẽ chịu chi phí bộ nhớ và thời gian rất lớn.

Lỗi №3: ConcurrentModificationException khi dùng bộ sưu tập thông thường. Bạn duyệt ArrayList hoặc HashMap, trong khi luồng khác thay đổi bộ sưu tập — và nhận ConcurrentModificationException. Hãy dùng bộ sưu tập chuyên biệt hoặc tự khóa thủ công.

Lỗi №4: Quên tính nguyên tử của các thao tác phức tạp. Nếu cần thay đổi đồng thời nhiều bộ sưu tập hoặc thực hiện một chuỗi hành động liên quan — bộ sưu tập thread-safe không giúp được. Hãy dùng đồng bộ hóa bên ngoài hoặc logic giao dịch.

Lỗi №5: Lỗi khi duyệt ConcurrentHashMap. Việc duyệt là nhất quán yếu: không thể dùng iterator như “ảnh chụp” trạng thái map. Để có ảnh chụp nhất quán, hãy sao chép dữ liệu sang cấu trúc riêng.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION