
1. 从面试题说起ReentrantLock的线程安全答案卡在哪看到ReentrantLock如何保证线程安全这个题目你是不是觉得有点哭笑不得——锁用来保证线程安全不是天经地义的吗怎么还能单独出一道面试题但恰恰是这种理所当然的问题最容易把人的底裤掀出来。很多候选人能背出Lock接口的实现类、基于AQS、非公平锁默认这几句台词可一旦面试官追问AQS里那个state到底是个什么东西多个线程同时来抢锁凭什么只有一个能进来谁在排队、谁负责叫醒下一个立马就卡壳了。这道阿里一面的题考察的其实不是你会不会用ReentrantLock而是你有没有把并发编程的底层地基打扎实。因为ReentrantLock的线程安全牵扯到并发领域最核心的三个支点状态可见性、原子操作、阻塞唤醒。这三件事拆开看不难但组合在一起、再嵌入到一套可扩展的框架里就形成了AQSAbstractQueuedSynchronizer这座所有Java并发工具开发者都绕不开的大山。这篇文章我会把ReentrantLock的线程安全机制从物理层面拆给你看先讲清楚它靠什么三条腿站稳再带你把lock和unlock的源码流程走一遍接着聊聊可重入、公平锁、中断、超时这些花活最后落到一个大家容易忽略的问题上——AtomicInteger到底安不安全以及它跟ReentrantLock的线程安全原理有何异同。2. 三条腿站稳volatile、CAS、CLH队列如何撑起一把锁ReentrantLock的线程安全不是靠JVM给什么特殊待遇也不是靠synchronized那样的监视器机制它是在纯Java代码层面把三样基础武器组合了起来。这三样东西每一个都不是新概念但配合到一块儿就变成了一套完整的线程协调协议。2.1 volatile修饰的state所有线程都能看见锁是否被占用ReentrantLock内部包着一个Sync对象Sync继承自AbstractQueuedSynchronizer。AQS类里有个关键字段private volatile int state;这个state就是锁状态的标志位0表示锁空闲大于0表示锁被占用而且这个数字还记录了重入次数。关键在于volatile修饰——它保证了所有线程对state的读写都是直接操作主内存的不会读到线程各自工作内存里的过期副本。这里有个面试官常挖的坑volatile只能保证可见性不能保证原子性那我把state声明成volatile就行了吗不行。因为多个线程同时执行判断state0然后改成1这个操作时即使大家都看得见最新值也无法阻止两个人同时看到0、同时改成1。这就是经典的check-then-act竞态条件光靠volatile根本hold不住。2.2 CAS原子操作把判断修改压缩成一条指令解决竞态条件靠的是sun.misc.Unsafe提供的compareAndSetInt也就是我们常说的CASCompare And Swap。它的语义是只有内存当前值跟预期值相等时才把新值写进去整个过程是硬件级别的一条原子指令不会被线程调度打断。ReentrantLock抢锁的第一行比较就是这种操作// AbstractQueuedSynchronizer中 compareAndSetState(0, 1)AQS把这层封装成了protected方法ReentrantLock直接调用这个能力来完成抢占。这里有个背景在没有CAS的时代要实现同样的原子修改得加锁但加了锁又显得很讽刺——用锁去实现锁的原子性鸡生蛋蛋生鸡。CAS用硬件指令从底层解决了这个问题。2.3 CLH变体队列抢不到锁的线程怎么排队等待CAS保证了同一时刻只有一个线程能把state从0改成1但那些没抢到的线程呢总不能原地自旋空转再不停地CAS吧那CPU资源会被白白烧掉。AQS里的解决方案是用一个FIFO双向队列把失败的线程组织起来这个队列就是CLHCraig, Landin, Hagersten锁的一个Java变体实现。每个没抢到锁的线程会被包装成一个Node节点挂到队列尾部进入等待状态。头节点是当前持有锁的线程。等锁的线程不会被反复唤醒再抢而是真正地阻塞住只有前驱节点释放锁时才会用LockSupport.unpark精确唤醒后续节点。这三样合在一起就构成了一套完整的线程安全闭环volatile解决多线程看到的状态一致CAS解决多线程同时修改状态的原子性CLH队列 阻塞唤醒解决抢不到的线程不乱打转、不忙等待3. 站在AQS肩膀上看ReentrantLockLock接口如何借壳上市理解了三条腿还得弄明白ReentrantLock跟AQS的承上启下关系。ReentrantLock本身并不直接实现线程安全逻辑它所有同步操作全部委托给内部的Sync。3.1 内部Sync体系公平与非公平的差异根源ReentrantLock内部定义了一个抽象静态内部类Sync同时派生出两个子类abstract static class Sync extends AbstractQueuedSynchronizer { ... } static final class NonfairSync extends Sync { ... } static final class FairSync extends Sync { ... }创建ReentrantLock时构造参数true/false决定用哪个子类。默认是非公平锁。这个设计本身就是AQS模板方法模式的一个典型应用AQS把acquire、release这些骨架流程定义好把tryAcquire、tryRelease留成钩子方法ReentrantLock两个子类分别实现不同的钩子逻辑。3.2 非公平锁的tryAcquire先插队后排队来看默认的NonfairSync实现protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); }nonfairTryAcquire在Sync基类里final boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) // overflow throw new Error(Maximum lock count exceeded); setState(nextc); return true; } return false; }这段逻辑有四个关键点先读state判断锁是否空闲c0若空闲直接CAS抢锁不管队列里有没有其他线程在等——这就是非公平的来历后到的线程可以插队若锁已被占用且占用者正好是当前线程则state加1——这就是可重入的入口否则返回false进入等待队列3.3 公平锁的tryAcquire多一个先行判断FairSync只比NonfairSync多了一段代码protected final boolean tryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // ... 其余可重入逻辑与NonfairSync一致 return false; }hasQueuedPredecessors()就是公平锁的关键——如果发现队列里已经有等待线程即使锁刚好空闲当前线程也不能抢必须乖乖入队。这样保证了先来后到但代价是吞吐量下降、上下文切换增加。面试中如果被问公平锁与非公平锁性能差异的来源答案就在这个多出来的判断上。这里要插一句经验实际生产环境默认的非公平锁在绝大多数场景下吞吐更好因为插队减少了线程切换次数。但某些需要严格有序处理的任务比如消息按顺序写入某张表得用公平锁避免线程饥饿。4. 从lock到unlock一次完整加解锁旅程的源码拆解面试官最喜欢让候选人讲讲线程A拿到锁、线程B去抢、线程B抢不到之后发生了什么。这一步把acquire、addWaiter、acquireQueued、release四个方法串起来讲明白AQS的半壁江山就通了。4.1 lock方法入口的分叉逻辑// ReentrantLock.lock() public void lock() { sync.lock(); } // NonfairSync.lock() final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }注意这里的设计非公平锁在进入AQS框架之前先做了一次无锁化的CAS抢锁。CAS成功就说明锁刚空闲、自己是第一个抢到的连队列流程都不用走性能最优。只有CAS失败才进入acquire实际上NonfairSync这里也等于尝试了一次tryAcquireAQS内部还会再走一遍完整逻辑但外层预判能极大优化最顺利的路径。4.2 acquire的完整流程public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这短短三行代码背后的流程是tryAcquire(arg)尝试纯CAS抢锁成功则整条insert结束失败则调用addWaiter(Node.EXCLUSIVE)创建Node节点并插入队列尾部然后进入acquireQueued在队列中自旋/阻塞等待如果acquireQueued期间线程被中断最后通过selfInterrupt()补上中断标志4.3 addWaiter怎么保证追加到队尾操作线程安全private Node addWaiter(Node mode) { Node node new Node(Thread.currentThread(), mode); Node pred tail; if (pred ! null) { node.prev pred; if (compareAndSetTail(pred, node)) { pred.next node; return node; } } enq(node); // 自旋方式入队 return node; }这里有个非常精妙的设计多线程同时往队尾追加节点也是并发操作。AQS的处理是先用尾节点的引用建立prev指针再通过compareAndSetTail原子的把tail从原来的尾节点更新为新节点。CAS设置成功后才连接pred.next node。如果CAS失败另一个线程先改了tail就走enq自旋重试直到成功。4.4 acquireQueued排队等待中的循环状态机final boolean acquireQueued(final Node node, int arg) { boolean failed true; try { boolean interrupted false; for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; // help GC failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }这个死循环是队列的核心机制。每个等待中的线程会反复检查两个条件前驱节点是不是头节点head保存的是当前持有锁的线程自己能不能抢到锁如果前驱是头节点且抢锁成功说明轮到自己了把自己设为新头节点出队。如果前驱不是头节点或者抢锁失败就进入shouldParkAfterFailedAcquire判断——找到合适的park前驱然后真正挂起线程等待被unpark唤醒后再跑下一轮循环。这套机制叫自旋阻塞的混合等待被唤醒的线程不会立刻争抢而是先看队列排位有效防止了惊群效应。4.5 unlock的对称流程释放锁对应的代码是public final boolean release(int arg) { if (tryRelease(arg)) { Node h head; if (h ! null h.waitStatus ! 0) unparkSuccessor(h); return true; } return false; }tryRelease把state减1当state归零时把独占线程清空protected final boolean tryRelease(int releases) { int c getState() - releases; if (Thread.currentThread() ! getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free false; if (c 0) { free true; setExclusiveOwnerThread(null); } setState(c); return free; }注意这里有个很多新手踩过的坑ReentrantLock的unlock要求必须由持锁线程调用否则会直接抛IllegalMonitorStateException。synchronized没有这个问题是因为语义层面强制了锁的获得与释放必须搭配而ReentrantLock则把约束完全落在Javadoc和运行时检查上。如果跨线程调用unlock就会触发这个异常。真正唤醒下一个等待线程的逻辑在unparkSuccessor里把头节点的下一个有效节点取出来调用LockSupport.unpark(thread)。如果下个节点的线程已经取消等待就继续往后找。release的时间复杂度最优O(1)最坏O(n)取决于队头后面有没有需要跳过的canceled节点。5. 可重入与中断加锁次数怎么记、排队被打断会怎样5.1 可重入计数为什么不会导致死锁你可能会问同一线程反复加锁每次state都加1这个状态本身怎么保证不会出错答案在于可重入计数只由持有锁的同一个线程来改而通过volatile具备了写读可见性。虽然它不是CAS但锁的独占性质天然保证了同一时刻只有一个线程会改state因此元数据就不存在竞争。真正用于锁竞争的是0变1的那一下CAS之后的加加减减都是锁内操作。但是如果同一线程重入太多次state的int值溢出会变负数AQS检测到nextc0就直接抛Error(Maximum lock count exceeded)——这个边界条件在标准Javadoc里都没细说却挺值得记在面试清单里。5.2 lockInterruptibly响应中断的优雅版本ReentrantLock还能响应中断public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); }acquireInterruptibly在tryAcquire失败、进入排队后如果线程的interrupt位被设置会立刻抛InterruptedException而不是默默吞掉。这个特性对什么场景有用我最常举的例子是在线程池里执行任务时如果某个线程因为拿不到锁而一直阻塞但你希望任务被取消时能立刻退出等待用lockInterruptibly准没错。synchronized可做不到这一点——一个线程阻塞在synchronized的monitor上时你去interrupt它它的中断标志是会被设上但它还在继续等锁直到抢到锁后才察觉到信号。想要可取消的阻塞等待就只能用ReentrantLock。5.3 tryLock带超时不会无限等下去的锁tryLock(timeout, TimeUnit)内部逻辑是acquireNanos。它在任意时刻都能响应中断而且到期直接返回false调用方可以自己去决定是重试、转异步还是记日志告警。这种机制在生产上太重要了比如某个订单支付回调如果锁等不到就阻塞在那儿线程池会被慢慢吃满。正确的姿势是tryLock超时未获取到就直接返回失败由上层决定是否重试。这里有个用户体验非常好的实践把拿锁和执行业务分开封装拿不到锁时快速失败而不是无限等待。6. 条件队列与公平性synchronized够用时为什么还要它聊到这儿ReentrantLock的基本盘已经打完了但面试官大概率会接着问既然synchronized就能保证线程安全ReentrantLock存在的理由是什么我的答案永远围绕三个字灵活性。6.1 Condition一把锁上挂多个等待队列Lock接口提供了newCondition()方法可以在同一把锁上创建多个条件队列。对应的经典场景是生产者-消费者队列一个条件队列管队列空了消费者要等待另一个管队列满了生产者要等待。用synchronized就只有一个隐式条件队列想实现这种多状态等待只能自己用whilenotifyAll在整个锁上广播效率差不少。Condition的await/signal也不是干等它底层跟AQS的CLH队列有配套逻辑——await时线程会把state释放掉并挪到条件队列signal时再把条件队列里的节点转移到等待锁的队列中。这个机制是很多高级并发容器比如ArrayBlockingQueue的实现底座。6.2 公平锁在真实世界的取舍说完问题这里补充一个我在项目里踩过跟公平锁有关的坑。曾经有个金融对账系统多个线程批量写同一张流水表用公平锁去保护写入结果压测时吞吐几乎腰斩。原因是任务本身执行时间很短抢锁的线程频繁切换每次换人都有用户态内核态的上下文切换开销。后来改成非公平锁总耗时掉了接近40%而且并没有出现某个线程长时间饥饿的情况。非公平锁的饥饿在短临界区场景下非常罕见因为临界区短、锁很快重新空闲插队线程顶多插几次不至于让老排队者永远轮不到。相比之下如果在长任务、分阶段任务里使用非公平锁确实可能长时间饿着后到的线程。具体用那种取决于你临界区的平均时长和任务对延迟抖动的容忍度。7. 面试官追问延伸AtomicInteger线程安全吗顺着ReentrantLock怎么保证线程安全这个话题面试官经常用一个热词来试探AtomicInteger线程安全吗这个问题的第一反应不该是安全或不安全而是要区分问题的维度。7.1 可见性与原子性分明但CAS解决不了ABAAtomicInteger同样利用volatile修饰value字段来保证可见性利用Unsafe的compareAndSwapInt保证单一读改写操作的原子性。这跟ReentrantLock的CAS是同一套手法。所以单个方法的安全性上AtomicInteger线程安全。但它的安全范围比锁要窄得多它只保护单个value字段它的CAS操作不能覆盖多步操作整体原子化CAS会踩到ABA问题线程A读到值是1线程B改成2又改回1A去CAS时发现还是1直接成功了但中间已经经过了一番变动所以更准确地回答AtomicInteger只保证每个独立读改写操作是线程安全的不能保证组合操作先读再计算再写的线程安全也不能保证与它无关的其他字段的可见性。如果你需要的是多个字段一起变更也不能被其他线程拆开看到中间态那必须用ReentrantLock或synchronized这样的互斥方案。这恰好也解释了为什么AtomicInteger跟AQS的CAS是同源不同用——CAS是原子手段lock是互斥策略。7.2 面试中如何把这道题答出层次我见过的比较好的回答逻辑是分三层递进明确线程安全的定义——原子性、可见性、有序性三要素缺一不可说清楚ReentrantLock的安全基础——volatile保证可见性CAS保证状态修改的原子性CLH队列park/unpark保证等待与唤醒的秩序延伸到AtomicInteger——它用了volatileCAS但没有锁的独占语义组合操作仍然不安全所以适用场景不同这样回答既有细节又有体系比背书式的基于AQS实现强太多。8. 一些写并发代码时的个人教训最后分享几个我在日常编码里真正感受过的点希望能帮你在面试之外少踩几个坑。第一尽量缩小临界区。很多人有个坏习惯从数据库查到内存再到业务计算全程都套着lock结果锁的持有时间超长队列堆积明显。正确做法是锁只保护共享可变状态的读写网络IO、数据库访问这类耗时操作移出去。第二优先考虑读写锁或更高效的工具。如果你的场景是读多写少ReentrantLock并不能发挥优势这时ReentrantReadWriteLock或者StampedLock往往更合适。ReentrantLock的价值在于通用、可控、可中断不是最快。读多写少的场景里读写锁可以支持多个线程同时读吞吐完胜纯互斥锁。第三永远在finally里释放锁。这个已经老掉牙了但我在code review里每年都能看到遗漏的情况。如果锁中代码抛了异常没有finally的unlock会让锁永远无法释放其他线程全部死锁。用synchronized不用管这个ReentrantLock则必须靠自己。前阵子有个线上事故就是因为某段代码提前return时少写了unlock导致一批任务线程全被堵死排查半天才发现。第四试试从AQS的视角去阅读JUC其他工具。Semaphore、CountDownLatch、ReentrantReadWriteLock全是基于同一个框架做出来的不同语义。一旦吃透了AQS的acquire/release骨架你会发现自己看这些类的源码基本就是换钩子方法这个认知能大幅降低并发编程的学习曲线。面试题只是入口它真正想考察的是你能否在会用之上掌握会想。下次再有人问ReentrantLock如何保证线程安全你可以从volatile的可见性、CAS的原子性讲到CLH队列的秩序再到公平与非公平的分支、可重入计数、中断唤醒、与AtomicInteger的安全边界比较——这趟链路走下来才算是把这个面试题真正答透了。