Java并发(四):CAS、原子类与并发容器
Java并发(四):CAS、原子类与并发容器
导语:本篇走「无锁并发」与「并发容器」两条线:CAS 的原理、ABA 问题与自旋开销,原子类家族与分段计数的 LongAdder,以及伪共享这一进阶性能话题;容器侧覆盖 ConcurrentHashMap 的双版本实现与并发扩容、各类阻塞队列选型、写时复制与无锁队列。共 12 题。
一、CAS 与原子类
1. 什么是 CAS?原理是什么?
答: CAS(Compare-And-Swap,比较并交换)是一种乐观的无锁原子操作,语义为 CAS(V, A, B):
- 若内存位置 V 的当前值等于预期值 A,则原子地把它更新为 B,返回成功;
- 否则不做任何操作,返回失败(调用方通常重新读取再重试)。
底层实现:由 CPU 提供的原子指令保证,x86 上是 cmpxchg 指令配 LOCK 前缀。LOCK 前缀会锁住总线或缓存行,确保"比较 + 交换"整体不可分割。
Java 中通过 sun.misc.Unsafe(JDK 9 后对外推荐用 VarHandle)暴露这些原子操作,java.util.concurrent.atomic 包下的类都基于它实现。
CAS 是典型的乐观策略:先假定没有冲突,直接尝试修改,失败再重试——因为不加锁,所以没有阻塞与线程切换开销。
2. CAS 存在哪些问题?如何解决?
答: 三个经典问题:
1)ABA 问题
值从 A 改成 B 又改回 A,CAS 检查时以为"没变过",于是放心更新——但中间状态变化被掩盖了。
- 解决:加版本号/时间戳,把"值 + 版本"一起比较。JDK 提供
AtomicStampedReference(值 + int 版本戳)和AtomicMarkableReference(值 + boolean 标记)。 - 注意:如果业务只关心最终值(如计数器),ABA 无害,无需处理;若是"栈/链表节点复用"这类场景,ABA 会造成严重问题。
2)自旋开销大
高并发下大量线程反复 CAS 失败重试,空耗 CPU,且可能加剧总线/缓存行争用。
- 解决:限制自旋次数、失败后退避(backoff)、或分段分散热点(
LongAdder的Cell就是典型);极端竞争下不如直接加锁。
3)只能保证单个变量的原子性
多个变量无法一次性 CAS。
- 解决:把多个变量封装成一个对象用
AtomicReference整体替换;或改用锁。
3. AtomicInteger 是如何保证线程安全的?
答: 内部结构极简:
private volatile int value; // ① volatile 保证可见性所有更新方法(如 incrementAndGet())通过 CAS 自旋完成:循环读取当前值 → 计算新值 → CAS 写回 → 失败则重试,直到成功。
特点:
- 无锁、无阻塞,因此没有线程挂起/唤醒与上下文切换开销;
- 极端高竞争下会自旋消耗 CPU(见第 2 题),此时
LongAdder更合适; volatile只解决"看得到",CAS 才解决"改得对"——二者缺一不可。
4. LongAdder 是什么?相比 AtomicLong 有什么优势?
答: LongAdder(JDK 8)是为高并发计数专门设计的原子类,核心思想是分段累加分散竞争:
- 内部维护一个
base变量和一组Cell[]数组; - 低竞争时直接 CAS 更新
base; - 高竞争时,线程按哈希落到不同的
Cell上各自 CAS 累加,把对"同一个热点变量"的竞争分散到多个变量上; - 读
sum()时把base与所有Cell累加得到结果。
| 维度 | AtomicLong | LongAdder |
|---|---|---|
| 并发写 | 所有线程竞争同一个变量 | 分散到多个 Cell,竞争大幅降低 |
| 写吞吐 | 低(高并发下急剧下降) | 高(近似线性扩展) |
| 读结果 | 精确值 | 非原子快照,并发更新时可能不准 |
| 内存占用 | 小 | 更大(Cell 数组 + 缓存行填充) |
选型:只做统计计数(如 QPS、调用次数)用 LongAdder;需要精确的读-改-写语义(如"取当前值并加一后返回")则必须用 AtomicLong。
关联:
ConcurrentHashMap的size()也用同样的思路(baseCount+CounterCell[]),可见"分段分散竞争"是 JUC 的通用套路。
5. 原子类家族有哪些?
答: java.util.concurrent.atomic 下共五类:
| 分类 | 代表类 | 说明 |
|---|---|---|
| 基本类型 | AtomicInteger、AtomicLong、AtomicBoolean | 最常用 |
| 引用类型 | AtomicReference、AtomicStampedReference、AtomicMarkableReference | 后者用于解决 ABA |
| 数组 | AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray | 数组元素的原子更新 |
| 字段更新器 | AtomicIntegerFieldUpdater 等 | 通过反射原子更新已存在的 volatile 字段,省去包装对象的内存开销 |
| 累加器 | LongAdder、DoubleAdder、LongAccumulator、DoubleAccumulator | 高并发累加,JDK 8 新增 |
字段更新器使用前提:字段必须
volatile、不能是private/static/final,且调用方必须有访问权限(否则抛异常)。它常用于"对象已经存量大,不想再包一层 Atomic 对象"的场景。
6. 什么是伪共享(False Sharing)?如何避免?
答: CPU 缓存以缓存行(Cache Line)为单位加载,x86 通常是 64 字节。
伪共享指:多个线程分别修改位于同一缓存行内、但逻辑上无关的不同变量。虽然它们没有真正的数据竞争,但按 MESI 缓存一致性协议,一个 CPU 修改该缓存行后会让整条缓存行在其他 CPU 上失效,迫使其他 CPU 反复从主存重新加载——性能急剧下降,而且不报错、极难排查。
避免方式:
- 缓存行填充(padding):在热点变量前后各填充若干无用字段,让它独占一条缓存行;
@Contended注解(JDK 8+):由 JVM 自动完成填充,用户代码中默认被限制,需加-XX:-RestrictContended才生效;- 实际应用:
LongAdder的Cell、ConcurrentHashMap的CounterCell都标注了@Contended;Thread的部分字段、Disruptor 的经典 padding 技巧也是为此。
这也是
LongAdder在高并发下远超AtomicLong的第二个原因(第一个是分段分散竞争):多个Cell不只分散了 CAS 竞争,还通过填充避免了Cell之间互相"拖缓存"。
二、并发容器
7. ConcurrentHashMap 在 JDK 7 与 JDK 8 的实现有何差异?
答: 这是高频题,必须区分版本。
JDK 7:
- 结构为
Segment[]数组,每个Segment继承ReentrantLock,内部是HashEntry[]数组 + 链表; - 采用分段锁(Segment Lock),默认 16 个段,理想并发度 16,不同段可并发写;
size()需要遍历所有段、加锁并重试,实现繁琐。
JDK 8(及之后):
- 摒弃 Segment,改为
Node[]数组 + 链表 / 红黑树(与HashMap一致,链表长度 ≥ 8 且容量 ≥ 64 转树,退化阈值为 6); - 并发控制用 CAS +
synchronized(锁单个桶的头节点),粒度更细,并发度不再固定为 16; size()改用baseCount+CounterCell[]分段计数(思想同LongAdder),高并发下无需全局锁。
关键纠正:不要说"JDK 8 用 CAS 完全替代了 synchronized"——实际是组合使用:空桶用 CAS 放置头节点,非空桶则
synchronized锁住桶的头节点再插入/更新。
TreeBin的线程安全:JDK 8 中红黑树被TreeBin包装,TreeBin通过lockState(WRITER/WAITER/READER)在树旋转(根节点会变化)期间维护写锁与读锁,并用waiter记录等待写锁的线程;读操作在检测到写锁时可退化为链表遍历,从而保证并发读不被阻塞。
8. ConcurrentHashMap 的并发扩容是如何实现的?
答: JDK 8 支持多线程协同扩容,避免单线程迁移大表造成长时间停顿。四个关键设计:
1)扩容状态编码在 sizeCtl 中sizeCtl 为负表示正在扩容:高 16 位是扩容戳(resizeStamp),低 16 位是参与扩容的线程数 + 1。这样既能标识"这是哪一次扩容"(防止重复初始化),又能统计协助线程数。
2)transfer() 分段迁移
每个参与线程按 stride(步长,至少 16 个桶)从旧表从后往前"认领"一段区间,通过 CAS 更新 transferIndex 抢占,各线程负责不同区段,互不冲突。
3)ForwardingNode 占位
某个桶迁移完成后,在旧表的原位置放一个 hash = MOVED(-1) 的 ForwardingNode,指向新表。
4)helpTransfer() 协助扩容
其他线程进行 put/get 时若遇到 ForwardingNode,说明正处于扩容中:put 会调用 helpTransfer() 一起参与迁移(把"阻塞等待"变成"帮忙干活"),get 则直接到新表查找。
效果:扩容从"一个人搬、其他人干等"变成"大家一起搬",扩容期间仍可正常读写(弱一致性),停顿被大幅摊薄。这是 JDK 8 相比 JDK 7 分段锁最重要的机制进步。
9. 常见阻塞队列的实现原理与区别?
答: 阻塞队列实现"满时阻塞生产者、空时阻塞消费者",底层多用 ReentrantLock + Condition。
| 实现 | 有界性 | 底层结构 | 特点 |
|---|---|---|---|
ArrayBlockingQueue | 有界(必须指定容量) | 数组 | 一把锁 + 两个 Condition(notEmpty/notFull) |
LinkedBlockingQueue | 可选(默认 Integer.MAX_VALUE 即无界) | 链表 | 读写两把锁,并发更高;Executors 默认用它的无界模式 |
SynchronousQueue | 容量为 0 | 无存储 | 生产者必须直接移交给消费者;newCachedThreadPool 使用 |
PriorityBlockingQueue | 无界 | 堆 | 按优先级出队,元素需可比较 |
DelayQueue | 无界 | 堆 + 延迟 | 元素到期才出队,用于定时任务、缓存过期 |
LinkedTransferQueue | 无界 | 链表 | 支持 transfer() 同步移交,性能优于 SynchronousQueue |
选型要点:
- 需要可控的队列长度(防止任务堆积 OOM)→
ArrayBlockingQueue或指定容量的LinkedBlockingQueue; - 需要高吞吐→
LinkedBlockingQueue(双锁); - 需要手把手交付 / 线程间直接移交→
SynchronousQueue、LinkedTransferQueue; - 需要优先级或延迟→
PriorityBlockingQueue、DelayQueue。
10. BlockingQueue 的 add / offer / put 有什么区别?
答: BlockingQueue 对"插入 / 移除 / 检查"三类操作各提供四套方法,区别只在失败时的行为:
| 操作 | 抛异常 | 返回特殊值 | 阻塞 | 超时 |
|---|---|---|---|---|
| 插入 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
| 移除 | remove() | poll() | take() | poll(time, unit) |
| 检查 | element() | peek() | — | — |
实践要点:
- 生产者-消费者模型用
put/take——真正的阻塞语义,这正是BlockingQueue的核心价值; - 不希望线程被卡住时用
offer/poll,自行处理返回值; - 有界队列上慎用
add/remove:队列满时add抛IllegalStateException,队列空时remove抛NoSuchElementException; put/take都响应中断(抛InterruptedException),因此适合构建"可取消"的任务管道。
11. CopyOnWriteArrayList 的原理与适用场景?
答: 写时复制(Copy-On-Write):
- 写(
add/set/remove):先加ReentrantLock(保证写之间互斥),复制当前底层数组一份,在新数组上修改,最后把volatile的数组引用指向新数组; - 读:完全不加锁,直接读当前数组引用。
| 维度 | 表现 |
|---|---|
| 读性能 | 极高,无锁 |
| 写性能 | 差(每次写都要复制整个数组) |
| 一致性 | 弱一致性,读可能读到旧数组(快照) |
| 迭代器 | 不会抛 ConcurrentModificationException,遍历的是创建时的快照 |
| 内存 | 写期间存在两份数组,瞬时占用翻倍 |
适用场景:读多写极少,且数据量不大——如监听器/观察者列表、黑白名单、配置项。
不适用:写频繁(复制开销爆炸)、数据量大(每次复制都拷整个数组)。
12. ConcurrentLinkedQueue 与阻塞队列的区别?
答:
| 维度 | ConcurrentLinkedQueue | ArrayBlockingQueue 等阻塞队列 |
|---|---|---|
| 加锁方式 | CAS 无锁(入队/出队 CAS 自旋) | ReentrantLock + Condition |
| 阻塞语义 | 无(空队列 poll 立即返回 null) | 有(空则 take 阻塞、满则 put 阻塞) |
| 容量 | 无界 | 有界或可选有界 |
| 吞吐 | 高(无锁) | 受锁与线程阻塞影响 |
| 适用 | 高并发、无需"阻塞等待"的队列场景 | 生产者-消费者模型 |
选择依据:需要"队列空/满时自动阻塞以协调生产消费节奏"→ 阻塞队列;只需要一个高并发的无锁队列、自己控制等待逻辑(如配合自旋或事件循环)→ ConcurrentLinkedQueue。
补充:
ConcurrentLinkedQueue还有一个"弱一致性"的size()(需遍历,O(n)),因此不要用它做频繁的size()判断。
