跳转至

AQS 与 CAS


1. 引入:它解决了什么问题?

问题背景synchronized 是 JVM 层面的重量级锁,在竞争激烈时需要将线程挂起(涉及用户态→内核态切换),性能开销大。Java 并发包(java.util.concurrent)需要一套更灵活、更高性能的同步机制。

AQS 和 CAS 解决的核心问题

问题 解决方案
synchronized 不支持超时获取锁 ReentrantLock.tryLock(timeout)
synchronized 不支持可中断等待 ReentrantLock.lockInterruptibly()
synchronized 不支持公平锁 new ReentrantLock(true)
synchronized 只有一个等待队列 Condition 多个等待队列
简单计数器用锁太重 AtomicInteger 基于 CAS 无锁操作

典型应用场景: - ReentrantLock:需要超时、可中断、公平锁的场景 - CountDownLatch:等待多个线程完成(如并行查询后汇总结果) - Semaphore:限流(如数据库连接池限制并发数) - AtomicInteger:高并发计数器 - ConcurrentHashMap:JDK 8 的 put 操作用 CAS 实现无锁插入


2. 类比:用生活模型建立直觉

AQS = 银行叫号系统

银行只有一个窗口(临界资源),客户来了先取号(加入等待队列),窗口空闲时叫号(唤醒队首线程):

  • state(同步状态) = 窗口是否有人在办理(0=空闲,1=占用)
  • CLH 等待队列 = 等候区的叫号队列
  • 获取锁 = 取到号,走到窗口办理
  • 释放锁 = 办理完毕,叫下一个号

公平锁 vs 非公平锁: - 公平锁 = 严格按叫号顺序(先来先得) - 非公平锁 = 新来的客户可以直接插队尝试(如果窗口刚好空闲就直接办理,不用排队)

CAS = 乐观锁的"比较并交换"

就像两个人同时修改同一份 Google 文档: - 你读取文档版本号为 v1,做了修改 - 提交时,先检查当前版本号是否还是 v1 - 如果是 v1(没人改过),提交成功,版本号变为 v2 - 如果不是 v1(别人改过了),提交失败,重新读取最新版本再修改

这就是 CAS:Compare And Swap(比较并交换),不加锁,通过重试保证最终成功。


3. 原理:逐步拆解核心机制

3.1 CAS 原理

CAS 是一条 CPU 原子指令cmpxchg),包含三个操作数: - 内存地址 V:要修改的变量 - 期望值 A:认为变量当前应该是这个值 - 新值 B:要设置的新值

操作语义:如果 V 的当前值等于 A,则将 V 设置为 B,返回 true;否则不修改,返回 false。

// AtomicInteger 的 incrementAndGet 底层实现
public final int incrementAndGet() {
    for (;;) {  // 自旋重试
        int current = get();          // 读取当前值
        int next = current + 1;       // 计算新值
        if (compareAndSet(current, next))  // CAS:如果当前值还是 current,就设为 next
            return next;
        // CAS 失败说明有其他线程修改了,重新读取再试
    }
}
flowchart TD
    A["读取 value = 5"] --> B["计算 newValue = 6"]
    B --> C{"CAS: value == 5?"}
    C -->|"是(没人改过)"| D["设置 value = 6,成功返回"]
    C -->|"否(有人改过)"| E["重新读取 value,重试"]
    E --> A

3.2 AQS 核心结构

flowchart LR
    subgraph AQS 核心
        State["volatile int state<br/>同步状态<br/>(ReentrantLock: 0=未锁,>0=重入次数)"]
        Head["head<br/>等待队列头节点"]
        Tail["tail<br/>等待队列尾节点"]
    end

    subgraph "CLH 等待队列(双向链表)"
        N1["Node<br/>thread=T2<br/>waitStatus=SIGNAL"]
        N2["Node<br/>thread=T3<br/>waitStatus=SIGNAL"]
        N3["Node<br/>thread=T4<br/>waitStatus=0"]
    end

    Head --> N1 --> N2 --> N3 --> Tail

获取锁的流程

flowchart TD
    A["lock() 调用"] --> B{"CAS 尝试将 state 从 0 改为 1"}
    B -->|"成功"| C["获得锁,设置当前线程为持有者"]
    B -->|"失败"| D{"是否是重入(当前线程已持有锁)?"}
    D -->|"是"| E["state++,重入成功"]
    D -->|"否"| F["创建 Node 加入 CLH 队列尾部"]
    F --> G["自旋检查前驱节点是否是 head"]
    G -->|"是"| H["再次尝试 CAS 获取锁"]
    H -->|"成功"| C
    H -->|"失败"| I["LockSupport.park() 挂起线程"]
    G -->|"否"| I
    I --> J["等待前驱节点释放锁后 unpark 唤醒"]
    J --> G

释放锁的流程

flowchart TD
    A["unlock() 调用"] --> B["state--"]
    B --> C{"state == 0?"}
    C -->|"否(还有重入)"| D["返回,锁未完全释放"]
    C -->|"是"| E["清除持有线程"]
    E --> F["LockSupport.unpark(head.next.thread)<br/>唤醒队列中第一个等待线程"]

3.3 CAS 的 ABA 问题

flowchart LR
    T1["线程1 读取 value=A<br/>(准备 CAS 改为 C)"]
    T2["线程2 将 value 改为 B"]
    T3["线程2 又将 value 改回 A"]
    T4["线程1 CAS 成功(value==A)<br/>但中间已经发生了 A→B→A 的变化!"]

    T1 --> T2 --> T3 --> T4

ABA 问题的危害:在某些场景下(如链表操作),ABA 变化可能导致数据结构损坏。

解决方案AtomicStampedReference — 每次修改同时更新版本号(stamp),CAS 同时比较值和版本号:

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

// 读取当前值和版本号
int[] stampHolder = new int[1];
Integer value = ref.get(stampHolder);
int stamp = stampHolder[0];

// CAS:同时比较值和版本号
ref.compareAndSet(value, newValue, stamp, stamp + 1);
// 即使值相同,版本号不同也会失败,解决 ABA 问题

4. 特性:关键对比

ReentrantLock vs synchronized

对比项 synchronized ReentrantLock
实现层面 JVM 内置关键字 Java 代码(基于 AQS)
可中断等待 lockInterruptibly()
超时获取锁 tryLock(timeout, unit)
公平锁 ❌(非公平) new ReentrantLock(true)
多个等待队列 ❌(一个 wait set) ✅ 多个 Condition
自动释放 ✅(代码块结束自动释放) ❌ 必须在 finally 中 unlock()
性能 JDK 6 后差距不大 高竞争场景略好

选择原则:大多数场景用 synchronized 即可(更简单,不会忘记释放锁);只有需要超时、可中断、公平锁、多条件队列时才用 ReentrantLock

常用并发工具类

工具类 基于 用途 典型场景
ReentrantLock AQS 可重入互斥锁 替代 synchronized
ReentrantReadWriteLock AQS 读写锁 读多写少场景
CountDownLatch AQS 等待 N 个事件完成 并行任务汇总
CyclicBarrier ReentrantLock N 个线程互相等待 分阶段并行计算
Semaphore AQS 限制并发数 连接池、限流
AtomicInteger CAS 原子整数 高并发计数器
AtomicStampedReference CAS 带版本号的原子引用 解决 ABA 问题

5. 边界:异常情况与常见误区

❌ 误区1:忘记在 finally 中释放 ReentrantLock

// ❌ 如果 doSomething() 抛出异常,锁永远不会释放 → 死锁!
lock.lock();
doSomething();
lock.unlock();

// ✅ 必须在 finally 中释放
lock.lock();
try {
    doSomething();
} finally {
    lock.unlock();  // 无论是否异常,都会释放
}

❌ 误区2:CAS 自旋在高竞争下性能差

CAS 失败后会自旋重试,如果竞争非常激烈,大量线程不断自旋,会白白消耗 CPU

解决方案: - 高竞争场景用 synchronized(失败后线程挂起,不消耗 CPU) - 或使用 LongAdder 替代 AtomicLong(分段累加,减少竞争)

❌ 误区3:CountDownLatch 不能重用

CountDownLatch 的计数器减到 0 后不能重置,如果需要重复使用,应该用 CyclicBarrier(可以重置)。

边界:AQS 的公平锁 vs 非公平锁性能

  • 非公平锁:新来的线程可以直接 CAS 抢锁,不用排队。如果恰好锁刚释放,可以直接获得,减少了线程切换,吞吐量更高
  • 公平锁:严格按队列顺序,每次都要检查队列,延迟更稳定但吞吐量略低。

ReentrantLock 默认是非公平锁,这是性能优先的设计选择。


6. 设计原因:为什么这样设计?

为什么 AQS 用 CLH 队列而不是普通链表?

CLH(Craig, Landin, and Hagersten)队列是一种自旋锁队列,每个节点自旋等待前驱节点释放锁,而不是全局自旋。这样: 1. 每个线程只关注自己的前驱节点,减少了缓存一致性流量 2. 通过 LockSupport.park() 将自旋改为挂起,避免 CPU 空转 3. 双向链表支持取消等待(将节点从队列中移除)

为什么 CAS 是原子操作?

CAS 对应 CPU 的 cmpxchg 指令,在单核 CPU 上天然原子;在多核 CPU 上,通过总线锁缓存锁(MESI 协议)保证原子性。这是硬件层面的保证,比软件锁(synchronized)的开销小得多。

为什么 AtomicLong 在高并发下要用 LongAdder 替代?

AtomicLong 所有线程竞争同一个变量,CAS 失败率高,大量自旋浪费 CPU。LongAdder 将值分散到多个 Cell(类似 ConcurrentHashMap 的分段思想),每个线程优先更新自己对应的 Cell,最终求和时汇总所有 Cell,大幅减少竞争。代价是读取时需要汇总,适合写多读少的计数场景。


7. 总结:面试标准化表达

面试问:AQS 的原理是什么?

标准答法

AQS(AbstractQueuedSynchronizer)是 Java 并发包的核心框架,ReentrantLockCountDownLatchSemaphore 都基于它实现。

AQS 的核心是一个 volatile int state(同步状态)和一个 CLH 双向等待队列。

获取锁时,线程通过 CAS 尝试修改 state(如从 0 改为 1);失败则创建 Node 加入等待队列,并通过 LockSupport.park() 挂起。释放锁时,将 state 改回 0,然后 unpark 唤醒队列中的第一个等待线程。

ReentrantLock 支持重入,是通过 state 记录重入次数实现的(同一线程每次 lock state++,每次 unlock state--,减到 0 才真正释放)。

面试问:CAS 的 ABA 问题是什么?如何解决?

标准答法

ABA 问题是指:线程1 读取变量值为 A,此时线程2 将值改为 B,再改回 A。线程1 执行 CAS 时,发现值还是 A,认为没有变化,CAS 成功。但实际上中间已经发生了 A→B→A 的变化,在某些场景下(如链表操作)可能导致数据错误。

解决方案是使用 AtomicStampedReference,在每次修改时同时更新一个版本号(stamp)。CAS 时同时比较值和版本号,即使值相同,版本号不同也会失败,从而检测到 ABA 变化。