目录
[第一代补丁:同步容器(Vector / Hashtable)](#第一代补丁:同步容器(Vector / Hashtable))
[四组 API:一个表格记全](#四组 API:一个表格记全)
锁和原子类解决的是"怎么保护共享数据",这一篇上升到容器层面:Java 集合在并发下的安全演进------传统容器的坑 → 同步容器的低效 → 并发容器的高效设计,最后落到线程池的基石:阻塞队列。
一、传统容器线程安全吗?
不安全。 ArrayList、HashMap、LinkedList 等日常容器都没做任何并发控制:
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
list.add(j); // 多线程 add:可能丢数据、抛 ArrayIndexOutOfBoundsException
}
}).start();
}
多线程 add 出问题的原因:add 内部先判断容量、必要时扩容(复制数组)、再赋值------三步非原子,并发时可能覆盖元素 或扩容错位导致数组越界。
迭代时也不安全:一边遍历一边被其他线程 remove,抛 ConcurrentModificationException(fail-fast,靠 modCount 比对)。
第一代补丁:同步容器(Vector / Hashtable)
JDK 1.0 时代的 Vector、Hashtable 直接给每个方法加 synchronized:
public synchronized boolean add(E e) { ... } // Vector 的 add
两个硬伤:
-
性能差:读读也互斥------100 个线程读 Vector,也得排队;
-
组合操作依然不安全:
if (!vector.contains(x)) { // 检查:持锁
vector.add(x); // add:重新持锁 ------ 两步之间锁已释放!
}
// "检查再执行"不是原子的,照样可能重复添加
结论:同步容器只是"单个方法原子",容器级操作仍需外部加锁,且锁粒度太粗。这就是 JDK 5 引入并发容器的原因。
二、常见并发容器
Java 5 起在 java.util.concurrent 包提供新一代容器,设计思路不再是"大锁包一切",而是缩小锁范围或干脆无锁:
| 容器 | 替代对象 | 核心设计 |
|---|---|---|
ConcurrentHashMap |
HashMap | CAS + synchronized 锁单个桶(JDK 8) |
CopyOnWriteArrayList |
ArrayList | 写时复制,读完全无锁 |
CopyOnWriteArraySet |
HashSet | 内部就是 CopyOnWriteArrayList |
ConcurrentLinkedQueue |
Queue | CAS 无锁队列(Michael-Scott 算法) |
ConcurrentSkipListMap |
TreeMap | 跳表实现,天然支持并发有序 |
BlockingQueue 家族 |
Queue | 阻塞语义,见第三节 |
ConcurrentHashMap:锁的粒度演进
- JDK 1.7 :分段锁(Segment)------把整个 Map 切成 16 段,每段一把锁,不同段并发写入互不影响;
- JDK 1.8 :放弃分段,改为 CAS + synchronized 锁单个桶头节点 ------哈希越分散,冲突越少,理论并发度从 16 提升到桶的数量级别;size 统计用
baseCount + CounterCell[]分散计数(与 LongAdder 同款思路)。
详尽版见本系列《ConcurrentHashMap》专篇,这里记住演进逻辑:锁的粒度不断缩小。
CopyOnWriteArrayList:写时复制
读操作完全无锁 ,写操作加锁后复制整个数组、在副本上修改、再原子替换引用:
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1); // 复制新数组
es[len] = e;
setArray(es); // 原子替换引用(volatile)
return true;
}
}
- 读:无锁,且读到的是某一时刻的完整快照,迭代不抛 CME;
- 写:复制全数组,代价 O(n)。
适用场景:读极多、写极少、列表不大(如监听器列表、配置缓存)。写频繁时内存复制开销和旧数据一致性(弱一致)都不可接受。
三、阻塞队列
什么是阻塞队列
BlockingQueue 在普通队列语义上增加两条规则:
- 队列满时,put 的线程阻塞等待(而不是报错);
- 队列空时,take 的线程阻塞等待(而不是返回 null)。
这一个特性天然匹配生产者-消费者模型------生产消费速率不一致时,阻塞队列就是中间的"蓄水池",还自带线程安全:
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
new Thread(() -> { // 生产者
try {
queue.put(produce()); // 满了自动阻塞
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}).start();
new Thread(() -> { // 消费者
try {
consume(queue.take()); // 空了自动阻塞
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}).start();
对比上一篇手写生产者-消费者还要 lock + 双 Condition + while 循环------阻塞队列直接把这套同步逻辑封装好了。
四组 API:一个表格记全
BlockingQueue 对"插入/移除/检查"各提供四种失败处理方式:
| 操作方式 | 抛异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
|---|---|---|---|---|
| 插入 | add(e) |
offer(e) |
put(e) |
offer(e, time, unit) |
| 移除 | remove() |
poll() |
take() |
poll(time, unit) |
| 检查 | element() |
peek() |
--- | --- |
记忆:抛异常的用在"不该失败的地方" (队列状态由你保证);阻塞的用在生产者-消费者 ;超时的用在不想无限等的场景。
常见实现
| 实现类 | 结构 | 特点 |
|---|---|---|
ArrayBlockingQueue |
数组 + 一把锁 | 有界,必须指定容量,读写共用一把锁 |
LinkedBlockingQueue |
链表 + 两把锁(读写分离) | 默认无界(Integer.MAX_VALUE),读写各一把锁,吞吐更高 |
SynchronousQueue |
不存储元素 | 容量为 0:put 必须等 take 来"交接",一手交一手 |
PriorityBlockingQueue |
堆 | 按优先级出队,无界 |
DelayQueue |
堆 | 元素到期才能取出,定时任务场景 |
和线程池的关系
ThreadPoolExecutor 的任务缓冲区就是一个 BlockingQueue------这正是构造线程池时必填 workQueue 参数的原因:
new ThreadPoolExecutor(
corePoolSize, maximumPoolSize, keepAliveTime, unit,
workQueue, // ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue
threadFactory, handler);
Executors.newFixedThreadPool→ 无界LinkedBlockingQueue(任务堆积有 OOM 风险);Executors.newCachedThreadPool→SynchronousQueue(不缓存,来了就交给线程);- 阿里规约推荐手动
new ThreadPoolExecutor并使用有界队列,就是为了防止无界队列悄悄吃掉内存。
四、总结
| 知识点 | 一句话记忆 |
|---|---|
| 传统容器 | ArrayList/HashMap 并发下会丢数据、越界、CME |
| 同步容器 | Vector 方法级 synchronized:读读互斥性能差,组合操作仍不安全 |
| 并发容器演进 | 锁粒度不断缩小:整锁 → 分段 → 单桶 → 无锁 |
| ConcurrentHashMap | 1.7 分段锁,1.8 CAS + synchronized 锁单桶 |
| CopyOnWriteArrayList | 读无锁读快照,写复制全数组------读多写少专用 |
| 阻塞队列 | 满了 put 阻塞、空了 take 阻塞,生产者-消费者的现成方案 |
| 四组 API | add/offer/put/offer(timeout),按"失败怎么办"选 |
| 与线程池 | 线程池的任务缓冲就是 BlockingQueue,建议用有界队列防 OOM |
至此 JUC 主线(并发基础 → 锁 → AQS/原子类 → 并发容器)走完,后面的线程池、Future 体系都建立在这几块之上。