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!
结论:普通集合(ArrayList、HashMap、HashSet 等)并非线程安全(thread-safe)。如果没有额外同步(synchronized、锁等),就不能在多个线程间安全地使用它们。
2. Java 中有哪些线程安全的集合
Java 不会置你于不顾。为了解决多线程任务,包 java.util.concurrent 提供了一整套可以在多线程环境下安全使用的集合。
主要的线程安全集合:
| 集合 | 使用场景 | 特点 |
|---|---|---|
|
Map、缓存、频繁访问 | 高性能,无全局 lock |
|
List,写少读多 | 读快,修改慢 |
|
Set,写少读多 | 与 Copy-On-Write 列表类似 |
|
队列,FIFO | 快速、非阻塞,任务队列 |
|
有序的 Map(NavigableMap) | 线程安全的 TreeMap 替代品 |
|
有序的 Set | 线程安全的 TreeSet 替代品 |
|
带阻塞的队列(线程池) | 接口,拥有多种实现 |
重要! 古早的 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
CopyOnWriteArrayList 和 CopyOnWriteArraySet 是特殊的集合:每次修改(例如调用 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
- 分别是 TreeMap 和 TreeSet 的线程安全替代。
- 元素始终有序。
- 当需要维护键的顺序时使用。
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 及其实现
- 支持阻塞操作(等待元素出现/等待空间释放)的队列接口。
- 实现有:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 等。
- 用于线程池、生产者-消费者模式。
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 存储用户会话)。
- 线程间的任务队列(ConcurrentLinkedQueue、BlockingQueue)。
- 事件监听器列表(CopyOnWriteArrayList)。
- 多线程数据处理(例如 MapReduce 风格)。
限制与坑点
- 对多个元素的复合操作不是原子的。 例如 if (!map.containsKey(k)) map.put(k, v) 并非原子。请使用 putIfAbsent、computeIfAbsent、merge。
- 在频繁修改时,CopyOnWriteArrayList 效率很低。 当规模较大且频繁 add/remove 时,开销会迅速增大。
- 遍历 ConcurrentHashMap 是“弱一致”的。 遍历得到的是弱一致性快照:可能看不到部分并发修改。
- 线程安全集合并不能解决所有同步问题。 若业务逻辑同时涉及多份集合/变量,仍需要外部同步(synchronized、locks、原子类)。
8. 使用线程安全集合时的常见错误
错误 1:对 thread-safe 集合抱有魔法般的期待。 “既然集合是 thread-safe,就可以为所欲为,不必考虑同步。” 可惜并非如此:由多步操作组成的序列(先检查再插入)不是原子的。请使用专用方法:putIfAbsent、compute、merge。
错误 2:把 CopyOnWriteArrayList 用在大且频繁修改的集合上。 它适合监听器列表,但在 10,000+ 元素并且频繁修改的情况下,会产生很大的时间与内存开销。
错误 3:在使用普通集合时出现 ConcurrentModificationException。 当你遍历 ArrayList 或 HashMap,而另一个线程在修改集合——就会碰到 ConcurrentModificationException。应使用专用的线程安全集合,或手动加锁。
错误 4:忽视复杂操作的原子性。 如果需要同时修改多份集合或执行一系列相关操作——线程安全集合也无能为力。应使用外部同步或事务性逻辑。
错误 5:遍历 ConcurrentHashMap 时的误用。 遍历是弱一致的:不能把迭代器当作整张表的“快照”。若需要一致性的快照,请将数据复制到单独的结构中。
GO TO FULL VERSION