Java 并发容器入门:ConcurrentHashMap、CopyOnWriteArrayList、阻塞队列
📌 先说结论
普通集合(HashMap、ArrayList)都不是线程安全的,多线程同时改会出问题。并发容器就是 JDK 提供的线程安全版本。
本篇只讲三个,够应付面试:
| 容器 | 一句话定位 | 用在哪 |
|---|---|---|
ConcurrentHashMap |
线程安全的 HashMap |
多线程共享的缓存、计数器 |
CopyOnWriteArrayList |
写时复制,读多写少 | 监听器列表、配置列表 |
BlockingQueue |
阻塞队列,生产者-消费者 | 线程池的任务队列、消息缓冲 |
💡 先说个实测数据让你有概念 :10 个线程各累加 1 万次,用
HashMap最终只统计到 25197 (期望 100000),丢了 75% 的数据 。换成ConcurrentHashMap精确到 100000。
一、为什么普通集合不安全
1.1 实测:HashMap 并发累加会丢数据
java
Map<String, Integer> hm = new HashMap<>();
// 10 个线程,每个都执行 1 万次 count++
hm.merge("count", 1, Integer::sum);
实测结果(10 线程 × 1 万次 = 期望 100000):
| 集合 | 最终结果 | 说明 |
|---|---|---|
HashMap |
25197 | ❌ 丢了 75% 的数据 |
ConcurrentHashMap |
100000 | ✅ 完全正确 |
为什么会丢? 因为 HashMap 的 put 是「先读再写」的多步操作,多个线程同时执行时会互相覆盖。
1.2 为什么不用 Hashtable 和 Collections.synchronizedMap
这两个是"老古董",能用但性能差:
java
// ❌ 老办法:整表加锁
Hashtable<String, String> table = new Hashtable<>();
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
问题:所有操作都锁住整张表,10 个线程读写时排队,吞吐量上不去。
💡 一句话 :
Hashtable是"给整张表加一把大锁",ConcurrentHashMap是"只锁正在操作的那一小块"。用ConcurrentHashMap。
二、ConcurrentHashMap
2.1 关键特性(面试必答的三点)
① 不允许 null 键和 null 值
java
ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>();
chm.put("k", null); // ❌ NullPointerException
chm.put(null, "v"); // ❌ NullPointerException
实测:
put null value -> NullPointerException(不允许 null 值)
put null key -> NullPointerException(不允许 null 键)
对比 HashMap: 允许 null 键和 null 值
为什么不允许? 因为并发场景下无法区分"没有这个键"和"这个键的值是 null"。
在单线程里可以用 containsKey 判断,但并发下这两步之间的状态可能已经变了(别的线程刚插入了),所以干脆禁止 null。
② 线程安全,但"复合操作"仍要小心
java
// ❌ 错误:先检查再操作,不是原子的
if (!map.containsKey(key)) {
map.put(key, value); // 这两步之间可能被别的线程插队
}
// ✅ 正确:用原子方法
map.putIfAbsent(key, value); // 不存在才放
map.computeIfAbsent(key, k -> new ArrayList<>()); // 不存在才计算并放入
map.merge(key, 1, Integer::sum); // 不存在放1,存在则累加
⚠️ 这是最容易考的点 :
ConcurrentHashMap的单个方法 是线程安全的,但**"检查 + 操作"两个方法组合起来不是**。必须用putIfAbsent/computeIfAbsent/merge这类原子方法。
③ 迭代时不会抛 ConcurrentModificationException
java
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.put("a", "1");
map.put("b", "2");
for (String k : map.keySet()) {
map.remove(k); // ✅ 不会抛异常(弱一致性迭代器)
}
对比 HashMap 这么做会抛 ConcurrentModificationException。
2.2 底层原理(简单了解)
面试问"ConcurrentHashMap 怎么保证线程安全",答这三句就够:
JDK 7 :用分段锁(Segment),把整个表分成 16 段,每段一把锁,不同段可以并发访问。
JDK 8 :放弃分段锁,改成 CAS +
synchronized只锁单个桶(数组元素)。锁的粒度更细了,性能更好。
JDK 8 的插入流程(简化):
1. 算出 key 的哈希,定位到数组的某个位置(桶)
2. 如果那个位置是空的 → 用 CAS 直接放进去(不加锁!)
3. 如果有元素了 → synchronized 锁住这个位置的链表/红黑树头节点,再操作
4. 如果链表长度 ≥ 8 且数组长度 ≥ 64 → 转成红黑树
关键在于第 2 步 :大部分情况(桶是空的)用 CAS 无锁就能完成 ,只有发生冲突时才加锁。所以性能远好于 Hashtable。
💡 JDK 8 相比 JDK 7 的改进:锁粒度从"段"(1/16 的表)降到"单个桶",并发度从 16 提升到接近数组长度。而且 CAS 处理空桶,完全无锁。
2.3 什么时候用
✅ 适合 :多线程共享的缓存、计数器、本地缓存(Map<id, Object>)
❌ 不适合:需要强一致性遍历的场景(它的迭代器是"弱一致"的,遍历时可能看不到刚插入的数据)
三、CopyOnWriteArrayList
3.1 一句话原理
写的时候不直接改原数组,而是复制一份新数组,改完再把引用指过去。
读:直接从当前数组读(不加锁,非常快)
写:
1. 复制一份新数组(长度 + 1)
2. 在新数组上做修改
3. 把内部引用指向新数组(这一步用 volatile 保证可见性)
所以叫 "Copy-On-Write"(写时复制)。
3.2 实测:读快写慢
100 万次读 size(): 4.8 ms ← 读非常快(无锁)
1000 次写 add(): 2.9 ms ← 每次都要复制整个数组!
注意这个对比 :读 100 万次只要 4.8ms,但写 1000 次就要 2.9ms。写的开销是读的几百倍 ------因为每次 add 都要复制整个数组。
3.3 关键特性:遍历时修改不抛异常
java
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(List.of("a", "b", "c"));
for (String item : list) {
System.out.print(item + " ");
list.remove(item); // ✅ 不会抛 ConcurrentModificationException
}
实测输出:
遍历中各删除(不会抛 ConcurrentModificationException): a b c → 最终剩余=0
为什么? 因为遍历的是旧数组的副本 。你 remove 时创建的是新数组,遍历用的还是老的快照。所以遍历过程中看不到修改。
⚠️ 这就是它的代价 :
for循环里remove了元素,但循环还是会走完旧的三个元素。别指望遍历时修改能立刻生效。
3.4 什么时候用
✅ 适合 :读多写极少的场景
- 监听器/回调列表(注册后基本不变,但要频繁遍历触发)
- 配置项列表(偶尔更新,大量读取)
- 白名单/黑名单
❌ 不适合:写频繁的场景(每次写都复制整个数组,性能灾难)
一句话选型:
读远多于写(比如读 100 次写 1 次)→ 用
CopyOnWriteArrayList;写多 → 用Collections.synchronizedList或Vector(或者干脆加锁)。
四、BlockingQueue 阻塞队列
4.1 什么是"阻塞队列"
普通队列满了会报错,空了会返回 null。阻塞队列的特殊之处:
| 操作 | 队列满时 | 队列空时 |
|---|---|---|
add / offer |
报错 / 返回 false | --- |
put |
阻塞等待,直到有空位 | --- |
take |
--- | 阻塞等待,直到有数据 |
这就是生产者-消费者模式的天然载体 :生产者 put,消费者 take,队列自动处理"满了等、空了等"的逻辑。
4.2 三种常用阻塞队列
| 队列 | 容量 | 特点 | 用在哪 |
|---|---|---|---|
ArrayBlockingQueue(n) |
必须指定 | 数组实现,有界 | 线程池任务队列、固定大小的缓冲 |
LinkedBlockingQueue(n) |
可选(不指定=无界 ⚠️) | 链表实现 | 线程池默认队列(生产要指定容量) |
SynchronousQueue |
0 | 不存元素,直接交接 | newCachedThreadPool 用的就是它 |
4.3 实测:三种队列的容量特性
ArrayBlockingQueue(3) 放 5 个: [第4个被拒] [第5个被拒] 成功放入 3 个
LinkedBlockingQueue(3) 放 5 个: [第4个被拒] [第5个被拒] 成功放入 3 个
SynchronousQueue 直接 offer: 失败(容量 0,必须有消费者在等)
重点看 SynchronousQueue:它就是"容量 0",没有消费者在等就放不进去。
验证"交接"特性:
生产者放数据: 成功(因为消费者在等)
消费者取到: handoff
SynchronousQueue 的意义 :它不存储元素,生产者必须直接交给消费者 。newCachedThreadPool 用它,所以任务来了会立刻创建新线程执行(因为没有队列可以缓冲)。
4.4 生产者-消费者代码模板
这是面试常要求手写的场景:
java
BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);
// 生产者
new Thread(() -> {
try {
while (true) {
queue.put("数据" + System.currentTimeMillis()); // 队列满会阻塞
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// 消费者
new Thread(() -> {
try {
while (true) {
String data = queue.take(); // 队列空会阻塞
System.out.println("消费: " + data);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
注意 :put 和 take 都会抛 InterruptedException,必须处理(通常是恢复中断状态后退出)。
4.5 常用阻塞队列一览(了解即可)
| 队列 | 特点 |
|---|---|
ArrayBlockingQueue |
有界数组,最常用 |
LinkedBlockingQueue |
可选有界,线程池默认 |
SynchronousQueue |
容量 0,直接交接 |
PriorityBlockingQueue |
无界,按优先级排序 |
DelayQueue |
元素到时间才能取出(延时任务) |
LinkedTransferQueue |
更高性能的交接队列 |
五、三个并发容器怎么选(一表搞定)
| 需求 | 选哪个 |
|---|---|
| 多线程共享的 Map / 计数器 | ConcurrentHashMap |
| 读多写极少的 List | CopyOnWriteArrayList |
| 生产者-消费者 / 线程池任务队列 | BlockingQueue |
| 写频繁的 List | 加锁,或用 Collections.synchronizedList |
| 需要强一致性的遍历 | 加锁,或用普通集合 + 同步 |
六、面试常问的 7 个问题
1. HashMap 和 ConcurrentHashMap 有什么区别?
答:① 线程安全 :HashMap 不安全,并发写会丢数据(实测 10 线程累加 1 万次,HashMap 只统计到 25197,丢了 75%);ConcurrentHashMap 安全;② null 支持 :HashMap 允许 null 键值,ConcurrentHashMap 不允许 ;③ 迭代器 :HashMap 是 fail-fast(会抛 ConcurrentModificationException),ConcurrentHashMap 是弱一致(不抛)。
2. ConcurrentHashMap 为什么不支持 null?
答:因为并发场景下无法区分"键不存在"和"键存在但值是 null" 。单线程可以用 containsKey 两步判断,但并发下这两步之间状态可能变了,会误导调用方。所以直接禁止 null。
3. ConcurrentHashMap 怎么实现线程安全的?
答:JDK 7 用分段锁(Segment),把表分成多段,每段一把锁;JDK 8 改成 CAS + synchronized 锁单个桶 ------空桶用 CAS 无锁插入,有冲突时只 synchronized 锁那个桶的头节点。锁粒度更细,并发度更高。
4. ConcurrentHashMap 是绝对线程安全的吗?
答:单个方法是线程安全的,但复合操作不是。 比如 containsKey + put 组合起来就有竞态。必须用原子方法:putIfAbsent、computeIfAbsent、merge。
5. CopyOnWriteArrayList 的原理和适用场景?
答:写时复制 ------写入时不改原数组,而是复制一份新数组修改后再替换引用;读操作不加锁。适合读多写极少 的场景(如监听器列表)。写操作代价大(每次复制整个数组),写频繁时不能用。
6. 阻塞队列有什么用?put 和 offer 有什么区别?
答:阻塞队列用于生产者-消费者模式 ,队列满/空时自动阻塞等待。区别:offer 不阻塞 (满了返回 false),put 会阻塞 (满了等待)。同理 poll(空了返回 null)vs take(空了阻塞等待)。
7. SynchronousQueue 是什么?有什么用?
答:容量为 0 的阻塞队列 ,不存储元素,生产者必须直接"交接"给消费者。newCachedThreadPool 用的就是它------所以任务来了会立刻创建新线程执行(因为没有队列可以缓冲)。
七、总结
五个要点,面试够用:
- 普通集合不安全 ------实测
HashMap并发累加丢 75% 数据,必须用并发容器。 ConcurrentHashMap------线程安全的 Map;不允许 null ;单个方法安全但复合操作要用原子方法 (putIfAbsent/computeIfAbsent/merge)。CopyOnWriteArrayList------写时复制,读快写慢,适合读多写极少的场景。BlockingQueue------生产者-消费者专用,put/take会阻塞,offer/poll不会。- 别用
Hashtable和synchronizedMap------整表加锁,性能差,ConcurrentHashMap完胜。
一句口诀:
共享 Map 用 CHM,读多写少用 COW,生产消费用 BlockingQueue,老古董 Hashtable 别碰。
如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!如果疑问或补充,欢迎在评论区交流~
关注我,后续分享更多 Java 核心知识点和实战技巧!