Skip to content

并发集合:为共享数据挑一把合适的容器 ​

多线程直接用 HashMap 会出什么问题?ConcurrentHashMap 从 JDK 7 到 JDK 8 换掉了 Segment,靠什么把并发度撑起来?想要一个"读完全无锁"的队列,代价是什么?

前面的章节讲的是如何用锁、原子类、AQS 保护共享变量。这一章换个视角:JDK 已经把常见的并发场景封装成了容器——ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、BlockingQueue 家族。理解它们的内部结构,一是能选对;二是能推理性能形态,判断什么场景下锁竞争、复制或遍历会成为瓶颈。

1. 普通集合为什么不能并发使用 ​

Java 标准库里 ArrayList / HashMap / TreeMap 的 Javadoc 都写着一句几乎相同的话:"If multiple threads access an instance concurrently, and at least one of the threads modifies it structurally, it must be synchronized externally." 这不是建议,是硬性约束。

1.1 ArrayList 的 size++ 竞态 ​

ArrayList.add() 简化到最短:

java
public boolean add(E e) {
    ensureCapacityInternal(size + 1);
    elementData[size++] = e;
    return true;
}

size++ 在字节码层面是三步(读、加、写)。两线程并发:

txt
线程 A: 读 size=5
线程 B: 读 size=5          ← A 还没写回
线程 A: elementData[5]=a, size=6
线程 B: elementData[5]=b, size=6   ← 覆盖了 A 的写

结果:一次自增被吞掉、一个位置被覆盖,elementData 里丢了一个元素。两个线程可能同时使用同一个旧 size 作为数组下标,导致一个位置被覆盖;由于容量检查、下标计算和数组写入没有作为整体原子操作,其他交错执行还可能造成计数与数组内容不一致,甚至抛出越界异常。这是最经典的"丢更新 + 结构损坏"复合症状。

1.2 HashMap 的死循环(JDK 7) ​

JDK 7 的 HashMap 有一个上过面试题几百次的 bug:并发扩容时可能形成环形链表,导致 get() 死循环、CPU 100%。

根因是 JDK 7 扩容用头插法迁移链表。以下仅示意一种可能的并发交错,不是 JDK 7 扩容必然按此顺序执行:

txt
扩容前旧桶:A → B → null

线程 1 迁移到新桶(头插法):B → A
线程 2 同时迁移到新桶(也头插):A → B

若两条线程的迁移交叉,可能形成:
     A.next = B, B.next = A     ← 环

get() 触发到这个桶时可能沿着 next 进入环,长期不返回并持续消耗 CPU。

JDK 8 调整了扩容迁移逻辑,按哈希结果拆分链表并保留子链表内的相对顺序,避免了 JDK 7 这类经典的成环问题。但这不代表 HashMap 支持并发修改:无同步的并发写入仍可能产生数据丢失或结构与可见性问题。

1.3 一张表总结普通集合的并发风险 ​

集合并发问题根因
ArrayList元素丢失、越界异常size++ 非原子
HashMap(JDK 7)死循环、数据丢失头插法扩容 + 无同步
HashMap(JDK 8)数据丢失、resize 中间态无同步
HashSet同 HashMap底层是 HashMap
TreeMap数据丢失、红黑树结构损坏并发修改破坏树平衡

粗粒度的 Collections.synchronizedMap 能修正确性问题,但所有写操作会共用一把锁,高并发写入时吞吐可能明显下降。并发容器的意义是用更细的粒度或无锁算法,在安全与并发度之间取得更好的平衡。

2. ConcurrentHashMap:从 Segment 到 bin 级锁 ​

ConcurrentHashMap 是 java.util.concurrent 里用得最多的容器。它的实现在 JDK 7 到 JDK 8 之间发生了一次结构性重写。

2.1 JDK 7:Segment 分段锁 ​

思路是分段加锁——把整个 Map 切成若干段(Segment),每段有独立的锁,不同段的操作可以并行:

txt
ConcurrentHashMap(JDK 7)
├── Segment[0]  (ReentrantLock) → HashEntry[] → 链表
├── Segment[1]  (ReentrantLock) → HashEntry[] → 链表
├── ...
└── Segment[15] (ReentrantLock) → HashEntry[] → 链表
    默认构造通常为 16 段,Segment 数量由构造参数决定

核心结构:

java
static final class Segment<K,V> extends ReentrantLock {
    transient HashEntry<K,V>[] table;
    transient int count;
}

final Segment<K,V>[] segments;   // 默认 16

定位是两级 hash:先对哈希值扰动,再通过掩码与移位定位 Segment 和桶。读操作靠 volatile 保证可见性,不加锁。

局限也直接摆在设计里:

  • Segment 数量在构造时确定——默认构造通常为 16 段,创建后不随内部哈希表容量扩展
  • 热点 key 集中在少数 Segment:并发度退化到几乎为 1
  • Segment 本身占内存:每个 Segment 都是一个 ReentrantLock + 独立的 HashEntry[]

2.2 JDK 8:CAS + synchronized 锁 bin ​

JDK 8 抛弃了 Segment,回到单个 Node[] 数组 + bin 级锁:

txt
ConcurrentHashMap(JDK 8+)
Node[] table
├── [0] → Node → Node → Node(链表)
├── [1] → null
├── [2] → TreeBin → 红黑树(链表长度 > 8 且表容量 ≥ 64 时转树)
├── [3] → Node
└── ...
锁粒度:单个 bin

putVal 的核心骨架:

java
final V putVal(K key, V value, boolean onlyIfAbsent) {
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();                              // CAS 初始化

        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 空 bin:一次 CAS 直接插入,完全无锁
            if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
                break;

        } else if ((fh = f.hash) == MOVED) {
            tab = helpTransfer(tab, f);                      // 参与扩容

        } else {
            // 非空 bin:只锁这个 bin 的头节点
            synchronized (f) {
                if (tabAt(tab, i) == f) {                    // 二次校验
                    // 链表或红黑树的插入逻辑
                }
            }
        }
    }
}

两个关键设计点:

  • 空 bin 走 CAS:完全无锁,写热点分散到不同 bin 时几乎无竞争
  • 非空 bin 走 synchronized:锁粒度到"单个 bin 的头节点",比 Segment 细一个数量级

2.3 JDK 8 为什么从 ReentrantLock 换回 synchronized ​

反直觉的一件事:JDK 7 用的是 ReentrantLock(因为 Segment 继承它),JDK 8 换成了 synchronized。原因需要区分可验证的实现事实与设计动机:

  • 更细的同步粒度:JDK 8 把同步粒度从 Segment 缩小到具体 bin,空 bin 用 CAS 插入,非空 bin 以桶头节点为监视器同步,不同 bin 的操作更容易并行。粒度设计和锁原语选择是两个不同问题,不能只凭粒度解释为什么选 synchronized 而非 ReentrantLock。
  • 减少额外锁对象:使用已有的 bin 头节点作为监视器,避免为每个同步单元额外创建 ReentrantLock 对象及其状态。
  • 利用 JVM 对监视器的实现与优化:HotSpot 持续优化监视器实现和 JIT 对同步代码的处理(第 6 章的锁优化部分)。

不能据此断言 synchronized 在所有场景都比 ReentrantLock 更快,实际性能取决于锁竞争、代码形态与 JVM 实现。

2.4 size() 的分散计数 ​

并发计数最容易撞的坑是"所有线程 CAS 同一个变量"。JDK 8 用 baseCount + CounterCell[] 分散热点:

java
long baseCount;                    // 无竞争时直接 CAS 这个
volatile CounterCell[] counterCells;// 竞争激烈时打散到多个 Cell

public int size() {
    long sum = baseCount;
    if (counterCells != null) {
        for (CounterCell c : counterCells) {
            if (c != null) sum += c.value;
        }
    }
    return (sum > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int) sum;
}

这与 LongAdder的核心思路相似:将高竞争下的更新压力分散到多个计数单元,最后再汇总。代价是 size() 在没有并发修改时返回当前映射数量,在并发更新期间通过汇总计数单元得到结果,但不保证与某一个瞬间的完整 Map 状态严格对应。

2.5 JDK 7 vs JDK 8 一览 ​

维度JDK 7 SegmentJDK 8+ CAS + synchronized
锁粒度一整个 Segment(默认 16 个)单个 bin 的头节点
并发度上限16bin 数(通常上千)
空 bin 写入需持 Segment 锁一次 CAS 完成
数据结构链表链表 + 红黑树(长度 > 8 转树)
锁类型ReentrantLocksynchronized(走锁升级)
计数每个 Segment 计数baseCount + CounterCell[]

3. CopyOnWrite 容器:读完全无锁 ​

CopyOnWriteArrayList / CopyOnWriteArraySet 的思路和 ConcurrentHashMap 完全不同:读端根本不加锁,写端通过复制整个底层数组来隔离读写。

3.1 写时复制的工作过程 ​

txt
当前数组:[A, B, C, D]   ← array 引用指向这里

线程 T1 执行 add(E):
  1. 拿写锁(保证写-写互斥)
  2. 复制数组 → [A, B, C, D, null]
  3. 新数组尾部写入 E → [A, B, C, D, E]
  4. array = newArray(volatile 写发布)
  5. 释放写锁

核心代码简化:

java
public class CopyOnWriteArrayList<E> {
    private transient volatile Object[] array;

    public boolean add(E e) {
        synchronized (lock) {                   // 写-写互斥
            Object[] old = array;
            Object[] neu = Arrays.copyOf(old, old.length + 1);
            neu[old.length] = e;
            array = neu;                        // volatile 写,读端立即可见
            return true;
        }
    }

    @SuppressWarnings("unchecked")
    public E get(int index) {
        return (E) array[index];                // 无锁读
    }
}

get() 不获取锁,也不需要 CAS;它读取 volatile 数组引用,再访问对应元素。与加锁读取相比路径较轻,但不能把 volatile 的内存语义当成不存在。

3.2 迭代器是"快照" ​

CopyOnWriteArrayList.iterator() 拿到的是创建那一刻的 array 引用,之后无论谁 add / remove,迭代器一无所知:

java
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("A");
list.add("B");

Iterator<String> it = list.iterator();     // 拿到 [A, B] 的快照

list.add("C");                              // 不影响 it

while (it.hasNext()) {
    print(it.next());                       // 只输出 A、B
}

这解决了 ArrayList 常见的 ConcurrentModificationException——迭代期间不会因为并发修改而抛异常。代价是读到的可能是旧数据(弱一致性)。快照只固定迭代器看到的数组及其元素引用,不包含元素对象状态的深拷贝;元素对象内部的可变状态仍需单独保证线程安全。

3.3 写成本 O(N) 决定了适用场景 ​

维度优势代价
读完全无锁,性能等同于普通数组访问可能读到旧数据
写不阻塞任何读每次写复制整个数组,O(N) 时间和内存
迭代不抛 ConcurrentModificationException迭代的是快照,不含期间新加的元素

适合"读远大于写、且写发生频率很低、数据量不太大"的场景:

  • 事件监听器列表
  • 白名单 / 黑名单 / 路由表
  • 应用启动时加载、运行期极少变的配置

不适合任何需要频繁写的场景——写 100 万次意味着复制 100 万次整个数组。

3.4 一个典型用法 ​

java
public class EventBus {
    private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();

    public void register(Listener l)   { listeners.add(l); }
    public void unregister(Listener l) { listeners.remove(l); }

    public void fire(Event e) {
        for (Listener l : listeners) {         // 无锁遍历
            l.onEvent(e);
        }
    }
}

listeners 变化频率极低(一般只在启动或组件生命周期变化时),fire 高频调用。这是 CopyOnWrite 最舒服的场景。

4. ConcurrentSkipListMap:有序并发 ​

ConcurrentHashMap 不保证遍历顺序。需要"线程安全 + 按 key 有序 + 支持范围查询"时,ConcurrentSkipListMap 是标准答案。

4.1 跳表结构 ​

跳表(Skip List)是一种概率平衡的有序数据结构。它通过多层索引加速查找:

txt
Level 3:  head ──────────────────────────► 50 ────────────────► null
Level 2:  head ─────────► 20 ─────────────► 50 ────► 70 ──────► null
Level 1:  head ► 10 ────► 20 ────► 30 ────► 50 ────► 70 ► 80 ─► null

查找 50:从 Level 3 直接跳到 50,一步命中。平均查找 O(log N),与红黑树相当,但插入 / 删除只需要调整链表指针,不涉及旋转——这也是它比红黑树更适合并发的原因。

4.2 无锁插入的关键机制 ​

ConcurrentSkipListMap.put 完全靠 CAS 完成,不使用 synchronized:

  • 定位插入点后,用 CAS 修改前驱节点的 next 指针,把新节点接入
  • CAS 失败说明其他线程改动了前驱,重新定位再试
  • 索引层的建立也是 CAS——概率性地决定新节点建到哪一层

这套无锁算法让 ConcurrentSkipListMap 的读写都不需要加锁。它的迭代器是弱一致性的:可以与并发修改同时进行,不会因并发修改而抛出 ConcurrentModificationException,但不提供遍历开始时的全量快照。

4.3 与 ConcurrentHashMap 的选型 ​

维度ConcurrentHashMapConcurrentSkipListMap
顺序无按 key 有序
底层数组 + 链表 / 红黑树跳表
查找O(1) 平均O(log N)
范围查询不支持subMap / headMap / tailMap
典型场景通用并发 Map需要排序或范围查询

代表场景:时间线索引(key 是时间戳)、按分数排名的排行榜、按前缀过滤的路由表。

5. 无锁队列与阻塞队列 ​

队列是线程池、Reactor、事件总线、日志异步落盘的底层零件。JDK 里有两大家族:无锁队列(ConcurrentLinkedQueue / LinkedTransferQueue)与阻塞队列(BlockingQueue 家族)。

5.1 ConcurrentLinkedQueue:Michael-Scott 算法的直接实现 ​

ConcurrentLinkedQueue 是无界、无锁、线程安全的 FIFO 队列。它的实现是 Michael 和 Scott 在 1996 年发表的M&S 无锁队列算法——教科书级的经典。

核心结构极简:

java
private transient volatile Node<E> head;
private transient volatile Node<E> tail;

static final class Node<E> {
    volatile E item;
    volatile Node<E> next;
}

入队(offer)的两处 CAS 操作:

txt
初始:  head → dummy ── tail
                ▲
             (next=null)

线程 A 入队 X:
  1. CAS 把 tail.next 从 null 改成 new Node(X)   ← 关键第一步
       head → dummy → X
                       ▲
                     (tail 还没动)
  2. CAS 把 tail 从原节点改到 new Node             ← 关键第二步
       head → dummy → X
                       ▲
                      tail

两步中间可能被别的线程"抢跑"——但没关系。M&S 算法的巧妙在于:任何看到 tail.next != null 的线程都会帮忙推进 tail,然后再尝试自己的入队:

java
public boolean offer(E e) {
    final Node<E> newNode = new Node<E>(Objects.requireNonNull(e));
    for (Node<E> t = tail, p = t;;) {
        Node<E> q = p.next;
        if (q == null) {
            // 找到"真正的尾节点",尝试挂上新节点
            if (p.casNext(null, newNode)) {
                if (p != t)
                    casTail(t, newNode);          // 尽力推进 tail,失败也无妨
                return true;
            }
        } else if (p == q) {
            // 遇到自引用(因为节点被出队),重新定位
            p = (t != (t = tail)) ? t : head;
        } else {
            // tail 落后了,跳到 q 继续找
            p = (p != t && t != (t = tail)) ? t : q;
        }
    }
}

关键理解:tail 允许“暂时落后于真实尾”。算法约定任何线程发现 tail 落后都可以协助推进,并处理节点自引用和并发入队;整个过程不使用 synchronized 或 park。

出队(poll)先原子摘取元素:通常把队首节点的 item CAS 为 null,成功后返回原值;推进 head 是辅助清理步骤,只在满足条件时执行,并非每次固定再做一次 CAS。

5.2 无锁队列的正确性来源 ​

无锁队列听着神奇——没有锁,凭什么保证并发正确?三个关键点:

  • CAS 是原子操作:任何 CAS 要么整体成功,要么整体失败,不存在中间态
  • 协作性算法:任何看到"中间态"的线程都会主动帮忙推进到"完全态"(推 tail、跳过被移除节点)
  • 失败即重试:for(;;) 循环让失败线程立刻重试,不阻塞、不 park

M&S 队列通常被归类为无锁(lock-free)而非无等待(wait-free)——它不保证每条线程都在有限步内完成,但算法设计目标是保证整体进展。

5.3 size() 是 O(N) ​

一个必须记住的坑:ConcurrentLinkedQueue.size() 不是 O(1)——它遍历整个队列计数:

java
public int size() {
    int count = 0;
    for (Node<E> p = first(); p != null; p = succ(p)) {
        if (p.item != null)
            if (++count == Integer.MAX_VALUE)
                break;
    }
    return count;
}

该实现不维护全局精确计数:入队或出队都更新一个共享计数会增加热点竞争。生产代码里频繁调用 ConcurrentLinkedQueue.size() 容易造成性能问题:它把预期为常数时间的查询变成了 O(N) 遍历。

5.4 LinkedTransferQueue:ConcurrentLinkedQueue + 传递语义 ​

LinkedTransferQueue(JDK 7 引入)是同源家族里的加强版:同时是无锁队列,又支持"必须有消费者才成功"的传递语义:

java
LinkedTransferQueue<Task> queue = new LinkedTransferQueue<>();

// offer / put:立即返回,行为等同 ConcurrentLinkedQueue
queue.put(task);

// transfer:阻塞直到有消费者 take 到这个元素
queue.transfer(task);         // 一直等
queue.tryTransfer(task);       // 立即失败版
queue.tryTransfer(task, 1, TimeUnit.SECONDS);   // 超时版

三种语义在一个容器里:

方法语义用途
offer / put入队立即返回,不等消费者常规异步队列
transfer阻塞到消费者取走强"交接"场景
tryTransfer只有消费者已在等时才成功探测式提交

LinkedTransferQueue 底层用一种叫 双队列(dual queue) 的算法——同一条链表里同时保存"待消费的数据节点"和"等待数据的消费者节点"。入队时如果发现链尾是"等待消费者"节点,直接把数据塞给他;如果是"数据"节点,就把自己挂到尾部。

它比 SynchronousQueue 更灵活(因为可以 offer 不等)、比 ConcurrentLinkedQueue 更强大(因为可以 transfer 等)。LinkedTransferQueue 适合既需要常规异步入队,又有部分操作要求等待消费者实际接收的场景。但它不提供固定容量背压,若生产者可能长期快于消费者,应评估有界 BlockingQueue;若只需要普通的非阻塞入队与出队,则 ConcurrentLinkedQueue 可能更简单。是否选择 LinkedTransferQueue,应由交接语义、内存边界与负载特征决定。

5.5 BlockingQueue 家族一览 ​

BlockingQueue 的核心语义:队列满时 put 阻塞,队列空时 take 阻塞。天然适合生产者-消费者模型。JDK 提供五种实现:

实现边界底层锁数量特点
ArrayBlockingQueue有界数组1 把锁简单、通用;put/take 互相阻塞
LinkedBlockingQueue可选有界(默认 Integer.MAX_VALUE)单链表2 把锁(put/take 分离)高吞吐;默认无界要小心 OOM
SynchronousQueue0(不存储)无并发匹配算法与线程等待机制put 与 take 必须配对,"握手"传递
PriorityBlockingQueue无界二叉堆1 把锁按优先级出队
DelayQueue无界PriorityQueue1 把锁元素延时到期后才可出队

其中 LinkedBlockingQueue 的两把锁设计(putLock 和 takeLock)是它高吞吐的核心——两把锁让常见的入队与出队路径可以分别受保护,生产者和消费者在许多情况下能够并行执行,减少了单锁队列中插入与移除直接竞争同一把锁的情况。但它们仍需通过计数和条件信号协调,并非完全独立。

5.6 生产者-消费者的标准形态 ​

java
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1000);

// 生产者
new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        Task t = createTask();
        queue.put(t);                         // 满则阻塞
    }
}).start();

// 消费者
new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        Task t = queue.take();                // 空则阻塞
        process(t);
    }
}).start();

对比第 6 章的 wait/notify,BlockingQueue 少了三个坑:条件判断的 while 循环、notifyAll 惊群、忘了持锁的 IllegalMonitorStateException。所有这些都被封装到容器里。

5.7 三类队列的选型 ​

需求推荐
只是异步队列、不需要限流、不需要阻塞ConcurrentLinkedQueue
生产者-消费者、需要"满则阻塞、空则阻塞"、可控内存ArrayBlockingQueue(有界) / LinkedBlockingQueue(显式指定容量)
生产者-消费者、且需要"必须交给消费者才成功"的精确交接LinkedTransferQueue 或 SynchronousQueue
按优先级消费PriorityBlockingQueue
定时消费(延迟队列)DelayQueue

无界队列(ConcurrentLinkedQueue、LinkedTransferQueue、默认的 LinkedBlockingQueue)不会因为达到配置容量而提供背压。如果生产速度长期超过消费速度,队列可能持续增长;需要控制积压时,应优先评估有界 BlockingQueue 或在上游设置限流机制。

第 11 章 ThreadPoolExecutor的 workQueue 就是从这里挑一种——不同队列直接决定线程池的调度形态。

6. 选型:安全、并发度、开销的三角权衡 ​

并发容器的差异,本质是三个维度上的取舍:

txt
             安全性
             /    \
            /      \
      并发度 ────── 内存 / 时间开销

ConcurrentHashMap 在三角形中间——安全、并发度高、开销中等。其他容器都是"某个维度极致 + 某个维度让步":

容器安全并发度主要开销
ConcurrentHashMap✅高(bin 级锁)中(数组 + Node)
CopyOnWriteArrayList✅读极高、写极低高(O(N) 内存复制)
ConcurrentLinkedQueue✅高(无锁)size() 是 O(N)
LinkedTransferQueue✅高(无锁 + 传递)结构复杂、size() 也是 O(N)
ArrayBlockingQueue✅中(一把锁)内存固定
LinkedBlockingQueue✅较高(两把锁)默认无界 → 潜在 OOM
Collections.synchronizedMap✅极低(全局锁)简单,兜底方案

判断选哪个的一条起手线:先想清楚读写比、有无顺序需求、能不能容忍弱一致性。三个问题回答完,选型基本就出来了。

7. 本章小结 ​

问题根源解决方案
ArrayList.add 丢元素size++ 非原子用 CopyOnWriteArrayList 或外部同步
HashMap 死循环(JDK 7)头插法 + 并发扩容JDK 8 尾插法;生产用 ConcurrentHashMap
Segment 数量构造时确定段数创建后不随哈希表扩容JDK 8 换 bin 级锁
精确计数成为写热点单个 AtomicLong CAS 争抢baseCount + CounterCell[] 分散计数
需要"读无锁"的容器常规锁读写都要竞争CopyOnWriteArrayList(写时复制)
需要"排序 + 并发"的 Map无ConcurrentSkipListMap(跳表)
需要"无阻塞"的 FIFO 队列阻塞队列 put/take 都要 parkConcurrentLinkedQueue(M&S 算法)
需要"精确交接"的队列SynchronousQueue 太受限LinkedTransferQueue
size() O(N) 陷阱无锁队列不维护实时计数不要频繁调 size(),用其他指标监控
生产者-消费者语义手写 wait/notify 易错BlockingQueue 家族

8. 下一步 ​

并发集合提供数据结构层面的并发安全,任务调度仍需要线程池管理执行资源。需要组合多个异步结果时继续阅读异步编程;出现吞吐下降或 key 语义错误时,从并发问题诊断进入。

Released under the MIT License.