尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入理解ReentrantLock:可重入锁原理、AQS与Condition实战

深入理解ReentrantLock:可重入锁原理、AQS与Condition实战 开头如果你已经用惯了synchronized第一次接触ReentrantLock的时候大概率会有个疑问JDK 里明明有内置锁为什么还要搞一个需要手动lock()和unlock()的显式锁这个疑问带着你往下走就会发现ReentrantLock并不是synchronized的简单替代品而是把“锁”这个抽象概念做得更精细的一把工具。这篇内容我按讲给团队新人的方式从头拆一遍ReentrantLock它解决什么问题、怎么用、底层大概怎么回事、什么时候该选它而不是synchronized以及我在实际项目里踩过的几个坑。不管你是准备面试、写并发组件还是单纯想把多线程基础打牢这篇都能给你一条清晰的路径。1. 为什么需要 ReentrantLocksynchronized 解不了的围1.1 synchronized 的四大限制Java开发者最早接触的锁一定是synchronized。它用法简单、自动释放、异常也不怕死锁看起来完美。但等你的并发场景变复杂之后会发现它有几个不好绕过的问题第一不可中断。一个线程拿到锁之后如果长时间不释放比如里面有个慢查询其他线程只能干等你没法从外部把等待的线程打断这在线程池场景下很致命。想象线程池里有 10 个线程其中 8 个被卡在等待某把锁上即便你调shutdownNow()它们也响应不了中断池子就废了。第二不支持超时。synchronized没有“等 3 秒拿不到锁就放弃”的能力。这在做限流、做降级、做分布式组件本地锁的时候非常难过上游调用方等你返回降级结果你却在那儿无限阻塞。第三非公平锁一视同仁。synchronized是典型的不公平锁新来的线程可以插队。大多数场景没问题但某些对读写顺序敏感的场景比如日志顺序、库存扣减顺序你想要“先来先服务”的公平性它给不了。第四只有一个等待队列。synchronized配合wait/notify时所有等待线程挤在一个条件队列里想细分“读队列”“写队列”得靠额外变量自己标识既麻烦又容易出 bug。1.2 显式锁的定位精细化控制ReentrantLock这个名字拆开看就是“可重入锁”它的核心定位就是把上面四个短板全部补上支持中断响应、支持tryLock超时、默认非公平但可切换公平、内置多个Condition条件队列。官方文档里有一句话很直接“它对锁的语义有一种比synchronized更灵活的实现方式并且提供了更多功能。”我之前在一个网关组件里要给每个下游调用做本地熔断要求调用方等待锁的时间不能超过 300ms超时就立刻走降级。用synchronized没法做换成ReentrantLock的tryLock(300, TimeUnit.MILLISECONDS)十行代码搞定。这就是显式锁存在的意义它不是炫技而是给特别具体的业务约束提供一个落地的把手。2. ReentrantLock 核心 API 与基础使用2.1 从零开始一个最小可用 Demo先说最基本的使用姿势。ReentrantLock的可重入性指的是同一个线程可以多次获取同一把锁每次lock()都会让锁的持有计数加 1每次unlock()减 1减到 0 才算真正释放。下面这个例子是计数器累加模拟抢锁场景import java.util.concurrent.locks.ReentrantLock; public class LockDemo { private int count 0; private final ReentrantLock lock new ReentrantLock(); public void increment() { lock.lock(); try { count; } finally { lock.unlock(); } } public int getCount() { return count; } public static void main(String[] args) throws InterruptedException { LockDemo demo new LockDemo(); Thread t1 new Thread(() - { for (int i 0; i 10000; i) demo.increment(); }); Thread t2 new Thread(() - { for (int i 0; i 10000; i) demo.increment(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(最终 count demo.getCount()); } }这段代码跑完如果两个线程各自累加一万次最终结果必然是 20000。因为count不是原子操作没有锁保护时会出现丢失更新用了ReentrantLock就能保证同一时刻只有一个线程进入临界区。2.2 lock / unlock 的标准写法与禁忌上面代码里最关键的其实是try-finally结构这是使用ReentrantLock的唯一正确姿势。千万不能这样写public void wrongIncrement() { lock.lock(); count; lock.unlock(); }如果count这一行抛出异常unlock()永远不会执行锁就永远不释放。其他线程全部卡死在lock.lock()上轻则该模块瘫痪重则线程池被打满、整个服务出现雪崩效应。我见过不止一次线上事故追根溯源就是有人在异常路径上没有释放锁。注意synchronized靠 JVM 自动释放ReentrantLock没有任何自动机制。把unlock()放在finally里是这门语言里为数不多的“铁律”没有例外。另外一个小细节lock.lock()这一行建议放在try块外面。原因是如果lock()本身抛出异常比如线程被中断时lockInterruptibly会抛InterruptedException此时锁并没有被获取就不应该执行unlock()。标准模板是lock.lock(); try { // 业务代码 } finally { lock.unlock(); }2.3 常用方法速查ReentrantLock的方法不算多但每个都有明确的使用场景。我整理了一份平时用得最频繁的清单方法作用典型场景lock()获取锁拿不到就阻塞等待常规互斥临界区unlock()释放锁与 lock 配对必须放在 finally 中tryLock()立刻尝试获取锁返回 boolean非阻塞尝试拿不到就干别的tryLock(timeout, unit)在指定时间内等待获取锁限时等待、降级保护lockInterruptibly()获取锁且响应线程中断线程池优雅关闭isLocked()判断锁是否被某个线程持有监控、调试isHeldByCurrentThread()当前线程是否持有此锁嵌套场景判断getQueueLength()等待获取锁的线程数量监控告警getHoldCount()当前线程持有锁的重入次数调试重入逻辑isFair()判断是否公平锁配置核对这里提醒一点不要用getQueueLength()判断业务是否出现锁竞争激烈。它在高并发下只是一个瞬时值而且返回的只是估算不能作为精确指标。真正看竞争要用 JFR 或者ThreadMXBean拿线程阻塞时间。3. 底层原理AQS 与公平锁/非公平锁3.1 一句话理解 AQSReentrantLock的内部核心是一个叫AbstractQueuedSynchronizerAQS的抽象同步器。AQS 在并发包里的地位相当于地基之于大楼CountDownLatch、Semaphore、ThreadPoolExecutor的Worker里都在用它。AQS 做的事情可以浓缩成一句话用一个volatile int state变量记录同步状态配合一个 FIFO 等待队列管理未获取到锁的线程。对ReentrantLock来说state的含义就是“锁被获取的次数”——0 表示没有线程持有锁大于 0 表示某个线程已经重入过多次。加锁就是 CAS 地把state从 0 改成 1重入就是让state 1解锁就是state - 1回到 0 才真正释放。CAS 和volatile是理解 AQS 的两个前置概念。CASCompare And Swap是一种无锁的原子操作它先把当前值和期望值对比相等才更新新值volatile保证多线程之间变量的可见性。这两者结合AQS 才能在没有粗粒度锁保护下的前提下完成线程安全的队列操作。3.2 非公平锁和公平锁的差别到底在哪ReentrantLock的构造器长这样new ReentrantLock(); // 默认非公平锁 new ReentrantLock(true); // 公平锁 new ReentrantLock(false); // 非公平锁很多人以为公平锁就是“先来后到排队”非公平锁就是“谁抢到算谁的”这话大体对但不够精确。关键在于“新线程到来时做了什么”。非公平锁在新线程进入加锁流程时会先插队尝试一次 CAS如果刚好锁被释放就成功了如果尝试失败这才乖乖去队尾排队。公平锁则老老实实检查队列里有没有比它更早的等待线程有就去排队绝不插队。这样设计的目的其实是为了吞吐量。非公平锁允许处于运行状态的线程直接拿锁省去了切换阻塞唤醒的上下文开销但如果竞争非常激烈就可能出现“线程 A 释放锁新线程 B 插队成功老线程 C 一直被饿着”的情况——注意是“被饿着”不是“被饿死”因为非公平锁的插队只有一次失败后还是按队列排队极少出现真正的线程饿死通常是长尾延迟变大。实际选择上我个人建议默认场景用非公平锁除非你的业务顺序敏感或者阻塞时间特别长。因为加锁开销往往远小于业务执行时间不公平带来的“尾部延迟”在大多数业务里无感。但如果你在做一个消息顺序处理器要求严格按提交顺序执行任务就上公平锁。3.3 可重入是怎么实现的可重入是指同一个线程可以多次lock()同一把锁。这部分逻辑在nonfairTryAcquire里写得很清楚protected final boolean tryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { // 尝试 CAS 获取锁 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { // 当前线程已经持有锁重入计数累加 int nextc c acquires; setState(nextc); return true; } return false; }注意第二个分支里的current getExclusiveOwnerThread()。代码先判断锁是否已经被持有再判断持有者是不是自己。如果是state直接累加不进入阻塞队列这就是重入的核心。同时因为setState没有经过 CAS 而是直接赋值正好印证了一条同步器规则当前持有锁的线程可以无冲突地修改同步状态。重入的意义很实在允许你在一个加锁方法里嵌套调用另一个加锁方法不会自己锁死自己。比如lock.lock(); try { outerMethod(); // 里面又会 lock() } finally { lock.unlock(); }没有可重入性这把锁会在第二次lock()时把自己阻塞死那任何有层级的加锁代码都没法写。面试如果问到这里能把这个分支讲清楚基本上已经超过一大半候选人。4. 高级特性锁中断、超时与条件变量4.1 tryLock 与 lockInterruptibly 的实战运用tryLock是ReentrantLock使用频率最高的扩展方法它有两个重载核心解决“等不到就算了”的问题。if (lock.tryLock(300, TimeUnit.MILLISECONDS)) { try { // 拿到锁执行任务 } finally { lock.unlock(); } } else { // 300ms 内没拿到执行降级逻辑 doFallback(); }我在熔断组件里就是这么用的本地锁等待超过 300ms 直接走降级避免下游接口把调用方拖死。注意这里有个容易踩的坑tryLock返回false时锁没拿到绝不能调用unlock()否则会抛IllegalMonitorStateException。lockInterruptibly()则是“可以被打断的lock()”。它的典型场景是线程池关闭如果线程池里的线程在lock()上无限等待shutdownNow()根本停不下来。换成lockInterruptibly()后池子销毁时会中断等待线程阻塞状态被打破线程能快速退出。public void interruptibleLock() throws InterruptedException { lock.lockInterruptibly(); try { // 业务逻辑 } finally { lock.unlock(); } }区别要从异常类型去记lock()不接受中断lockInterruptibly()在等待时被中断会抛InterruptedException并且因为它还没有持锁unlock()不需要执行。这个细节在面试时经常被拎出来问。4.2 Condition替代 wait/notify 的升级版Condition是ReentrantLock最容易被忽略但含金量最高的部分。synchronized对应的是wait/notify一个对象只有一个内置的等待队列Condition允许你在一把锁上创建多个等待队列用newCondition()想建几个建几个。下面是一个有界队列的例子用两个Condition分别管理“队列满时等待写”和“队列空时等待读”。import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class BoundedBufferT { private final QueueT queue new LinkedList(); private final int capacity; private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); public BoundedBuffer(int capacity) { this.capacity capacity; } public void put(T item) throws InterruptedException { lock.lock(); try { while (queue.size() capacity) { notFull.await(); // 队列满写线程等待 } queue.offer(item); notEmpty.signal(); // 唤醒一个读线程 } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 队列空读线程等待 } T item queue.poll(); notFull.signal(); // 唤醒一个写线程 return item; } finally { lock.unlock(); } } }这段代码的核心是while循环 await()。用while而不是if是因为存在“虚假唤醒”spurious wakeup即使没有信号等待线程也可能被唤醒。再用条件判断一次可以保证队列状态确实满足要求。signal()唤醒的是等待队列中的一个线程signalAll()唤醒所有——如果只唤醒一个务必确认它唤醒后能检查条件并继续往下走否则另一类线程可能永远等不到通知。4.3 条件变量与等待队列的完整配合逻辑Condition.await()执行时其实发生了三件事释放当前持有的锁、把当前线程加入条件等待队列、让线程进入阻塞状态。这跟Object.wait()是同一个套路。当其他线程调用signal()时等待线程不会立即获取锁而是从条件队列转移到 AQS 的同步队列重新参与锁的竞争。代码被唤醒后回到await()的下一条指令继续执行但此时锁并没有在手里必须重新抢到锁才能往下走。这也是为什么await()必须放在lock()保护的范围里不持锁调用await()会抛IllegalMonitorStateException。更深一层说await()和signal()注定要配合相同的Condition实例两个不同Condition的等待线程彼此感知不到这正是它能实现精细唤醒的根本原因。5. 对比选型与高频踩坑实录5.1 与 synchronized 的全面对比速查表对比项synchronizedReentrantLock获取锁方式JVM 自动管理手动 lock/unlock释放锁异常自动释放必须 finally 中手动释放可中断不支持支持 lockInterruptibly超时等待不支持支持 tryLock(timeout)公平性仅非公平公平与非公平可切换条件队列一个对象一个 wait 队列多个 Condition 独立队列锁粒度控制较粗锁定方法体/代码块细颗粒度可精确到某个操作性能低竞争略胜一筹略低于 synchronized性能高竞争一般扩展性更好静态锁选择不可能支持锁的惰性加载、动态创建Java 6之后synchronized引入了偏向锁、轻量级锁、锁消除等优化在低竞争场景下它的性能并不比ReentrantLock差甚至更好。所以新版JDK官方推荐的其实仍然是能用 synchronized 就用 synchronized只有需要中断、超时、多条件队列时才换 ReentrantLock。这种“先简单后复杂”的思路本身就是写好并发代码的核心原则。5.2 我再补两句最真实的个人体会第一句加锁范围要尽量小。我看到过有人把一个for循环的累加整个包进锁里也没毛病但如果循环里夹了Thread.sleep、远程调用那就完全让锁变成了串行瓶颈。锁作为一种资源持有的时间越短系统的并发度越高。能用局部锁解决的事不要锁整个方法能拆成细粒度锁的不要只盯着一把锁。第二句调试ReentrantLock死锁或争用问题时多利用jstack。拿到线程 dump 后搜索waiting on monitor或locked字样可以看到每个线程等待的是哪把锁、持有哪些锁。还有个经验技巧给每把锁起一个有意义的名字配合Thread.setName排查问题速度会快很多。5.3 常见问题速查清单现象可能原因解决方案IllegalMonitorStateException未持锁调用 unlock/await/signal检查加锁与释放是否严格配对线程阻塞在lock()上无法中断用的 lock 而不是 lockInterruptibly换用 lockInterruptibly 或 tryLock其他线程一直等不到唤醒signal 唤醒了错误队列的线程用独立 Condition 精准管理等锁超时却走了降级tryLock 返回 false 理解错误明确 false 时不能 unlock偶发的逻辑错乱用了if而不是while做条件判断改成 while 循环重连续判断Fair 锁吞吐量明显下降每个新线程都要入队排队考虑使用非公平锁保证吞吐结尾最后再分享一个小技巧如果你在写一个需要并发控制的业务组件先用synchronized能满足需求就绝不上ReentrantLock当发现synchronized给不了你要的超时、中断或多条件唤醒时再把它平移到ReentrantLock。而且迁移成本其实很低——无非是把方法签名上挂的synchronized改成lock/unlock包裹、内部wait/notify改成对应的Condition方法。我面过不少候选人往往一听到“条件变量”就开始背诵概念但一问await()会释放锁吗、唤醒后需要重新抢锁吗就支支吾吾了。希望这篇讲清楚了毕竟面试官最喜欢从这种细节里头分辨一个人是真懂还是背题。用几行代码亲手跑一跑Condition的有界队列体会会比看一遍文章深很多。
返回列表