enyang
enyang
Published on 2026-01-13 / 1 Visits
0
0

Java 多线程<6>——并发容器与 CAS

1 为什么需要并发容器

在多线程程序中,共享数据结构几乎不可避免。最直观的做法,是在普通容器外层加锁:

Map<String, Object> map = new HashMap<>();

synchronized (map) {
    map.put(key, value);
}

这种方式在低并发下可以工作,但一旦并发度上升,会迅速暴露问题:

  • 所有操作串行化,吞吐量极低

  • 锁粒度粗,读写无法区分

  • 容器内部结构对并发不友好

并发容器的核心目标不是“线程安全”,而是在保证线程安全的前提下,尽可能提高并发性能

2 并发容器的设计原则

2.1 不共享就不需要同步

并发容器并不是让所有操作都无锁,而是尽量减少共享范围。

  • 拆分内部结构

  • 降低锁竞争概率

  • 让更多线程同时向前推进

2.2 用 CAS 替代互斥锁

传统锁通过阻塞来解决竞争问题,而 CAS 通过重试来解决竞争问题。

CAS 的核心思想是:
只有当内存中的值与预期一致时,才进行更新,否则认为失败并重试。

这种方式避免了线程阻塞,但引入了新的设计挑战。

3 CAS 的底层语义

3.1 CAS 的基本形式

CAS 可以抽象为如下操作:

if (memory == expected) {
    memory = newValue;
    return true;
}
return false;

这个比较与交换的过程是由 CPU 指令保证原子性的。

3.2 Java 中的 CAS 支持

在 Java 中,CAS 通过 Unsafe 或 VarHandle 间接完成,对外暴露为原子类 API。

AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();

incrementAndGet 并不是简单的加 1,而是一个 CAS 自旋过程。

3.3 CAS 的典型执行流程

以 incrementAndGet 为例,逻辑等价于:

for (;;) {
    int current = value;
    int next = current + 1;
    if (compareAndSet(current, next)) {
        return next;
    }
}

多个线程同时执行时:

  • 只有一个线程 CAS 成功

  • 失败线程读取新值并继续尝试

  • 无线程阻塞

4 CAS 的问题与代价

4.1 ABA 问题

CAS 只能判断“值是否相等”,无法判断“是否被修改过”。

如果一个变量从 A 变成 B,又变回 A,CAS 会误认为它没有发生变化。

4.2 ABA 的解决思路

Java 通过版本号机制解决 ABA 问题。

AtomicStampedReference<Integer> ref =
    new AtomicStampedReference<>(100, 0);

每次更新不仅比较值,还比较版本号,从而识别中间变化。

4.3 CAS 并不一定更快

在以下场景中,CAS 可能比锁更差:

  • 高竞争导致大量自旋

  • 单核 CPU

  • 长时间失败重试

因此 CAS 适合冲突概率较低、操作简单的场景。

5 原子类体系详解

5.1 基本数值原子类

AtomicInteger
AtomicLong
AtomicBoolean

适合用于:

  • 计数器

  • 状态标志

  • 序列号生成

5.2 引用类型原子类

AtomicReference<T>
AtomicStampedReference<T>
AtomicMarkableReference<T>

用于对对象引用进行 CAS 操作,常见于无锁数据结构。

5.3 字段更新器

AtomicIntegerFieldUpdater

用于在不改变对象结构的前提下,对字段进行原子更新。

字段必须是 volatile,且不能是 final。

6 ConcurrentHashMap 的并发模型演进

6.1 JDK 7 的分段锁模型

JDK 7 的 ConcurrentHashMap 将数据分为多个 Segment:

  • 每个 Segment 内部是一个 HashMap

  • Segment 级别加锁

  • 并发度取决于 Segment 数量

这种设计在当年显著优于 Hashtable。

6.2 JDK 8 的 CAS + synchronized 混合模型

JDK 8 彻底移除了 Segment,采用:

  • 数组 + 链表 / 红黑树

  • CAS 控制桶级别竞争

  • synchronized 锁住单个桶

锁粒度进一步缩小到“单个 hash 槽”。

6.3 put 操作的关键路径

put 操作大致流程:

  1. 计算 hash

  2. CAS 尝试放入空桶

  3. 失败则对桶加锁

  4. 链表或树中插入节点

大部分情况下,put 不需要加锁。

6.4 为什么不用 ReentrantLock

ConcurrentHashMap 使用 synchronized 而非 ReentrantLock,是因为:

  • synchronized 在 JDK 8 后性能足够好

  • 锁对象生命周期更短

  • 减少对象创建成本

7 CopyOnWrite 容器的设计思想

7.1 CopyOnWrite 的核心假设

CopyOnWrite 的前提假设是:

  • 读操作远多于写操作

  • 写操作可以容忍较高成本

7.2 CopyOnWriteArrayList 的写时复制

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] newArray = Arrays.copyOf(array, array.length + 1);
        newArray[array.length] = e;
        array = newArray;
        return true;
    } finally {
        lock.unlock();
    }
}

写操作复制整个数组,读操作完全无锁。

7.3 使用 CopyOnWrite 的限制

  • 不适合频繁写

  • 不适合大对象

  • 内存占用较高

8 阻塞队列的并发模型

8.1 BlockingQueue 的设计目标

BlockingQueue 将:

  • 并发控制

  • 条件等待

  • 队列语义

统一封装,避免重复造轮子。

8.2 ArrayBlockingQueue 与 LinkedBlockingQueue

ArrayBlockingQueue 使用单锁控制入队和出队。

LinkedBlockingQueue 使用两把锁,分别控制 put 和 take,提高并发度。

8.3 阻塞队列与线程池的关系

线程池的核心组件就是 BlockingQueue。

  • 队列决定任务缓存策略

  • 队列大小影响系统稳定性

  • 错误配置会直接导致 OOM 或任务丢失

9 并发容器的使用误区

9.1 线程安全不等于逻辑安全

if (!map.containsKey(key)) {
    map.put(key, value);
}

即使 map 是 ConcurrentHashMap,这段代码依然不是线程安全的。

9.2 复合操作必须使用原子方法

应该使用:

map.putIfAbsent(key, value);

而不是手动拆分逻辑。

10 并发容器的选择策略

10.1 选择维度

  • 读写比例

  • 数据规模

  • 冲突概率

  • 是否需要阻塞语义

10.2 常见选择建议

  • 高并发 KV:ConcurrentHashMap

  • 读多写少列表:CopyOnWriteArrayList

  • 任务调度:BlockingQueue

  • 简单计数:AtomicInteger


Comment