CodeGym /课程 /JAVA 25 SELF /线程安全的集合:ConcurrentHashMap 等

线程安全的集合:ConcurrentHashMap 等

JAVA 25 SELF
第 53 级 , 课程 2
可用

1. 为什么普通集合不适合多线程

先回顾一下我们在主应用(例如聊天室)中如何使用集合:

List<String> messages = new ArrayList<>();
messages.add("你好!");
messages.add("最近怎么样?");

在单线程程序中,一切都很顺利。但如果有多个线程同时对同一个集合进行添加、删除或读取——欢迎来到数据竞争(race conditions)、状态不一致以及各种神秘 Bug 的世界。

例如,一个线程在添加元素,另一个在删除,第三个在迭代——很可能突然得到 ConcurrentModificationException,有时甚至是 ArrayIndexOutOfBoundsException,或者干脆得到一个“损坏”的集合。

经典场景:

List<String> list = new ArrayList<>();
Runnable writer = () -> {
    for (int i = 0; i < 1000; i++) {
        list.add("msg-" + i);
    }
};
Runnable reader = () -> {
    for (String msg : list) {
        // ...
    }
};
// 在不同线程中启动 writer 和 reader —— 你就会遇到 Bug!

结论:普通集合(ArrayListHashMapHashSet 等)并非线程安全thread-safe)。如果没有额外同步(synchronized、锁等),就不能在多个线程间安全地使用它们。

2. Java 中有哪些线程安全的集合

Java 不会置你于不顾。为了解决多线程任务,包 java.util.concurrent 提供了一整套可以在多线程环境下安全使用的集合。

主要的线程安全集合:

集合 使用场景 特点
ConcurrentHashMap
Map、缓存、频繁访问 高性能,无全局 lock
CopyOnWriteArrayList
List,写少读多 读快,修改慢
CopyOnWriteArraySet
Set,写少读多 与 Copy-On-Write 列表类似
ConcurrentLinkedQueue
队列,FIFO 快速、非阻塞,任务队列
ConcurrentSkipListMap
有序的 Map(NavigableMap) 线程安全的 TreeMap 替代品
ConcurrentSkipListSet
有序的 Set 线程安全的 TreeSet 替代品
BlockingQueue
带阻塞的队列(线程池) 接口,拥有多种实现

重要! 古早的 Collections.synchronizedList(list) 等并不完全等同于 java.util.concurrent 中的现代集合。详情见下文。

3. ConcurrentHashMap:多线程世界中的好伙伴

ConcurrentHashMap<K, V> 本质上与 HashMap 类似,但为多线程场景做了强化。它允许多个线程同时安全地读写数据,而不会把整张表都锁住。

在普通的 HashMap 中,如果想让访问变得线程安全,往往需要给整个结构加锁——它立刻变成“瓶颈”:一个线程在工作,其它线程只能等待。

ConcurrentHashMap 更聪明。早期实现将表分为多个段分别加锁;新的实现更多使用在桶级别的轻量级原子操作(CAS)。因此,只要不是同时操作同一片数据,线程就能并行工作。

ConcurrentHashMap 的使用示例

import java.util.concurrent.ConcurrentHashMap;

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

    public void increment(String user) {
        // 原子地增加值
        userMessageCount.merge(user, 1, Integer::sum);
    }

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

这里的要点:

  • 可以从不同线程调用这些方法——行为是正确的。
  • merge 方法是原子的:即使多个线程同时递增计数,结果也是正确的。
  • 读取无需额外同步。

ConcurrentHashMap 相比 synchronizedMap 有何优势?

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

当你使用 synchronizedMap 时,任何操作——读取、写入或删除——都会锁住整张表。只要一个线程在操作,其它线程就必须等待。

ConcurrentHashMap 要优雅得多:它允许多个线程同时读取,甚至在不碰撞的情况下同时修改(不访问同一个桶)。因此在真实的多线程系统中,它的性能通常好很多——差距有时会达到数十倍。

4. CopyOnWriteArrayList 和 CopyOnWriteArraySet

CopyOnWriteArrayListCopyOnWriteArraySet 是特殊的集合:每次修改(例如调用 add() 或 remove())都会创建底层数组的新副本。但从中读取时不需要任何同步,并且对线程完全安全。

想象你有一个派对来宾名单。每当有人来或离开,你就把名单重新誊一份并发给所有人。稍显浪费,但没有人会搞不清现在现场都有谁。

何时真的合适

  • 读取很频繁,而修改很少。
  • 经典用例——事件监听器列表:处理器很少增删,但事件源源不断。

示例:聊天监听器

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) {
        // 对多线程是安全的,即使此刻有人正在订阅/退订
        for (ChatListener listener : listeners) {
            listener.onMessage(message);
        }
    }
}

重要特性:

  • 遍历 CopyOnWriteArrayList 永远不会抛出 ConcurrentModificationException
  • 修改(add/remove)在时间和内存上都很昂贵(会复制整个数组!)。
  • 不应在大且频繁修改的集合上使用。

5. 其他线程安全集合

ConcurrentLinkedQueue

ConcurrentLinkedQueue 是一种非阻塞队列,遵循 FIFO 原则。它允许多个线程同时安全地加入或取出元素,而无需显式加锁。常用于在线程间传递任务——快速且不“卡顿”。

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // 若队列为空则返回 null

ConcurrentSkipListMap 和 ConcurrentSkipListSet

  • 分别是 TreeMapTreeSet 的线程安全替代。
  • 元素始终有序。
  • 当需要维护键的顺序时使用。
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 及其实现

  • 支持阻塞操作(等待元素出现/等待空间释放)的队列接口。
  • 实现有:ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue 等。
  • 用于线程池、生产者-消费者模式。
import java.util.concurrent.ArrayBlockingQueue;

ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // 若队列已满则阻塞
String t = blockingQueue.take(); // 若队列为空则阻塞

6. 示例:对集合的安全操作

示例 1:用于统计消息的线程安全 Map

import java.util.concurrent.ConcurrentHashMap;

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

// 线程 1
messageCount.put("Anna", 1);
// 线程 2
messageCount.put("Anna", messageCount.getOrDefault("Anna", 0) + 1); // 非原子!

// 正确(原子):
messageCount.merge("Anna", 1, Integer::sum);

示例 2:遍历 CopyOnWriteArrayList

import java.util.concurrent.CopyOnWriteArrayList;

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

for (String user : users) {
    System.out.println(user);
    users.remove(user); // 不会抛出 ConcurrentModificationException!
}
System.out.println(users); // []

示例 3:跨线程的任务队列

import java.util.concurrent.ConcurrentLinkedQueue;

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

// 生产者线程
queue.add("task-1");

// 消费者线程
String task = queue.poll(); // 若为空则为 null

7. 有用的细节

何时(以及为何)使用线程安全集合

在以下情况下,使用线程安全集合是合理的:

  • 同一个集合被多个线程共享。
  • 不想手动为每个操作编写同步。
  • 需要避免数据竞争和一致性错误。

典型场景:

  • 多线程系统中的缓存(例如使用 ConcurrentHashMap 存储用户会话)。
  • 线程间的任务队列(ConcurrentLinkedQueueBlockingQueue)。
  • 事件监听器列表(CopyOnWriteArrayList)。
  • 多线程数据处理(例如 MapReduce 风格)。

限制与坑点

  • 对多个元素的复合操作不是原子的。 例如 if (!map.containsKey(k)) map.put(k, v) 并非原子。请使用 putIfAbsentcomputeIfAbsentmerge
  • 在频繁修改时,CopyOnWriteArrayList 效率很低。 当规模较大且频繁 add/remove 时,开销会迅速增大。
  • 遍历 ConcurrentHashMap 是“弱一致”的。 遍历得到的是弱一致性快照:可能看不到部分并发修改。
  • 线程安全集合并不能解决所有同步问题。 若业务逻辑同时涉及多份集合/变量,仍需要外部同步(synchronized、locks、原子类)。

8. 使用线程安全集合时的常见错误

错误 1:对 thread-safe 集合抱有魔法般的期待。 “既然集合是 thread-safe,就可以为所欲为,不必考虑同步。” 可惜并非如此:由多步操作组成的序列(先检查再插入)不是原子的。请使用专用方法:putIfAbsentcomputemerge

错误 2:把 CopyOnWriteArrayList 用在大且频繁修改的集合上。 它适合监听器列表,但在 10,000+ 元素并且频繁修改的情况下,会产生很大的时间与内存开销。

错误 3:在使用普通集合时出现 ConcurrentModificationException。 当你遍历 ArrayListHashMap,而另一个线程在修改集合——就会碰到 ConcurrentModificationException。应使用专用的线程安全集合,或手动加锁。

错误 4:忽视复杂操作的原子性。 如果需要同时修改多份集合或执行一系列相关操作——线程安全集合也无能为力。应使用外部同步或事务性逻辑。

错误 5:遍历 ConcurrentHashMap 时的误用。 遍历是弱一致的:不能把迭代器当作整张表的“快照”。若需要一致性的快照,请将数据复制到单独的结构中。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION