Semaphore 源码解析

Semaphore 源码解析:AQS 共享模式、许可传播机制与公平策略实现 🚀 道格·李为什么需要一个信号量 信号量(Semaphore)是操作系统教科书里最古老的并发原语之一,由 Edsger Dijkstra 在 1960 年代提出。但在 Java 1.0 ~ 1.4 时代,JDK 里并没有信号量——开发者只能用 synchronized 加一个计数器模拟,代码又长又容易出错。 道格·李在设计 JSR 166 时,需要将信号量引入 Java,原因很简单:synchronized 是互斥的(同一时刻只能一个线程进入),而很多并发控制场景需要的是"限制并发数量"而不是"限制到只有 1 个"。比如数据库连接池最多 10 个并发连接、文件读取最多 3 个线程同时打开、API 限流每秒 100 个请求——这些场景用 synchronized 无法表达。 Semaphore 的思路是许可计数:构造时定义 N 个"许可证",线程调用 acquire() 拿走一个许可(不够就阻塞),用完调用 release() 归还。许可与线程没有绑定关系——线程 A 获取的许可可以由线程 B 释放。这个设计很关键:它让 Semaphore 不仅可以用作"限流器",还可以用作"对象池管理器"或"同步器"。 在 AQS 框架中,Semaphore 使用的是共享模式(Shared Mode)——多个线程可以同时获取许可,不像 ReentrantLock 的独占模式那样一次只唤醒一个线程。 🏗️ 核心数据结构 🏗️ 整体类层次结构 Semaphore 和 ReentrantLock 的内部结构高度相似——都是通过内部类 Sync 间接继承 AQS,并通过两个子类实现公平/非公平策略。但有一个关键区别:Semaphore 使用的是 AQS 的 共享模式(Shared Mode),而非独占模式。 ...

八月 26, 2022 · 10 分钟 · 2051 字 · yaomingye

CountDownLatch 设计思想解析:一次性的共享门闩为什么这样设计

CountDownLatch:一次性的共享门闩 🤔 道格·李为什么需要一个倒计时门闩 多线程编程里有一个反复出现的问题:主线程需要等待若干个子任务全部完成,然后汇总结果继续执行。在 JUC 出现之前,Java 只有两种方式应对: Thread.join()——但 join() 等的是线程终止,不是任务完成。如果线程来自线程池(被复用,不会终止),join() 完全不适用。它是"等人死了"而不是"等人把活干完"。 自己写自旋等待——用一个 volatile 计数器,主线程循环检查。但自旋空转浪费 CPU,加 sleep 又会引入延迟,而且 count++ 本身不是原子操作。 道格·李在设计 JSR 166 时看到了这个空缺:需要一个轻量级的同步辅助工具,让一个(或多个)线程能够等待其他线程完成一组操作,不依赖线程终止,不浪费 CPU,而且足够简单。 这就是 CountDownLatch 的诞生背景。它用 AQS 的共享模式实现了一个一次性的倒计时器:计数器从 N 开始,每个子任务完成时减 1(countDown()),主线程在 await() 上阻塞直到计数器归零。 道格·李给它设计了几个刻意的约束: 一次性——计数器归零后无法重置。这个约束简化了实现(不需要考虑"重置时正在等待的线程怎么办"),也迫使使用者为可重复场景选用 CyclicBarrier 只减不增——countDown() 只能减少计数,无法增加。这也简化了状态机——计数器状态只有 N→0 这一个方向 基于 AQS 共享模式——让多个等待线程可以同时被唤醒(当计数器归零时),而不是排他锁那样只唤醒一个 设计理念 1:一次性的约束 CountDownLatch 最核心的设计约束是 一次性 ——一旦计数器从 N 减到 0,门闩永久打开,无法再关闭。 这个约束绝非"能力不足",而是 刻意的设计取舍 : 如果支持重置 代价 需要处理"已有线程在 await 上等待"和"重置后的新等待者"两种状态 状态机复杂度翻倍 countDown 和 reset 并发时的语义难以定义 需要额外的同步机制 每个等待者都要知道自己是"老批次"还是"新批次" 需要代际(generation)标记 一次性约束消除了一个巨大的设计空间:时间维度上的状态管理。 CountDownLatch 只有两个有意义的状态: state > 0 (门闩关闭)和 state == 0 (门闩打开)。从关闭到打开只需要 state 单向递减,不需要考虑"打开了又关上"的复杂路径。 ...

八月 26, 2022 · 5 分钟 · 986 字 · 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