并发04:并发容器
从 ConcurrentHashMap 的分段锁到 CopyOnWriteArrayList 的读写分离,再到 BlockingQueue 的生产消费模式,系统理解 Java 并发容器的设计思想和适用场景。
并发容器不是加把锁这么简单
并发容器不是”普通集合外面包一把锁”。它真正做的是在正确性、吞吐、内存成本和迭代语义之间重新取舍。
比如 Collections.synchronizedMap(new HashMap<>()) 确实能用互斥锁保护 Map,但所有读写都抢同一把锁,迭代时还要额外手动同步。ConcurrentHashMap 则通过更细的并发控制和 CAS,把常见读写路径做得更适合高并发。
项目里选并发容器,不能只看名字里有没有 Concurrent,要看读写比例、是否需要阻塞等待、迭代结果是否必须强一致。
ConcurrentHashMap 的并发控制
ConcurrentHashMap 常用在本地缓存、连接映射、任务状态表这类场景。Java 8 之后,它不再使用早期的 Segment 分段锁模型,而是围绕数组、链表/红黑树、CAS 和节点级 synchronized 做并发控制。
| 版本 | 锁策略 | 特点 |
|---|---|---|
| Java 7 | Segment 分段锁 | 多个 Segment 并发,每个 Segment 互斥 |
| Java 8+ | CAS + 节点级锁 | 更细粒度,读写并发更高 |
它的一个重要能力是复合原子方法:
cache.computeIfAbsent(key, k -> loadFromDb(k));
这比先 get 再 put 更稳,因为”没有就创建”这个动作被容器封装成了原子语义。
扩容和树化机制
ConcurrentHashMap 仍然有哈希桶、链表和红黑树这些结构,但并发场景下最难的是扩容。扩容不能简单停住全世界,所以 Java 8 之后会让多个线程协助迁移桶数据。
当某个桶冲突很高时,链表会树化成红黑树,以降低查询复杂度。但树化不是只看链表长度,还和数组容量有关:容量太小时,优先扩容比树化更划算。
不过它也不是万能缓存。size() 在并发修改时只能尽力统计,不适合做强一致业务判断。用它判断”当前元素是否精确超过阈值”要非常谨慎。
弱一致迭代
ConcurrentHashMap 的迭代器是弱一致的。迭代时其他线程可以继续修改 Map,迭代器不会像 ArrayList 那样轻易抛 ConcurrentModificationException,但它也不保证你看到的是某个瞬间的完整快照。
这适合监控、统计、清理过期项这类场景:允许轻微误差,换取系统继续运行。
如果业务要求”这次遍历必须覆盖当前所有元素且不能多不能少”,那就不要直接依赖并发迭代语义。可以复制快照、加锁、用数据库分页,或者重新设计状态边界。
CopyOnWriteArrayList 读写分离
CopyOnWriteArrayList 的思路很直白:写入时复制一份新数组,改完后替换引用;读取时不加锁,读旧数组也没关系。
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
for (Listener listener : listeners) {
listener.onEvent(event);
}
| 场景 | 适用 | 不适用 |
|---|---|---|
| 监听器列表 | ✅ | - |
| 配置快照 | ✅ | - |
| 黑白名单 | ✅ | - |
| 高频写入的消息列表 | - | ❌ |
它不适合高频写入。每次写都复制数组,元素越多成本越高。把它拿来做高并发消息列表,内存和 CPU 都会很难看。
BlockingQueue 生产消费边界
BlockingQueue 不只是线程安全队列,它还表达了生产者和消费者之间的背压关系。
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1000);
queue.put(task);
Task task = queue.take();
队列满了,生产者等待或失败;队列空了,消费者等待。这让系统有机会把压力停在边界上,而不是无限创建任务。
队列选择就是压力模型选择
| 队列类型 | 特点 | 适用场景 |
|---|---|---|
ArrayBlockingQueue | 容量固定,边界清楚 | 稳定服务的任务缓冲 |
LinkedBlockingQueue | 默认容量很大 | 需要容量时必须显式设置 |
SynchronousQueue | 不存元素,直接交接 | 任务必须立即执行 |
PriorityBlockingQueue | 按优先级排序,默认无界 | 需要优先级控制,注意容量 |
DelayQueue | 延迟任务 | 定时任务场景 |
线程池里的工作队列,本质上也在做这件事。有界队列能让容量显性化,无界队列会把压力藏起来,最后变成内存压力和超时堆积。
常见误区
误区一:并发容器里放什么操作都安全
容器只能保证自己的结构安全,保证不了元素对象内部状态安全。Map 里放了一个普通 ArrayList,多个线程同时改这个 List,仍然会出问题。
误区二:线程安全方法组合起来就线程安全
containsKey 后再 put 不是整体原子,要用 putIfAbsent、computeIfAbsent 这类复合方法。
误区三:所有场景都用 ConcurrentHashMap
读多写少的小列表可能更适合 CopyOnWrite;需要等待和削峰的场景更适合 BlockingQueue;需要顺序和优先级时还要看具体队列。
项目里怎么选
选并发容器时先问:
- 数据主要是读多、写多,还是生产消费?
- 是否需要阻塞等待、超时放弃或有界背压?
- 迭代结果是否必须强一致?
- 容器里的元素自身是否也是线程安全的?
- 是否需要复合原子操作,而不是多个方法拼起来?
如果只是高并发 Key-Value 映射,优先看 ConcurrentHashMap;如果是监听器和配置快照,考虑 CopyOnWriteArrayList;如果是任务交接和削峰,先看有界 BlockingQueue。
并发容器的价值不是省掉思考,而是把常见并发访问模式做成更可靠的边界。