AQS 源码重走:从 Doug Lea 的 CLH 变体到 JDK 21 的完整路径

AQS 源码重走:从「如果让我写」到「原来还可以这样」 某开发者背了一周 AQS 八股:CLH 队列、acquireQueued、shouldParkAfterFailedAcquire……面试官问「AQS 的 prev 和 next 为什么一个可靠一个不可靠?」——答了,但说不出这么设计是为了解决什么问题。面试官一句话戳穿:「你读过源码,但你有没有想过在并发场景下删队列中的一个节点,不这么写会发生什么?」 背源码最大的问题是:你不知道那些代码是在解决什么问题。 所以这篇文章不走寻常路——先问「如果不用 AQS,让我自己写一个锁排队框架,该怎么下手?」然后一步步推演,当你发现「哎这里不安全怎么办」的时候,再引出 Doug Lea 的解法。这个过程会让人惊叹:原来并发代码还可以这样写。 最后,会专门对比 JDK 8 和 JDK 21 两个版本的 AQS,讲清楚 Doug Lea 为什么在 JDK 21 中对核心逻辑做了一次大重构。 🏗️ 从零开始:如果让我实现一个可排队的锁 假设只有 synchronized 和 LockSupport.park/unpark ,让你实现一个「锁没抢到就排队等」的框架,你打算怎么写? 第一次尝试:一个粗暴的实现 // 初版思路 class NaiveLock { volatile int state = 0; // 0=未锁, 1=已锁 Queue<Thread> queue = new ... // 等待队列 void lock() { while (!CAS(&state, 0, 1)) { // 没抢到锁 queue.enqueue(currentThread()); // 入队 park(); // 阻塞 } } void unlock() { state = 0; // 释放锁 Thread t = queue.dequeue(); // 从队列取一个 unpark(t); // 唤醒 } } 看上去好像没问题。但仔细想想,全是坑: ...

十月 21, 2023 · 20 分钟 · 4200 字 · yaomingye

ReentrantReadWriteLock 深度解析:从读多写少场景到底层 state 拆分与锁降级

ReentrantReadWriteLock 深度解析 🤔 道格·李为什么要区分读锁和写锁 ReentrantLock 是可重入的互斥锁——无论什么操作,同一时刻只有一个线程能持有锁。这在写多读少的场景里不是问题,但在读多写少的场景(缓存查询、配置读取、字典加载)中,ReentrantLock 带来了不必要的串行化:100 个线程同时读,它们不应该互相阻塞,因为读取不修改数据。 道格·李在设计 JSR 166 时专门为此引入了读写锁。ReentrantReadWriteLock 把锁拆成两把: 读锁(readLock)——共享锁,多个读线程可以同时持有,读与读不互斥 写锁(writeLock)——独占锁,写线程独占,写与写、写与读互斥 这个设计基于一个观察:大多数并发访问的数据结构是"读多写少"的。如果 90% 的操作都是读取,那么互斥锁让这 90% 的操作全部串行化——白白浪费了并发能力。读写锁让读操作完全并行,只在写操作发生时短暂阻塞。 内部实现上,ReentrantReadWriteLock 把 AQS 的 32 位 state 字段拆成高 16 位(记录共享的读锁持有次数)和低 16 位(记录独占的写锁重入次数),用一个 int 同时跟踪两种模式的持有状态。这也是为什么读写锁在同一时刻要么被读线程共享、要么被写线程独占——两种模式共享同一个 state,状态机决定了它们互斥。 📋 基本用法:readLock 和 writeLock ReentrantReadWriteLock 提供两个 Lock 对象: 锁 类型 获取方法 AQS 模式 readLock() 共享锁 readLock.lock() 共享模式(Shared) writeLock() 独占锁 writeLock.lock() 独占模式(Exclusive) ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); Lock readLock = rwLock.readLock(); Lock writeLock = rwLock.writeLock(); // 读锁:多线程可同时持有 readLock.lock(); try { // 读取操作 } finally { readLock.unlock(); } // 写锁:只有一个线程能持有 writeLock.lock(); try { // 写入操作 } finally { writeLock.unlock(); } 读写互斥规则 stateDiagram-v2 state "无锁" as NOLOCK state "读锁持有中" as READ state "写锁持有中" as WRITE NOLOCK --> READ : 任意线程获取读锁 NOLOCK --> WRITE : 任意线程获取写锁 READ --> READ : 其他线程获取读锁(允许) READ --> WRITE : 任意线程获取写锁(阻塞等待) WRITE --> READ : 任意线程获取读锁(阻塞等待) WRITE --> WRITE : 任意线程获取写锁(阻塞等待) READ --> NOLOCK : 所有读锁释放 WRITE --> NOLOCK : 写锁释放 当前状态 请求读锁 请求写锁 无锁 允许 允许 读锁持有中 允许(读-读不互斥) 阻塞(读-写互斥) 写锁持有中 阻塞(写-读互斥) 阻塞(写-写互斥) 关键点 :写锁释放后,如果有读线程和写线程同时在等待,谁先获取取决于锁的公平性设置。非公平模式下,写线程可能被插队的读线程持续阻塞(写饥饿问题)。 ...

八月 25, 2022 · 8 分钟 · 1648 字 · yaomingye

ReentrantLock 源码解析

ReentrantLock 源码解析:AQS 同步队列、可重入机制与公平锁实现 🚀 道格·李为什么需要一把新锁 在 Java 1.0 时代,多线程互斥只有一个选择——synchronized。它用起来简单:在方法签名上加个关键字,JVM 自动处理加锁和解锁。但随着并发编程场景的复杂化,synchronized 的局限越来越明显: 无法尝试获取锁:线程要么拿到锁,要么无限期阻塞。没有"试一下,拿不到就先干别的"这个选项。这在需要获取多个锁(避免死锁)的场景里是致命的——一旦第一个锁拿到但第二个锁拿不到,已持有的锁无法自动释放 无法中断等待:如果一个线程在 synchronized 上阻塞了,外部无法通过 interrupt() 让它停止等待。这在需要超时取消的场合(比如用户点了取消按钮)完全没办法 无法实现公平锁:synchronized 的锁分配由 JVM 内部机制决定,不保证先来后到。高并发下可能出现线程饥饿——某个线程永远抢不到锁 一个对象只有一个条件队列:synchronized 配合 wait/notify 使用时,所有线程在同一个 wait set 上等待,无法区分"因为缓冲区满了而等待的生产者"和"因为缓冲区空了而等待的消费者" 道格·李在设计 JSR 166(java.util.concurrent 包的基础)时意识到:要构建一个可靠的并发工具包,必须有一把比 synchronized 更灵活的锁。这把锁需要支持尝试获取、超时获取、可中断获取、公平调度——这些 synchronized 做不到的事,是构建 Semaphore、CountDownLatch、BlockingQueue 这些高级并发组件的基础。 这就是 ReentrantLock 的诞生背景。它不是简单地把 synchronized 重写一遍,而是把锁的控制权从 JVM 内部暴露给开发者——开发者可以决定:要不要公平、等多久算超时、拿到锁之后要不要释放。 // synchronized 做不到的三件事: // ① tryLock:试一下,拿不到就做别的 if (lock.tryLock()) { try { ... } finally { lock.unlock(); } } // ② tryLock(timeout):等一段时间,超时就不等了 if (lock.tryLock(2, TimeUnit.SECONDS)) { try { ... } finally { lock.unlock(); } } // ③ lockInterruptibly:别人让你停你就停 lock.lockInterruptibly(); // 被 interrupt 时抛 InterruptedException 接下来逐层深入 ReentrantLock 的内部实现——它如何在 AQS 框架上构建这些能力。 ...

八月 24, 2022 · 13 分钟 · 2617 字 · yaomingye

LockSupport 深度解析:从 wait/notify 的痛点到底层 park/unpark 实现

LockSupport 深度解析 🤔 道格·李为什么需要一个比 wait/notify 更可靠的阻塞原语 在 java.util.concurrent 诞生之前,Java 线程阻塞/唤醒的唯一手段是 Object.wait() 和 Object.notify()。每个 Java 程序员都知道这两件事:第一,必须在 synchronized 块里调用;第二,notify() 如果在 wait() 之前调用,信号就丢了,线程永远醒不过来。 道格·李在构建 AQS 时遇到了一个棘手的问题:AQS 的 acquire() 是先尝试获取锁,失败了再 park()。但线程可能在 tryAcquire 失败和 park() 之间被 unpark()——如果 park() 没有"许可证记忆"能力,这个 unpark() 就白调了,线程永久阻塞。这和 wait/notify 的丢信号问题是同源的。 更麻烦的是,wait/notify 必须配合 synchronized 使用,而 AQS 内部用的是 CAS——如果为了调 wait() 还要加一层 synchronized,性能和设计都会变成灾难。 道格·李需要一个更底层的线程阻塞原语,满足三个条件:unpark() 可以先于 park() 调用(许可证语义,不像 notify 必须后于 wait)、不需要配合 synchronized 监视器锁、直接调用操作系统的线程挂起/恢复能力。 这就是 LockSupport 的诞生背景。它基于二值信号量(permit)——每个线程有且仅有一个 permit(0 或 1),park() 消费 permit(没有则阻塞),unpark() 生产 permit(最多为 1)。这一设计让 AQS 的 acquire() / release() 有了可靠的线程调度基础。 ...

八月 22, 2022 · 7 分钟 · 1280 字 · yaomingye

synchronized 锁升级机制:从对象头到重量级锁的完整路径

synchronized 的锁是怎么升级的? 🔒 一、HotSpot 团队为什么要设计锁升级机制 在 JDK 1.0 时代,synchronized 直接对应操作系统的 Mutex(互斥量)。每次加锁都要陷入内核态,哪怕只有一条线程在访问、根本不存在竞争。这就像你住的小区只有一个停车位,每次出门都要跑去物业办公室办手续——哪怕车位从来没人跟你抢。 2004 年,随着 JDK 5 和 JSR 133 的发布,Java 并发性能成了焦点。道格·李的 JUC 提供了 ReentrantLock、Semaphore 等无锁/CAS 工具,它们的性能远超 synchronized。一时间,社区舆论变成了"别用 synchronized,它是重量级锁、太慢"。 但 synchronized 有一个 JUC 工具永远比不了的优势:它是语言内置的——不需要显式 lock() / unlock(),不用怕忘了释放锁导致死锁。 如果因为性能差就被开发者抛弃,将是 Java 语言的重大损失。 HotSpot JVM 团队(主要贡献者包括 David Dice 等人)在 JDK 6 中给出了答案:锁升级(Lock Escalation)机制。核心思路是——根据"大多数锁没有竞争"这个经验事实,让 synchronized 从最轻的模式开始: 偏向锁(Biased Locking):只有一条线程用这个锁时,Mark Word 里记个线程 ID 就行,不需要 CAS,几乎零开销。 轻量级锁(Lightweight Locking):两个线程交替使用(无实际竞争)时,在栈上分配 Lock Record,用 CAS 交换 Mark Word。 重量级锁(Heavyweight Locking):真有竞争时,才膨胀为 OS Mutex,线程阻塞等待。 这个设计让 synchronized 在大多数实际场景中的性能追平甚至超过了 ReentrantLock。锁升级的判断依据只有 Mark Word 中的 3 个比特位。 ...

八月 20, 2022 · 13 分钟 · 2724 字 · yaomingye
Cat Radio