【面试八股速通篇01】锁与并发工具

前言:

好久没有发文章了,其实并不是没写,而是最近在写自己的项目,没有什么时间来写,还有六七篇存稿没有整体,都是好几万字的,后续会慢慢整理,主要是关于agent的相关内容,同步进行的是面试的一些八股,算法,因此在这里给大家分享一些八股吧,主要是自己一个人看也记不住多少,自己写出来并且能讲明白印象更深一点。

今天看的主要是java并发工具还有锁相关的知识,这些都是面试的高频考点,需要重点掌握。

我们先整体的看一下:

先明确了整体的框架,我们主要抓住重点的进行学习。

实际最常用的是:线程池、ConcurrentHashMap、CountDownLatch、CompletableFuture。

synchronized 底层原理

这是面试超高频 题,核心就一句话:synchronized 的锁会根据竞争情况,从"轻"到"重"逐级升级,尽量少用重量级锁。


一、先搞懂锁存在哪:对象头

每个 Java 对象在内存里都有一段对象头(Mark Word),锁的状态就记录在这里。

64 位 JVM 下,Mark Word 主要存这些信息(不同锁状态存的不一样):

锁状态 Mark Word 内容
无锁 hashcode + 分代年龄 + 锁标志位(01)
偏向锁 线程ID + 时间戳 + 分代年龄 + 标志位(01)
轻量级锁 指向栈中锁记录的指针 + 标志位(00)
重量级锁 指向 Monitor 的指针 + 标志位(10)

关键:锁的信息不在你的业务对象上,而在对象头里。


二、四种锁状态 & 升级过程

复制代码
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
       (只有一个线程) (少量竞争、自旋) (竞争激烈)

注意:锁升级是单向的,不能降级(偏向锁撤销除外)。


三、逐个拆解

1. 偏向锁(Biased Locking)

场景:只有一个线程反复进入同步块。

原理:

  • 第一个线程来的时候,把自己的线程 ID 记到对象头的 Mark Word 里

  • 以后这个线程再来,检查 Mark Word 里是不是自己的 ID,是就直接进去,连 CAS 都不用

  • 相当于这个锁偏向我,我随便进

为什么快

几乎零开销,就是一次比较。

什么时候升级

有第二个线程 来竞争时,偏向锁就要撤销,升级为轻量级锁。

补充:JDK 15 之后默认关闭并废弃了偏向锁,因为维护成本高、收益有限。但面试还是会问。


2. 轻量级锁(Lightweight Lock)

场景:多个线程竞争,但竞争不激烈,锁持有时间短。

原理(CAS + 自旋):

  1. 线程在自己的栈帧里创建一个锁记录(Lock Record)

  2. 用 CAS 把对象头的 Mark Word 替换成指向自己锁记录的指针

  3. CAS 成功 → 拿到锁;失败 → 说明有竞争,自旋重试

  4. 自旋一定次数还拿不到 → 升级为重量级锁

为什么比重量级快

不涉及操作系统,在用户态用 CAS 自旋解决,避免线程阻塞/唤醒的开销。

自旋的代价:

自旋就是空转 CPU。如果锁一直被占用,自旋就是白费 CPU,所以不能无限自旋。

JDK 6 后引入自适应自旋:自旋次数根据上次成功情况动态调整。


3. 重量级锁(Heavyweight Lock)

场景:竞争激烈,或锁持有时间长。

原理:

  • 依赖操作系统的 Monitor(管程,C++ 实现,存在对象头指向的 ObjectMonitor 里)

  • 抢不到锁的线程直接阻塞(挂起),进入等待队列

  • 涉及用户态 ↔ 内核态切换,开销大

ObjectMonitor 结构(了解):

  • _owner:当前持有锁的线程

  • _EntryList:等待锁的线程队列

  • _WaitSet:调用 wait() 后进入的等待队列

  • _count:重入次数

为什么慢

线程阻塞和唤醒需要操作系统介入,上下文切换成本高。


四、锁优化补充(面试加分项)

除了锁升级,JVM 还有这些优化:

优化 说明
锁消除 逃逸分析发现锁对象不会被其他线程访问,直接去掉锁
锁粗化 连续多次加锁解锁,合并成一次大锁
自适应自旋 根据历史成功率动态决定自旋次数

锁消除例子:

复制代码
java

public String test() {
    StringBuffer sb = new StringBuffer(); // 局部变量,不会逃逸
    sb.append("a");
    return sb.toString();
}
// StringBuffer 方法有 synchronized,但对象是局部的,JVM 直接消除锁

五、一句话总结

synchronized 通过对象头 Mark Word 记录锁状态,采用锁升级 策略:

单线程用偏向锁 (记线程ID,零开销);

少量竞争用轻量级锁 (CAS + 自旋,用户态解决);

激烈竞争用重量级锁 (操作系统 Monitor,阻塞排队)。

目的是尽量把锁控制在用户态,减少内核态切换开销 。

此外还有锁消除、锁粗化、自适应自旋等优化。


六、常见追问

Q:偏向锁为什么被废弃

A:撤销偏向锁需要 STW(Stop The World),在高并发场景下维护成本大于收益,JDK 15 后默认关闭。

Q:轻量级锁自旋多少次升级

A:不是固定值,JDK 6 后是自适应自旋,根据上次在该锁上自旋的成功率动态调整。

Q:synchronized 和 ReentrantLock 底层区别

A:synchronized 是 JVM 层面(Monitor),ReentrantLock 是 JDK 层面(基于 AQS + CAS)

ReentrantLock

是什么: JDK 提供的可重入锁,基于 AQS 实现,手动加锁解锁。

复制代码
java

Lock lock = new ReentrantLock();

lock.lock();
try {
    i++;
} finally {
    lock.unlock();  // 必须手动释放!
}

比 synchronized 多的能力:

能力 方法 场景
可中断 lockInterruptibly() 防止死等
可超时 tryLock(3, SECONDS) 抢不到就放弃
公平锁 new ReentrantLock(true) 需要按顺序
多条件 Condition 精确唤醒

使用场景:

复制代码
java

// 场景1:尝试获取锁,超时就放弃(防止死等)
if (lock.tryLock(3, TimeUnit.SECONDS)) {
    try { /* 干活 */ }
    finally { lock.unlock(); }
} else {
    // 3秒没抢到,走别的逻辑
}

// 场景2:公平锁,按排队顺序拿锁
Lock fairLock = new ReentrantLock(true);

什么时候用 ReentrantLock 而不是 synchronized

需要超时、可中断、公平、多条件这些高级功能时。


ReentrantReadWriteLock ------ 读多写少

是什么: 一把锁拆成读锁 + 写锁。

核心规则:

复制代码
读锁 + 读锁  → 可以共存(多个线程同时读)
读锁 + 写锁  → 互斥
写锁 + 写锁  → 互斥

为什么需要

普通锁:读读也互斥,太浪费。但读操作不会改数据,多个线程一起读完全没问题。

复制代码
java

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// 读操作:多个线程可同时进行
readLock.lock();
try {
    return map.get(key);
} finally {
    readLock.unlock();
}

// 写操作:独占
writeLock.lock();
try {
    map.put(key, value);
} finally {
    writeLock.unlock();
}

使用场景: 读多写少,比如本地缓存。

text

复制代码
缓存:读 100 次,写 1 次 → 用读写锁,100 个读可以并行

注意: 如果读写差不多,甚至写多,读写锁反而更慢(维护两把锁有开销)。


StampedLock ------ 读写锁的升级版(JDK8)

是什么: 比读写锁更快的锁,支持乐观读。

复制代码
java

StampedLock lock = new StampedLock();

// 乐观读(不加锁,读完检查有没有被改)
long stamp = lock.tryOptimisticRead();
int data = this.data;
if (!lock.validate(stamp)) {  // 读期间被改了
    // 升级为悲观读
    stamp = lock.readLock();
    try { data = this.data; }
    finally { lock.unlockRead(stamp); }
}

使用场景: 读极多、写极少,追求极致性能。实际项目用得很少,面试会问。


CAS / 原子类 ------ 无锁

是什么: 不加锁,靠 CPU 的 CAS 指令保证原子性。

复制代码
java

AtomicInteger i = new AtomicInteger(0);
i.incrementAndGet();  // 无锁自增,比 synchronized 快

使用场景:

  • 简单计数(访问量、库存)

  • 状态标记 (AtomicBoolean)

  • 无锁数据结构 (ConcurrentHashMap 底层)

    java

    // 典型:库存扣减
    AtomicInteger stock = new AtomicInteger(100);
    if (stock.decrementAndGet() >= 0) {
    // 扣减成功
    }

注意: 高并发下 CAS 自旋会浪费 CPU,此时用 LongAdder 更好。


ConcurrentHashMap

一句话定位

线程安全的 HashMap。 多线程环境下替代 HashMap,性能又远好于 Hashtable。


为什么不用 HashMap / Hashtable

集合 线程安全 性能 问题
HashMap 不安全 高 并发 put 会丢数据、死循环(JDK7)、数据错乱
Hashtable 安全 低 所有方法加 synchronized,全表一把锁,并发差
ConcurrentHashMap 安全 高 锁粒度细,并发好

Hashtable 的问题: 一个线程 put,其他所有线程连 get 都被阻塞。太粗暴。


ConcurrentHashMap 怎么做到又安全又快

JDK 7:分段锁(Segment)

把整个表分成 16 个 Segment,每个 Segment 一把锁。

复制代码
ConcurrentHashMap
├── Segment[0]  ← 锁A
├── Segment[1]  ← 锁B
├── ...
└── Segment[15] ← 锁P

不同 Segment 可以并发写,锁冲突概率降到 1/16。

JDK 8:CAS + synchronized(锁桶头节点)

放弃分段锁,改成:

  • 数组 + 链表 + 红黑树(和 HashMap 一样)

  • 锁的粒度是单个桶(数组的某个下标)

  • 桶为空时用 CAS 放第一个节点(无锁)

  • 桶非空时用 synchronized 锁住桶的头节点

text

复制代码
数组 table
[0] → null
[1] → A → B → C        ← 锁这个头节点 A
[2] → null
[3] → D → E            ← 锁这个头节点 D
[4] → 红黑树...

为什么快

  • 不同桶可以真正并行写

  • 空桶用 CAS,完全无锁

  • 只有哈希冲突到同一个桶,才用 synchronized,且只锁那一个桶


4. 核心操作原理

put 流程(JDK 8):

text

复制代码
1. 计算 key 的 hash,定位到数组下标 i
2. 如果 table[i] == null:
   → CAS 直接放入新节点(无锁,成功就完事)
3. 如果 table[i] != null:
   → synchronized 锁住 table[i] 这个头节点
   → 遍历链表/红黑树,找到就覆盖,找不到就尾插
4. 链表长度 ≥ 8 且数组长度 ≥ 64 → 转红黑树
5. 元素个数 +1,用 CAS 更新 size(baseCount + CounterCell 分段计数)

get 流程:

text

复制代码
1. 算 hash 定位下标
2. 遍历链表/红黑树找 key
3. 全程不加锁(节点 value 和 next 用 volatile 保证可见性)

get 为什么不用加锁

因为节点的 val 和 next 是 volatile 的,改了别的线程立刻能看到,不会读到脏数据

常见追问

Q:ConcurrentHashMap 允许 null 键值吗

A:不允许。因为 get 返回 null 时无法区分"key 不存在"还是"value 就是 null",并发下有二义性。

Q:size() 是精确的吗

A:JDK 8 用 baseCount + CounterCell 累加,并发下是近似值,不是强一致。

Q:扩容时其他线程能操作吗

A:能。扩容是多线程协助迁移,其他线程 put 时如果发现正在扩容,会帮忙搬一部分数据。

补充

ConcurrentHashMap的扩容机制:

设计 目的
多线程协助 避免一个线程搬、其他人干等
CAS 抢任务 无锁分配搬迁任务,高效
ForwardingNode 标记搬完的桶,转发请求
锁桶头 保证正在搬的桶不出错
sizeCtl 负数 标识扩容状态,让其他线程感知

目标:扩容期间,读写操作尽量不被长时间阻塞。

JDK 8 采用多线程协助扩容:

  • 触发扩容时,把 sizeCtl 设为负数标识扩容中,创建新数组 nextTable;

  • 每个线程用 CAS 抢 transferIndex,领取一段桶进行搬迁;

  • 搬某个桶时,用 synchronized 锁住桶头节点,把元素重新 hash 到新数组,搬完在旧桶放 ForwardingNode 标记;

  • 其他线程 put/get 遇到 ForwardingNode,会转发到新数组操作;

  • 其他线程 put 时发现正在扩容,也会主动来帮忙搬。

相比 JDK 7 的单线程扩容,JDK 8 多线程协助,扩容期间尽量不阻塞读写。


CountDownLatch

1. 作用

一个线程等 N 个线程完成。

2. 原理

基于 AQS 的共享锁 ,state 初始化为 N,countDown() 就是 state-1,await() 就是等 state=0。

3. 代码

复制代码
java

CountDownLatch latch = new CountDownLatch(3);

for (int i = 0; i < 3; i++) {
    new Thread(() -> {
        try {
            // 干活
        } finally {
            latch.countDown();
        }
    }).start();
}

latch.await();  // 主线程阻塞,直到计数为 0
System.out.println("全部完成");

4. 使用场景

  • 主线程等所有子任务完成(并发汇总)

  • 模拟并发测试(让多个线程同时开始)

  • 服务启动等待组件初始化

5. 面试追问

Q:CountDownLatch 能重用吗

不能,计数到 0 就废了。要重用用 CyclicBarrier。

Q:await() 能超时吗

能,await(3, TimeUnit.SECONDS),返回 false 表示超时。

Q:底层原理

AQS 共享模式,state 为计数,countDown 用 CAS 递减,减到 0 唤醒所有等待线程。


AQS 是什么

AQS 是面试锁 话题的终极考点。ReentrantLock、CountDownLatch、Semaphore、读写锁全都基于它。搞懂它,前面所有锁的底层就串起来了。


一、一句话定义

AQS = AbstractQueuedSynchronizer(抽象队列同步器),是 JDK 提供的一个"构建锁和同步器的框架"。

它把"抢锁、排队、阻塞、唤醒"这套通用逻辑都写好了,你只需要告诉它"能不能拿到锁"。

打个比方:

AQS 就像一栋毛坯房:

  • 框架、水电、墙体(排队、阻塞、唤醒)它都建好了

  • 你只需要装修(实现"能不能拿锁"的逻辑)

ReentrantLock、CountDownLatch、Semaphore 都是基于这套毛坯房装修出来的成品。


二、AQS 的核心:两个东西

AQS 内部就靠两样东西运转:

java

复制代码
public abstract class AbstractQueuedSynchronizer {
    // 1. 同步状态(核心!)
    private volatile int state;

    // 2. 等待队列(双向链表)
    private transient volatile Node head;
    private transient volatile Node tail;
}

核心1:state(同步状态)

state 是一个 int,不同子类给它不同含义:

子类 state 表示什么
ReentrantLock 锁被重入几次(0=没锁,1=拿1次,2=重入)
CountDownLatch 还剩几个任务没完成
Semaphore 还剩几个许可证

state 的修改用 CAS 保证原子性。

核心2:等待队列(CLH 变体的双向链表)

抢不到锁的线程,包装成 Node,排进这个队列。

text

复制代码
     head                    tail
      │                       │
      ▼                       ▼
   [Node] ⇄ [Node] ⇄ [Node] ⇄ [Node]
   线程B     线程C     线程D     线程E
  (正在等)(等待中)(等待中)(等待中)
  • 每个 Node 装一个等待的线程

  • 前驱释放锁后,唤醒后继

  • 双向链表方便节点取消、找前驱


三、AQS 的精髓:模板方法模式

AQS 把"抢锁"分成两部分:

text

复制代码
┌─────────────────────────────────────┐
│  AQS 负责的(通用逻辑,写好了)      │
│  ├── 维护 state                     │
│  ├── 维护等待队列                    │
│  ├── 线程排队、阻塞(park)          │
│  └── 唤醒后继(unpark)             │
├─────────────────────────────────────┤
│  子类负责的(业务逻辑,你来写)       │
│  └── tryAcquire:能不能拿到锁?      │
└─────────────────────────────────────┘

AQS 定义了几个"留给子类实现"的方法:

java

复制代码
// 独占模式:能不能获取
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();  // 空实现,子类必须重写
}

// 独占模式:能不能释放
protected boolean tryRelease(int arg) { ... }

// 共享模式:能不能获取
protected int tryAcquireShared(int arg) { ... }

// 共享模式:能不能释放
protected boolean tryReleaseShared(int arg) { ... }

你只需要重写这几个方法,队列、阻塞、唤醒 AQS 全帮你搞定。


四、两种模式:独占 vs 共享

模式 含义 实现
独占模式(Exclusive) 同一时刻只有一个线程能拿到 ReentrantLock
共享模式(Shared) 多个线程可以同时拿到 CountDownLatch、Semaphore、读写锁的读锁

独占 :像厕所单间,一个人进去锁门,别人等。

共享:像公园长椅,可以坐好几个人,坐满了才需要等。


五、加锁流程(独占模式,重点)

以 ReentrantLock.lock() 为例:

text

复制代码
lock()
  │
  ▼
AQS.acquire(1)
  │
  ▼
① tryAcquire(1) —— 子类实现,尝试拿锁
  │
  ├── 成功 → 拿到锁,直接返回,干活
  │
  └── 失败 ↓
  ▼
② addWaiter() —— 把当前线程包装成 Node,加入队列尾部
  │
  ▼
③ acquireQueued() —— 在队列里"自旋 + 阻塞"
  │
  ├── 如果自己是队首(head.next),再试 tryAcquire
  │     成功 → 出队,拿到锁
  │     失败 → 继续
  │
  └── 不是队首,或试失败 → LockSupport.park() 阻塞
  │
  ▼
④ 前驱节点释放锁时,unpark 唤醒自己
  │
  ▼
回到 ③ 再试

简化记忆:

text

复制代码
尝试拿锁 → 失败入队 → 自旋/阻塞 → 被唤醒 → 再尝试 → 成功出队

六、ReentrantLock 怎么用 AQS 实现

加锁

java

复制代码
// ReentrantLock 的 tryAcquire(非公平版,简化)
protected boolean tryAcquire(int acquires) {
    int c = getState();

    if (c == 0) {
        // 没人拿锁,CAS 抢
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(Thread.currentThread());
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        // 是当前线程拿的 → 可重入,state+1
        setState(c + acquires);
        return true;
    }
    return false;  // 抢不到
}

看,ReentrantLock 只做了两件事:

  1. state 为 0 → CAS 抢

  2. state 不为 0 且是自己 → state+1(重入)

剩下的排队、阻塞、唤醒全是 AQS 干的。

解锁

java

复制代码
protected boolean tryRelease(int releases) {
    int c = getState() - releases;  // state - 1
    if (c == 0) {
        setExclusiveOwnerThread(null);  // 真正释放
        setState(c);
        return true;  // 返回 true 才会唤醒后继
    }
    setState(c);  // 还没释放完(重入),返回 false
    return false;
}

只有 state 减到 0 才算真正释放,才唤醒队列里的下一个。



七、CountDownLatch 怎么用 AQS(共享模式)

java

复制代码
// 初始化,state = 3
new CountDownLatch(3);

// countDown:state - 1
countDown();
  → tryReleaseShared(1)
    → CAS 把 state 减 1
    → state 减到 0 → 返回 true → 唤醒所有等待线程

// await:等 state = 0
await();
  → tryAcquireShared(1)
    → state == 0 ? 返回 1(成功): 返回 -1(失败,入队阻塞)

state 就是"还剩几个任务"。减到 0,所有人放行。


八、公平锁 vs 非公平锁(AQS 里怎么体现)

区别就在 tryAcquire 里加不加"检查队列"这一步。

非公平锁(默认)

java

复制代码
protected boolean tryAcquire(int acquires) {
    // 直接 CAS 抢!不管队列里有没有人排队
    if (compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(current);
        return true;
    }
    // ...
}

新来的线程可以直接插队抢锁。

公平锁

java

复制代码
protected boolean tryAcquire(int acquires) {
    // 先检查:队列里有没有人排队?
    if (!hasQueuedPredecessors() &&   // ← 关键:有前驱就不抢
        compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(current);
        return true;
    }
    // ...
}

公平锁多了一句 hasQueuedPredecessors():如果前面有人在等,我就乖乖排队,不抢。

公平锁 非公平锁
抢锁前 检查队列有没有前驱 直接 CAS 抢
性能 慢(上下文切换多) 快(减少唤醒)
饥饿 不会 可能

为什么默认非公平? 因为非公平可以减少线程唤醒,吞吐量更高。


九、AQS 里几个关键方法(面试可能问)

方法 作用
acquire(int) 独占获取(含入队阻塞)
acquireShared(int) 共享获取
release(int) 独占释放
releaseShared(int) 共享释放
hasQueuedPredecessors() 队列里有没有前驱(公平锁用)
LockSupport.park() 阻塞当前线程
LockSupport.unpark(t) 唤醒指定线程

park/unpark 和 wait/notify 的区别(常问):

park/unpark wait/notify
是否需要持有锁 不需要 必须在 synchronized 里
唤醒精度 可以精确唤醒指定线程 notify 随机唤醒一个
顺序 unpark 可以先于 park notify 先于 wait 会丢失

AQS 是什么

AQS 是 AbstractQueuedSynchronizer,是 JUC 里构建锁和同步器的基础框架。ReentrantLock、CountDownLatch、Semaphore 都基于它。

核心是一个 volatile 的 state 变量 + 一个双向等待队列:

  • state 表示同步状态,不同子类含义不同(ReentrantLock 里是重入次数,CountDownLatch 里是剩余计数)

  • 抢不到锁的线程包装成 Node,进入队列排队,用 LockSupport.park 阻塞

它用了模板方法模式 :AQS 负责队列、阻塞、唤醒这些通用逻辑,子类只需实现 tryAcquire/tryRelease 等方法,告诉 AQS"能不能拿到锁"。

AQS 支持两种模式:独占 (ReentrantLock)和共享 (CountDownLatch、Semaphore)。公平锁和非公平锁的区别,就在于 tryAcquire 里是否检查队列前驱。

AQS 里的 Condition(条件队列)

Condition 是 AQS 里除了等待队列之外的第二个队列 ,用来实现 await/signal,对应 synchronized 里的 wait/notify。


一、先搞懂 Condition 是什么

Condition = 条件变量,让线程在某个条件下等待,条件满足时被唤醒。

类比:

  • synchronized + wait/notify:只有一个"休息室",所有等的线程都挤在一起

  • ReentrantLock + Condition:可以开多个休息室,不同条件去不同休息室等

java

复制代码
// synchronized 版本
synchronized (lock) {
    while (条件不满足) lock.wait();
    // 干活
    lock.notifyAll();
}

java

复制代码
// Condition 版本(更灵活)
lock.lock();
try {
    while (条件不满足) condition.await();
    // 干活
    condition.signal();
} finally {
    lock.unlock();
}

核心区别:一个 Lock 可以创建多个 Condition,精确唤醒。


二、为什么需要 Condition(对比 wait/notify)

问题:wait/notify 只能有一个等待队列

经典例子:阻塞队列(生产者-消费者)

java

复制代码
// 用 synchronized + wait/notify 的问题
synchronized (lock) {
    // 生产者:队列满了要等
    while (queue.size() == MAX) lock.wait();
    // 消费者:队列空了要等
    while (queue.size() == 0) lock.wait();

    lock.notifyAll();  // 只能全部唤醒,分不清唤醒谁
}

问题: 生产者和消费者在同一个等待队列里。生产者唤醒时,可能唤醒的也是生产者,白唤醒,还得继续等。

解决:用两个 Condition 分开等

java

复制代码
// 用 Condition,生产者、消费者各一个队列
private final Condition notFull = lock.newCondition();   // 生产者在这等
private final Condition notEmpty = lock.newCondition();  // 消费者在这等

// 生产者
lock.lock();
try {
    while (queue.size() == MAX) notFull.await();  // 满了,在 notFull 等
    queue.add(e);
    notEmpty.signal();  // 精确唤醒消费者
} finally {
    lock.unlock();
}

// 消费者
lock.lock();
try {
    while (queue.size() == 0) notEmpty.await();  // 空了,在 notEmpty 等
    queue.poll();
    notFull.signal();  // 精确唤醒生产者
} finally {
    lock.unlock();
}

这就是 ArrayBlockingQueue 的实现原理!


三、Condition 的核心方法

方法 作用 对应 wait/notify
await() 释放锁,进入等待,被唤醒后重新拿锁 wait()
await(time, unit) 带超时的等待 wait(timeout)
signal() 唤醒一个等待线程 notify()
signalAll() 唤醒所有等待线程 notifyAll()

注意:调用这些方法前必须持有锁!

java

复制代码
lock.lock();          // 必须先拿锁
try {
    condition.await(); // 才能 await
} finally {
    lock.unlock();
}

和 wait/notify 必须在 synchronized 里一样。


四、await/signal 的完整流程

await() 干了什么

text

复制代码
线程A 调用 condition.await()
  │
  ▼
① 释放锁(state 归 0,唤醒等待队列的下一个)
  │
  ▼
② 把自己包装成 Node,加入 Condition 的【条件队列】
  │
  ▼
③ 阻塞(park),等待被 signal
  │
  ▼
④ 被 signal 后,从条件队列移到【等待队列】
  │
  ▼
⑤ 重新竞争锁(acquire)
  │
  ▼
⑥ 拿到锁,await 返回,继续执行

关键点:

  • await 会释放锁(不然别人没法 signal)

  • 被唤醒后要重新抢锁(所以不是立刻返回)

signal() 干了什么

text

复制代码
线程B 调用 condition.signal()
  │
  ▼
① 从条件队列取出第一个 Node
  │
  ▼
② 把它移到等待队列(不是直接唤醒!)
  │
  ▼
③ 等它前面的线程释放锁后,才会被唤醒去抢锁

关键:signal 只是"搬家",从条件队列搬到等待队列,真正唤醒要等锁释放。


五、两个队列的关系(核心图)

text

复制代码
AQS 内部有两个队列:

① 等待队列(同步队列)
   ┌──────────────────────────────────┐
   head ⇄ Node ⇄ Node ⇄ Node ⇄ tail
   (抢锁失败的线程在这排队)
   └──────────────────────────────────┘

② 条件队列(Condition Queue)
   ┌──────────────────────────────────┐
   firstNode → Node → Node → lastNode
   (调用 await 的线程在这等)
   └──────────────────────────────────┘

await 和 signal 就是在这两个队列之间"搬家":

text

复制代码
调用 await:
   线程从【运行】→ 释放锁 → 进入【条件队列】

调用 signal:
   线程从【条件队列】→ 移到【等待队列】→ 等锁 → 拿到锁 → 继续运行

画个流程:

text

复制代码
线程运行
   │
   │ await()  ← 释放锁,进条件队列
   ▼
条件队列(等待 signal)
   │
   │ signal()  ← 被移到等待队列
   ▼
等待队列(重新抢锁)
   │
   │ 抢到锁
   ▼
线程继续运行

六、完整代码示例:手写阻塞队列

java

复制代码
public class MyBlockingQueue<T> {
    private final Object[] items;
    private int count, putIndex, takeIndex;

    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();   // 生产者等
    private final Condition notEmpty = lock.newCondition();  // 消费者等

    public MyBlockingQueue(int capacity) {
        items = new Object[capacity];
    }

    // 生产者
    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();        // 满了,生产者在这等
            items[putIndex] = x;
            if (++putIndex == items.length) putIndex = 0;
            count++;
            notEmpty.signal();          // 唤醒消费者
        } finally {
            lock.unlock();
        }
    }

    // 消费者
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();       // 空了,消费者在这等
            T x = (T) items[takeIndex];
            if (++takeIndex == items.length) takeIndex = 0;
            count--;
            notFull.signal();           // 唤醒生产者
            return x;
        } finally {
            lock.unlock();
        }
    }
}

这就是 ArrayBlockingQueue 的核心逻辑。


七、面试必问的几个点

Q1:await 和 wait 的区别

await wait
所属 Condition Object
必须持有 Lock synchronized
队列 可以有多个 Condition 只有一个
唤醒 signal 精确唤醒 notify 随机

Q2:为什么 await 必须重新抢锁?

因为 await 会释放锁,别的线程可能在此期间改了状态。被唤醒后必须重新拿锁,重新检查条件 ,所以要用 while 而不是 if:

java

复制代码
while (条件不满足) condition.await();  // 必须 while,不能 if

用 if 的坑: 被唤醒后不重新检查,可能条件已经又不满足了(虚假唤醒)。

Q3:什么是虚假唤醒?

线程可能在没有 signal 的情况下被唤醒(操作系统层面)。所以必须用 while 循环重新检查条件。

Q4:signal 和 signalAll 区别

  • signal:唤醒条件队列第一个

  • signalAll:唤醒所有

一般用 signal 更高效,但如果多个线程等不同条件,得用 signalAll。

Q5:Condition 队列和等待队列的区别

等待队列 条件队列
触发 抢锁失败 调用 await
目的 等锁 等条件
出队 拿到锁 被 signal
结构 双向链表 单向链表

八、一句话总结

Condition 是 AQS 的条件队列,配合 Lock 实现 await/signal,对应 wait/notify。

  • 一个 Lock 可以有多个 Condition,实现精确唤醒(生产者、消费者各一个)

  • await 流程:释放锁 → 进条件队列 → 阻塞 → 被 signal 移到等待队列 → 重新抢锁 → 继续

  • 两个队列:等待队列(等锁)、条件队列(等条件),signal 就是"搬家"

  • 必须用 while 检查条件,防止虚假唤醒

相关推荐
临期冰淇淋1 小时前
MTK平台 安卓16 GMS报错修改
android·java·前端
Wang's Blog1 小时前
Java 项目实战: 交友项目 - Dubbo 入门案例全流程复盘
java·dubbo·交友
kimnoic1 小时前
PHP 8.3 数据库备份失败怎么排查
开发语言·php
bnmoel1 小时前
C++ 基础入门篇(六)类和对象-第三篇:运算符重载
开发语言·c++·算法·语法·运算符重载
CRMEB系统商城1 小时前
CRMEB开源商城系统(PHP)v8.0,打造全新AI二开商城底座!
开发语言·人工智能·小程序·开源·php
宇卿.1 小时前
C#字段、属性、索引器、常量
开发语言·计算机视觉·c#
菜鸟~noob2331 小时前
【电子战】第31篇:3D AOA 定位——方位+俯仰联合
开发语言·matlab·3d
小七在进步1 小时前
初识C++中的模板
java·开发语言·c++
Wang's Blog1 小时前
Java 项目实战: 交友项目 - SpringCloud 整合 Dubbo 的需求分析与模块规划
java·spring cloud·交友