很多人一听到“ReentrantLock如何保证线程安全”这个问题第一反应就是“内部用了AQS”。但面试官追问下去AQS凭什么能保证互斥state为什么必须是volatileCLH队列到底在等锁过程中做了什么这时候不少人就开始含糊了。这篇文章就从阿里这道一面题目出发完完整整拆一遍ReentrantLock这套线程安全机制从state字段、CAS抢锁到CLH变体队列、可重入计数再到公平锁、可中断、超时这些显式锁独有的能力顺带会把最近老被问到的“AtomicInteger线程安全吗”也拉出来对比一下。对准备面试的人这是一份能啃下来的源码走查对写业务的老手也能当作排查线上线程阻塞的参考。1. 面试官问“线程安全”实际是在考哪几件事1.1 并发出错的根源不只是“同时改数据”要理解ReentrantLock为什么能把线程安全扛住先得知道线程安全本身要解决哪些问题。很多人以为并发问题就是“两个线程同时修改一个变量导致数据错乱”其实没那么简单。一个count看着是一条语句底层要拆成三步读取count到寄存器、加1、把结果写回内存。线程A执行到第二步时被切走线程B进来把count从1改到2然后A接着写回自己寄存器里的旧值count就变成2而不是3——这是原子性被破坏。还有可见性问题线程A改了变量但由于CPU缓存或指令重排线程B读到的还是旧值。至于有序性当编译器或CPU为了性能打乱指令执行顺序某些依赖顺序的代码就可能出问题。所以并发场景下的“线程安全”本质上是同时管住原子性、可见性、有序性外加线程间的互斥协作。面试官问ReentrantLock怎么保证线程安全与其说在考API不如说是在看你能不能把这四件事说清楚并且能落到具体的实现机制上。1.2 ReentrantLock对这四件事分别做了什么ReentrantLock的答案非常直接原子性lock()和unlock()之间的临界区同一时刻只允许一个线程进入互斥本身就把“读-改-写”这种复合操作变成了一条不可分割的路径。可见性AQS内部有一个volatile int state获取锁的线程会读取state最新值释放锁的线程会把state的修改立即刷到主内存。更关键的是AQS的同步语义保证了解锁线程在临界区里的所有写操作对接下来拿到锁的线程都是可见的也就是happens-before关系。有序性加锁、解锁的内部方法上自带内存屏障效果能在关键节点上阻止危险的指令重排。互斥与协作它提供Condition让线程可以主动等待某个条件满足而不是干等一个锁。所以ReentrantLock的线程安全不是某一个魔法字段撑起来的是“volatile状态 CAS原子操作 队列阻塞/唤醒 happens-before规则”组合起来的一个完整方案。而这一整套骨架就是AQS。1.3 锁的底层抽象没那么玄把AQS想象成一个带门禁的公共厕所门口有一块显示牌state字段标记“有人/无人”门口装了一把只有硬件才能原子操作的锁CAS如果有人发现里面有人只能去走廊排队CLH队列排到队尾等着前一个人出来后再叫醒下一个。所有Java并发工具里的锁、信号量、栅栏抽象到最后都是这个模型一个状态变量、一条等待队列、一套阻塞和唤醒机制。看懂了AQSReentrantLock只是它的一个入门案例。2. 拆开AQS看底层state和等待队列各管什么2.1 state字段锁的“有人/无人”显示牌AQS最核心的字段就两个private volatile int state和队列头尾节点。state在ReentrantLock里表示锁的持有状态state 0当前锁未被持有state 1锁被某个线程持有state 2及以上同一个线程重入了一次又一次state记录的是重入次数。线程抢锁的时候执行的是compareAndSetState(0, 1)也就是用CAS把state从0改成1。CAS是CPU指令级别的原子操作底层靠Unsafe.compareAndSwapInt一次比较和交换要么全成、要么全不成中间不会被其他线程插一脚。这一步就堵死了“多个线程同时看到state0、同时把自己设成持锁人”的漏洞。state为什么必须是volatile因为它在多线程之间共享并且直接承担了“可见性”的责任。一个线程把state改成1或改回0另一个线程必须立刻能看到否则就会出现“锁明明释放了新来的线程却不知道”这种诡异情况。volatile保证了state的修改对其他线程立即可见同时禁止了与之相关的指令重排。这是面试里的高频追问点很多人背了“volatile保证可见性”却不知道在这个场景里到底是怎么用的——就是体现在这里。2.2 CLH变体队列等锁的人怎么排队、怎么被叫醒锁竞争激烈时抢不到锁的线程不能一直自旋空转那样CPU会被白白烧掉。AQS的做法是把抢锁失败的线程封装成一个Node节点塞进一个FIFO队列里然后让线程挂起park等锁释放时再唤醒。这个队列不是普通的单向队列而是CLH锁的变体每个Node都有前驱指针prev和后继指针next另外还存了线程引用thread和等待状态waitStatus。为什么要用双向链表因为被唤醒的线程要找到它的“下一个等待者”而取消等待的节点比如超时或者被中断需要从队列里摘除这就要同时操作前驱和后继。如果只用单向链表摘除中间节点根本没法实现。入队的动作是用compareAndSetTail完成的同样是CAS。head节点是“哨兵”代表当前持有锁的线程所在的位置真正的等待者从head.next开始排队。看到这里你应该能理解AQS的队列不是用来存放锁本身的它是用来登记“谁在等这把锁”的。2.3 模板方法模式框架定流程子类定规则AQS的厉害之处还不只是数据结构它把整个“抢锁失败—入队—阻塞—唤醒—再抢”的通用流程全部固定下来只把“如何判断自己能不能抢到锁”抽象成几个钩子方法留给子类实现这就是模板方法模式。对ReentrantLock来说真正要实现的只有几个方法tryAcquire、tryRelease、isHeldExclusively。AQS负责调用它们然后统一处理排队、park、unpark这些逻辑。也正因如此JUC里大量的工具都是站在AQS肩膀上实现的Semaphore控制许可证数量CountDownLatch控制倒计时门槛ReentrantReadWriteLock管理读锁和写锁两套状态。你搞懂一个ReentrantLock其实已经摸到了JUC半边天。3. 跟着源码走一遍lock/unlock看互斥是怎么落地的3.1 lock()非公平锁先试着“插队”非公平锁的lock()方法开头特别有意思final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }新线程进来第一件事不是老老实实排队而是先用CAS抢一次锁。抢成功了就直接成为锁持有者抢失败了才走进AQS的完整acquire流程。这一小步就是“非公平”的体现队尾的线程还在睡大觉刚来的线程反而可能先把锁拿走了。那为什么JUC默认推荐非公平锁因为性能更好。线程从挂起到被唤醒需要内核态的用户态切换开销非常大。如果能允许新线程在锁刚被释放的“空窗期”直接拿到锁就省掉了一次线程唤醒和上下文切换客观上提升了整体吞吐量。代价是某些等待线程可能饿肚子但ReentrantLock的非公平锁用的是“抢一次然后排队”的策略不是完全不管队列所以饿死情况很少见。公平锁则会直接用hasQueuedPredecessors()判断有没有人在排队有就绝不插队。3.2 acquire()和acquireQueued()抢不到锁到底会发生什么看AQS的acquire方法public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }流程分三步。第一步调用tryAcquire(1)这是ReentrantLock自己写的钩子方法里面会再看一眼state如果state是0就再CAS一次如果state不是0但当前线程已经是锁持有者就累加重入计数这个后面单独说。第二步是addWaiter把当前线程包装成独占Node插入队尾。第三步是acquireQueued这一步才是线程真正“睡觉”的地方。acquireQueued是一个for循环循环里做两件事先检查自己的前驱是不是head如果是就再试一次抢锁抢不到或者前驱不是head就把自己挂起。所谓的“挂起”就是LockSupport.park()当前线程进入WAITING状态停留在Java线程状态的waiting/parking上。等锁释放时被唤醒循环继续再判断自己是不是已经排到队首拿到锁就从循环退出。注意addWaiter和CAS入队也可能发生竞争所以队列初始化是用compareAndSetHead完成的懒初始化。3.3 unlock()释放锁没你想的那么直接释放锁走的是相反链路public void unlock() { sync.release(1); }release(1)先调用tryRelease(1)。这个方法第一步就检查当前线程是不是锁持有者不是就直接抛IllegalMonitorStateException这是很多人踩坑的地方在别的线程里调用unlock或者锁已经释放了又调一次都会在这里炸出来。然后state减1如果减完之后不是0说明是可重入场景锁还被同一个线程持有直接返回false不唤醒队列。只有当state变成0才说明锁真正释放了清空exclusiveOwnerThread然后去找等待队列里的后继节点并唤醒它。有意思的是唤醒后继的方式unparkSuccessor并不是直接唤醒head.next就完事而是从尾节点往前遍历找到离head最近的一个未被取消的节点再唤醒。为什么要从tail往前找因为CAS入队不是原子过程有极小的窗口期某个节点的next指针可能还没来得及设置从前往后遍历可能漏掉刚入队的节点。从尾部倒着找能保证找到的候选节点是稳定且有效的。这类细节就是面试官想听到的“我真的读过源码”。3.4 可重入计数为什么叫ReentrantLock可重入是ReentrantLock和synchronized都有的特性理解它要回到tryAcquire里的一段判断} else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) throw new Error(Maximum lock count exceeded); setState(nextc); return true; }当state不是0但持有锁的人正好是当前线程就说明这次加锁是“重入”。AQS不做别的只把state加1。相应地tryRelease每次减1减到0才真正释放。这个设计避免了一个死锁问题如果锁不可重入一个递归方法里多次加锁第二次加锁就会发现自己被自己挡住了。注意nextc 0的溢出保护int最多记录Integer.MAX_VALUE次重入真加到溢出就抛出Error防止state变成负数导致锁状态错乱。这种边界处理在老代码里不算少见但能自己主动写出来的不多。4. 公平锁、可中断、超时获取显式锁的灵活能力4.1 公平与非公平就差一行判断公平锁的tryAcquire开头比非公平锁多了一行if (c 0) { if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } }多出来的hasQueuedPredecessors()就是公平性的核心。它判断的是“当前队列里是否存在等得比我更久的线程”。如果有即使state现在是0我也不能抢必须入队等着。这个方法本身有很精巧的设计用h ! t排除队列为空或只有一个哨兵的情况再检查head.next的线程是不是自己。严格按请求到达顺序分配锁缺点就是频繁唤醒线程吞吐量不如非公平锁。所以在非特殊业务场景下默认用非公平锁反而更理智。4.2 lockInterruptibly和超时锁等待也能“中途退场”synchronized有个老痛点线程一旦进入等待外部几乎无法干预。ReentrantLock则通过lockInterruptibly()解决线程在等待锁的过程中如果收到中断信号会直接抛出InterruptedException并退出等待。同样处理中断的还有tryLock(timeout, unit)它把等待时间限制住超过指定时间就返回false。源码上体现在doAcquireNanos里每次循环park之前先算剩余时间预算耗尽就退出。这套机制在业务上的价值很实在比如一个接口要同时获取两台远程服务的资源锁第一把锁拿到了第二把锁迟迟等不到总不能永远挂在那里。这时可以用超时锁等不到就回滚第一把锁并响应失败避免整个线程池被拖垮。4.3 lock和lockInterruptibly对中断的态度不同面试里很容易问到一个细节lock()能感知中断吗答案是能但不是立刻退出。lock()在等待锁的过程中如果收到中断标记会把这个标记记下来但线程继续排队等成功拿到锁之后才执行一次selfInterrupt()重新给自己设置中断标记。实际效果是中断信号没有阻止它拿锁。而lockInterruptibly()则完全不同只要前置的Thread.interrupted()检查发现中断标记立即抛异常拒绝继续等。这个区别也是为什么调用lock()时最好在finally里处理中断标志的原因之一。5. 顺着热词聊AtomicInteger到底线程安全吗该怎么和Lock选型5.1 AtomicInteger线程安全的答案和边界最近后台常有人问“AtomicInteger线程安全吗”。先把结论说清楚原子类自身是线程安全的。它的incrementAndGet()内部是CAS自旋底层用一个volatile的value字段存数CAS保证比较和赋值两步操作原子完成。所以多线程同时执行incrementAndGet()不会丢更新。但“线程安全”不代表“万事大吉”。AtomicInteger只保证自己每个方法的原子性不保证多个方法组合起来的业务逻辑是安全的。举个例子你想做“检查当前值是否大于某个阈值大于才扣减”的操作get()和compareAndSet()分开执行两次调用之间线程可能被切走条件判断和实际操作就脱节了。这种复合操作要么用synchronized包住要么用AtomicInteger.updateAndGet这类能在一个原子操作里完成“读取-计算-写回”的API。很多面试者栽在这里说出“AtomicInteger线程安全”还不够得补充它的安全和锁的安全不在同一个维度上。5.2 Lock和CAS这对冤家到底怎么选很多初学者以为AtomicInteger比Lock“高级”因为“不加锁也能并发”。其实两种方案是不同竞争烈度下的不同武器对比维度ReentrantLockAtomicInteger/CAS核心思路悲观互斥阻塞等待乐观自旋失败重试竞争激烈时线程挂起CPU消耗低大量自旋CPU空转高竞争平缓时有加锁/唤醒开销轻量高效等待控制可中断、可超时、可条件等待通常没有真正等待队列适用场景临界区代码长、逻辑复杂单个操作或极短状态更新拿生活类比去热门餐厅吃饭门口排号等位是Lock站着等但能拿号干别的而CAS像是反复跑回去看有没有空位不排队但每次空跑都白费力气。餐厅生意越好反复跑的次数越多还不如老老实实拿个号。在实际工程里我的习惯是先这样分层如果并发核心就是一个计数器或一个状态位优先用Atomic系列如果临界区超过三行、有多个变量要一起改、或者需要条件等待协作立刻换ReentrantLock或synchronized。总想在低层CAS上硬写复杂业务逻辑基本都会写出隐蔽的并发Bug。6. 面试追问和实战排障会背原理不如会排障6.1 高频追问这些问题答不上来就露馅了一面题目后面通常跟着一串追问我梳理一下自己面试别人时最爱问的几环为什么非公平锁性能更好但可能饿肚子——省了线程唤醒的上下文切换但长时间竞争下等待线程可能迟滞。忘记unlock会发生什么——不会自动释放线程一直持有锁其他线程永久阻塞所以unlock必须放在finally里。可重入锁和synchronized怎么选——偏向锁和轻量级锁优化让synchronized在低竞争时更省心需要可中断、超时、多条件队列时用ReentrantLock。AQS里waitStatus有哪几种值——CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)。特别是CANCELLED节点清理的逻辑能答出来说明真看过源码。如何用jstack判断线程卡在锁上——线程状态是BLOCKED或者WAITING再结合堆栈里的锁ID和持有者信息定位。这些问题不属于死记硬背能答好一个大概率是把AQS代码“过”过一遍的人。6.2 一个真实的排查案例接口变慢线程堆栈全指着同一把锁之前有一次线上告警某个服务的下单接口P99从200ms飙到5秒。第一反应是dump线程堆栈jstack pid导出来一看几十个线程都卡在同一行代码——某个共享对象的lock.lock()线程状态清一色WAITING (parking)。下一步要确认谁持有锁在jstack的锁信息里找- locked 0x...标记发现是一个慢查询线程一直没走完临界区它在锁里面调了外部HTTP接口超时时间设了30秒导致整个业务被拖住。解决办法分两步一是把锁内部的远程调用挪到锁外面临界区只保护本地内存修改二是给tryLock(2, TimeUnit.SECONDS)加上超时上限拿不到锁就快速失败而不是无限等待。修改后P99立刻掉回300ms左右。这就是ReentrantLock和AQS原理在实际生产里最典型的使用场景你越清楚它内部怎么阻塞、怎么唤醒就越容易从堆栈信息里快速判断问题在哪一层。6.3 一套可以复用的排障流程结合多次实战我总结了一套比较完整的排查流程top -Hp pid看哪个线程CPU高或者直接用jstack pid采样几次。在jstack输出中找BLOCKED和WAITING的线程看堆栈是否都汇聚到同一个lock.lock()或者AQS的parkAndCheckInterrupt。利用jstack里的锁信息定位持有锁的线程确认持锁线程本身在干什么。如果是代码逻辑不当优化临界区范围和锁持有时间如果是锁竞争太激烈考虑读写分离、分段锁或者改用其他并发结构。变更后对照监控指标验证不要只凭感觉说“改好了”。线程安全的本质是让共享资源的访问路径变得可控。ReentrantLock提供的不仅是互斥还有一整套阻塞、唤醒和中断处理的框架。看懂了这套机制以后再遇到并发问题不是靠加锁“碰运气”而是能一眼看穿自己的代码到底堵在哪。我个人把这题当成并发编程的“分水岭”。能说清楚state为什么必须是volatile、CLH队列为什么要双向、非公平锁为什么快比背出十几种锁API有用得多。准备面试时建议拿着源码把lock/unlock流程亲手走一遍再用jstack模拟两个线程抢锁看看线程状态写业务代码时优先用更高级的并发工具没有错但心里要清楚自己在绕过哪一层机制。希望这篇整理能让你在下次面试或排查线程卡顿时心里更有底。
阅读完成 · 觉得有帮助?