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

CAS 从硬件到 Java:MESI 视角下的原子操作与 12 个原子类

CAS 从硬件到 Java 🤔 一、Intel 的工程师为什么要给 CPU 加一条 lock cmpxchg 指令 多线程编程中最基础的问题——count++ 不是原子操作。Java 层面它是三条字节码,CPU 层面它是 “LOAD → ADD → STORE” 三条指令。两个核心同时执行,结果必然互相覆盖。 一种解决思路是加锁——synchronized 把整个 count++ 包住,一次只有一个线程执行。但锁的代价高:上下文切换、线程阻塞/唤醒、内核态切换。高竞争场景下,线程在等待锁上花的时间可能比干活的时间还多。 有没有办法不阻塞线程、靠硬件指令实现原子更新?Intel 的 CPU 架构师提供了一个答案:lock cmpxchg(Compare and Swap)指令。它将"比较旧值→如果匹配就写新值"这个过程变成一条不可分割的 CPU 指令,配合 lock 前缀锁定总线(或缓存行),保证同一时刻只有一个核心能成功操作该内存地址。 这个思路的妙处在于:把"锁"从软件层(JVM / OS Mutex)下沉到硬件层(CPU 缓存一致性协议)。失败重试的代价只是几个 CPU 周期,不死锁、不阻塞、不切换上下文。道格·李在 JUC 中大量依赖 CAS 来构建无锁数据结构——ConcurrentHashMap 的 bucket 写入、ConcurrentLinkedQueue 的节点插入、AQS 的 state 更新,底层全是 CAS。 本文从 CAS 的硬件原理开始,一直讲到 Java 的 12 个原子类。 🏗️ 二、MESI 视角:为什么硬件需要 CAS 🏗️ 2.1 两个 CPU 同时写一个变量——MESI 的"竞速" 从 MESI 协议的角度重新审视 i++ 的三条指令。假设两个 CPU 核心(Core A 和 Core B)同时尝试对同一地址执行 ++: ...

八月 21, 2022 · 9 分钟 · 1876 字 · 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

volatile 如何填补 MESI 的两个缺口:Store Buffer 与 Invalidate Queue

volatile 如何填补 MESI 的缺口? 🤔 一、JSR 133 专家组为什么需要重新定义 volatile 在 JDK 1.4 及以前,Java 的 volatile 语义是模糊的。规范只说"对 volatile 变量的读写会直接在主内存进行",但什么叫"直接在主内存"、写入后多久另一个线程能看到、volatile 变量之间的指令能不能重排——这些问题都没有答案。不同 JVM 实现的行为不一致:有的插了内存屏障,有的什么都没做。 这直接导致了著名的双重检查锁定(DCL)单例在 Java 中不可靠的问题——即使 instance 声明为 volatile,在早期的 JMM 下仍然可能读到未初始化完成的对象。这个问题在当时被广泛讨论,甚至让不少开发者对 Java 并发编程失去了信心。 2004 年,JSR 133 专家组(道格·李是核心成员)重新定义了 volatile 的语义。新的 volatile 不再是一个模糊的"直接读写主内存",而是精确指定了四种内存屏障(LoadLoad、StoreStore、LoadStore、StoreLoad)在 volatile 读写前后的插入位置。 这个重新定义的本质是:用软件契约填补硬件盲区。Store Buffer 延迟写可见性 → volatile 写之后插 StoreLoad 屏障强制刷新。Invalidate Queue 延迟失效 → volatile 读之后插 LoadLoad 屏障强制缓存失效。指令重排序可能把 volatile 写后的普通写提到前面 → volatile 写之前插 StoreStore 屏障禁止。 volatile 不是用来做"原子操作"的(那是 CAS 的活),它的唯一职责是:保证一个线程对 volatile 变量的写入,对后续读取该 volatile 变量的其他线程立即可见。这就是 JSR 133 专家组对它的最终定义。 ...

八月 19, 2022 · 9 分钟 · 1724 字 · yaomingye

JMM 如何借鉴 MESI:Java 内存模型的概念引入

JMM 如何借鉴 MESI? 🏗️ 一、JSR 133 专家组为什么需要定义 JMM 上一篇文章讲完了 MESI 协议。它让所有核心看到一致的数据,但有一个前提:只管理 L1 Cache 之间的总线通信。Store Buffer、Invalidate Queue、编译器和 CPU 的指令重排序——这三样东西 MESI 完全不管。 CPU 架构师不管是有意为之:关掉 Store Buffer 和 Invalidate Queue 的代价是几十倍的性能损失,没有哪个芯片厂会做这种亏本买卖。但 Java 程序员不能不管——如果写了一个 stopped = true,另一个线程永远看不到,这就是线上事故。 2004 年,JSR 133 专家组(道格·李是核心成员之一)面临的问题很明确:不同的 CPU 架构有不同的内存模型(x86 是 TSO,ARM/PowerPC 更弱),Java 不能为每种 CPU 写一套并发程序。 Java 的"一次编写,到处运行"在并发领域受到了硬件差异的致命挑战。 专家组的选择是:在 Java 语言规范中定义一套软件层的内存可见性契约——JMM(Java Memory Model)。JMM 不规定 JVM 怎么实现(不管你是插 lock 指令还是 dmb 屏障),只管规则:如果你写了 volatile,那么 volatile 写之前的操作对 volatile 读之后的操作可见。 JMM 的核心参考模型就是 MESI。它将 MESI 的硬件概念映射为语言层的抽象: 🧠 二、JMM 的定位:一层"软件级缓存一致性" JMM 不是一个运行时可执行的东西。它是一套写在 Java 语言规范中的规则。它不规定 JVM 必须怎么实现 volatile——它只规定:如果你写了 volatile,那么 volatile 写之前的操作对 volatile 读之后的操作可见。 ...

八月 18, 2022 · 5 分钟 · 988 字 · yaomingye

MESI 缓存一致性协议

MESI 协议:CPU 是怎么保证缓存一致的? 🤔 一、CPU 架构师为什么需要 MESI 协议 1970 年代,CPU 直接从内存读数据,内存足够快。到了 1990 年代,CPU 主频飙到几百 MHz,内存还是几十 ns 的访问延迟——一颗 200MHz 的 CPU,等一次内存读取等于浪费十几个指令周期。CPU 架构师的应对方案是加缓存:把热数据放在离核心最近的地方。 缓存解决了速度问题,但制造了一个新问题:多核 CPU 中,同一个内存地址可能在多个核心的私有缓存中各有副本。 Core A 修改了 count = 1,Core B 的缓存中 count 还是 0——Core B 基于过期数据继续算,结果全错。 这个问题的本质不是"哪个值更正确",而是各个核心对同一地址的数据要有统一的认知——这就是缓存一致性(Cache Coherence)。没有它,多核处理器就等于多个单核处理器各算各的,合不起来。 Intel 架构师给出的答案就是 MESI 协议。它在每个缓存行上维护一个 2 位状态机——M(Modified,已修改且独占)、E(Exclusive,独占且干净)、S(Shared,多副本共享)、I(Invalid,本副本无效)。核心之间通过总线监听彼此的读写操作,自动在四种状态之间切换。 MESI 保证两件事:对同一地址的写操作最终对所有核心可见;所有核心对同一地址的写操作有一个全局一致的顺序(serialization)。它不是"所有核心时刻看到完全相同的值"——电信号传递本身有延迟,那是不可能的。它保证的是:给定足够时间,一致性一定会达成。 🏗️ 二、CPU 缓存层级结构 💾 2.1 三级缓存布局 现代多核 CPU 的缓存分为三级。L1/L2 每个核心私有,L3 所有核心共享: flowchart TD %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef highlight fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa,font-weight:bold; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; subgraph Core0["CPU Core 0"] L1_0[L1 Cache\n32KB 指令 + 32KB 数据] L2_0[L2 Cache\n256KB ~ 1MB] end subgraph Core1["CPU Core 1"] L1_1[L1 Cache\n32KB 指令 + 32KB 数据] L2_1[L2 Cache\n256KB ~ 1MB] end L1_0 --> L2_0 L1_1 --> L2_1 L2_0 --> L3[L3 Cache / LLC\n所有核心共享\n数MB ~ 数十MB] L2_1 --> L3 L3 --> MEM[主内存RAM] class L1_0,L1_1,L2_0,L2_1,L3 data; class Core0,Core1 highlight; class MEM process; 关键点: ...

八月 18, 2022 · 15 分钟 · 3144 字 · yaomingye
Cat Radio