并发编程这块内容,我前前后后翻过不少资料,也踩过不少坑。刚开始接触多线程那会儿,总觉得synchronized够用了,直到有一次在模拟项目里遇到一个需要超时放弃锁、还要能中断等待的场景,才发现光靠关键字根本搞不定。后来被逼着去啃Lock接口和它背后的AQS,才算真正摸到了Java并发包的门道。这篇内容就是把我从“只会用synchronized”到“能看懂AQS骨架、能自己选锁”这段路上攒下来的经验整理出来,重点讲清楚Lock和AQS到底是什么关系、为什么要有它们、以及实际写代码时怎么选、怎么用、怎么避坑。不管你是刚学完线程基础想往深走一步,还是已经在项目里用过ReentrantLock但没搞明白它内部怎么转的,应该都能从里面找到对你有用的东西。
1. 先把概念理清楚:Lock、AQS、synchronized到底谁是谁
很多人第一次看到“AQS与Lock区别”这个说法会有点懵,因为严格来讲这两者压根不是一个层级的东西,谈不上并列对比。我当初也在这上面绕了很久,所以先把这层关系掰开揉碎讲明白,后面所有内容才好展开。
1.1 Lock是一个接口规范,不是具体实现
java.util.concurrent.locks.Lock从JDK 1.5开始出现在标准库里,它本质上就是一个接口,定义了锁应该具备哪些能力。你可以把它理解成一份“锁的行为契约”,里面声明的方法包括lock()、unlock()、tryLock()、tryLock(long, TimeUnit)、lockInterruptibly()、newCondition()这几类。注意它跟synchronized最大的不同在于:加锁和解锁是显式调用的,你必须自己写unlock(),而且必须放在finally里,否则一旦业务代码抛异常,锁就永远释放不掉。
这个设计看起来像是把责任推给了开发者,但它换来的灵活性是synchronized给不了的。比如tryLock()可以“试一下,拿不到就走人”,tryLock带超时的版本可以“最多等3秒”,lockInterruptibly()可以“等待过程中响应中断”。这些能力在真实项目里非常有用,尤其是做资源调度、防止死锁、做降级策略的时候。
Lock接口的常见实现有ReentrantLock(可重入锁)、ReentrantReadWriteLock内部的读锁和写锁。它们才是你真正new出来用的东西。所以第一句话记住:Lock是规范,ReentrantLock是产品。
1.2 AQS是锁的“发动机”,是底层框架
AbstractQueuedSynchronizer,简称AQS,是java.util.concurrent.locks包下的一个抽象类。它不是一个能直接用的锁,而是一套用来构建锁和同步器的框架。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock这些我们天天用的并发工具,内部全都是基于AQS实现的。
打个比方:如果Lock是“汽车应该有哪些功能”的说明书,ReentrantLock是具体某款车,那AQS就是这款车的发动机加底盘。它把“线程怎么排队、怎么阻塞、怎么唤醒、状态怎么维护”这些又脏又累的活全干了,具体某个锁只需要告诉它“我这个锁的规则是什么”就行。
AQS的核心就三样东西:一个用volatile修饰的int型状态变量state、一个FIFO的双向等待队列(CLH队列的变体)、以及一套基于LockSupport的阻塞唤醒机制。所有基于AQS的同步器,本质上都是在玩这个state:谁把state改成功了谁就拿到锁,改不成功就去队列里排队等着。
1.3 三者关系一句话总结
我用一张表把这三者的定位摆清楚,比纯文字好记:
| 名称 | 层级 | 角色 | 能否直接new |
|---|---|---|---|
| synchronized | JVM关键字 | 内置锁,由JVM层面实现 | 不涉及 |
| Lock | 接口 | 锁的行为规范 | 不能,需用实现类 |
| ReentrantLock | 具体类 | Lock的常用实现 | 可以 |
| AQS | 抽象框架类 | 同步器的底层引擎 | 不能,需继承 |
所以“AQS与Lock区别”这个问法,更准确的表述应该是“Lock接口体系与它底层依赖的AQS框架之间是什么关系”。搞清楚这一点,你就不会再把它们当成两个平行的东西去死记硬背了。
2. 为什么要有Lock:synchronized不够用的那些场景
我一开始也疑惑,既然synchronized用起来那么简单,加个关键字就完事,为什么JDK还要费劲搞一套Lock出来。后来在实际项目里被几个场景教育了,才明白这不是重复造轮子,而是补短板。
2.1 synchronized的三个硬伤
第一个硬伤是无法中断等待。一个线程在synchronized块外面等锁的时候,你没法让它中途放弃,它只能死等。这在做超时控制或者服务优雅下线的时候非常要命。
第二个硬伤是没有超时机制。synchronized拿不到锁就一直阻塞,你没法说“我最多等2秒,等不到就去做别的事”。在分布式或者高并发场景里,无限等待往往意味着线程池被拖垮。
第三个硬伤是锁的获取和释放不够灵活。synchronized是块结构,锁的范围由代码块边界决定,你没法在一个方法里加锁、在另一个方法里解锁(虽然这种需求少,但确实存在)。而且它不支持“尝试获取”,也不支持读写分离。
2.2 Lock补齐的能力清单
Lock接口针对上面这些短板,给出了对应的解法,我列成清单方便对照:
- 可中断获取:
lockInterruptibly(),等待锁的过程中如果线程被中断,会抛InterruptedException并退出等待,不会傻等。 - 超时获取:
tryLock(long timeout, TimeUnit unit),到点拿不到就返回false,业务可以走降级逻辑。 - 非阻塞尝试:
tryLock(),立即返回结果,适合“能拿就拿,拿不到跳过”的场景。 - 条件变量:
newCondition()配合await()/signal(),实现比wait()/notify()更精细的线程协作,一个锁可以挂多个条件队列。 - 公平性可选:
ReentrantLock构造时可以传true开启公平模式,让等待最久的线程优先拿锁。
这些能力不是花架子。举个我实际遇到的例子:模拟项目里有个任务调度模块,多个工作线程抢同一批任务,我们要求“抢不到锁的线程不能干等,要立刻去处理别的队列”。这种需求用synchronized根本写不出来,换成tryLock()几行代码就解决了。
2.3 但别急着全面替换synchronized
这里必须泼一盆冷水。Lock能力强,不代表synchronized就该淘汰。JDK 6之后synchronized做了大量优化(偏向锁、轻量级锁、锁消除、锁粗化),在竞争不激烈的场景下性能并不差,而且它不会忘记释放,代码也更简洁。
我的经验判断标准是这样的:能用synchronized写清楚、且不需要中断/超时/条件变量的,就用synchronized;一旦涉及复杂的等待控制、需要多个条件队列、或者要做公平性控制,再上Lock。无脑全用ReentrantLock反而容易因为漏写unlock()引入死锁,这种事故我见过不止一次。
3. 拆开AQS看骨架:一个state加一条队列
要真正理解ReentrantLock为什么能实现那些能力,就得往AQS里看一眼。不用怕,它的核心结构其实很朴素,我尽量用大白话讲。
3.1 state:所有同步器的“命根子”
AQS里最关键的字段是这个:
private volatile int state;就一个int,volatile保证可见性。所有基于AQS的同步器,都是通过对这个state做CAS操作来判断“能不能拿到锁”。具体怎么解释这个state,由子类自己决定:
ReentrantLock里,state表示重入次数。0表示没人持有,大于0表示被某个线程持有且重入了这么多次。Semaphore里,state表示剩余许可数。CountDownLatch里,state表示还没倒数的计数。ReentrantReadWriteLock里,state的高16位表示读锁持有数,低16位表示写锁重入数。
这个设计非常巧妙,一个字段被不同同步器赋予不同语义,框架只负责“改成功了就放行、改失败就排队”,具体语义交给子类。这就是模板方法模式的典型应用。
3.2 等待队列:抢不到锁的线程去哪了
当线程CAS修改state失败(说明锁被别人占了),AQS会把它包装成一个Node节点,追加到一条双向链表的尾部。这条链表就是同步等待队列,结构大致是:
head <-> node1 <-> node2 <-> ... <-> tail每个Node里记录了当前线程、等待状态(waitStatus)、前驱后继指针。队列的头节点head通常是个哨兵节点,不关联实际等待的线程。线程入队后,会通过LockSupport.park()把自己挂起,避免空转消耗CPU。
当持有锁的线程释放锁时,会唤醒head的后继节点,让它重新去竞争。这里有个细节值得注意:被唤醒的线程不一定能立刻拿到锁,它还得再CAS一次,因为可能同时有其他线程来抢。这种“唤醒后重新竞争”的机制,是非公平锁吞吐量高的原因之一。
3.3 独占与共享:两种获取模式
AQS把获取同步状态分成两种模式:
- 独占模式(Exclusive):同一时刻只允许一个线程持有,
ReentrantLock用的就是这个。子类需要实现tryAcquire和tryRelease。 - 共享模式(Shared):允许多个线程同时持有,
Semaphore、CountDownLatch用的是这个。子类需要实现tryAcquireShared和tryReleaseShared。
这两种模式在队列里的表现也不同。共享模式下,一个节点被唤醒拿到状态后,还会继续唤醒后面的共享节点,形成“传播”效果;独占模式则只唤醒一个。理解这一点,你就能明白为什么CountDownLatch的await()能被多个线程同时等到。
3.4 模板方法:AQS留给子类的“填空题”
AQS本身把排队、阻塞、唤醒这些通用逻辑写死了,但把“什么算获取成功”留给子类。它定义了一组需要子类重写的方法:
| 方法 | 作用 | 是否必须实现 |
|---|---|---|
| tryAcquire(int) | 独占式获取 | 独占模式必须 |
| tryRelease(int) | 独占式释放 | 独占模式必须 |
| tryAcquireShared(int) | 共享式获取 | 共享模式必须 |
| tryReleaseShared(int) | 共享式释放 | 共享模式必须 |
| isHeldExclusively() | 是否被当前线程独占 | 用到Condition时需实现 |
ReentrantLock的Sync内部类就是继承AQS,然后实现了tryAcquire和tryRelease。你去看源码会发现,tryAcquire里干的事就是:判断state是不是0,是0就CAS抢,不是0就看持有者是不是自己,是自己就把state加1(重入)。逻辑非常直白。
4. ReentrantLock实操:从加锁到条件变量的完整用法
概念讲完,得落到代码上。这一节我把ReentrantLock的典型用法、参数选择、以及条件变量的配合方式,按我实际写项目的顺序过一遍。
4.1 最基础也最容易写错的标准模板
先看最朴素的用法,这个模板我建议直接背下来:
ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 临界区业务代码 } finally { lock.unlock(); }看起来简单,但坑就在finally上。我见过有同事把unlock()写在try块末尾,结果业务代码一抛异常,锁就永远释放不了,后续所有线程全部卡死。unlock()必须放在finally里,这是铁律。还有一点,lock()调用本身要放在try外面,因为如果lock()都失败了,你再去unlock()反而会抛IllegalMonitorStateException。
4.2 tryLock的两种形态与使用时机
tryLock()无参版本是立即返回的,拿不到就返回false,不阻塞。适合那种“抢到就干、抢不到就跳过”的轮询式任务分发:
if (lock.tryLock()) { try { // 拿到锁,处理任务 } finally { lock.unlock(); } } else { // 没拿到,去做别的事或者直接返回 }带超时的版本更适合防止死锁和做降级:
try { if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 临界区 } finally { lock.unlock(); } } else { // 2秒没拿到,走降级逻辑 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 处理中断 }这里有个实操心得:带超时的tryLock一定要处理InterruptedException,而且捕获后建议重新设置中断标志(Thread.currentThread().interrupt()),否则中断信号会被吞掉,上层逻辑感知不到。这个细节很多示例代码都省略了,但在生产环境里很关键。
4.3 公平锁与非公平锁:一个参数的性能取舍
new ReentrantLock()默认是非公平的,new ReentrantLock(true)才是公平锁。这两者的区别在于:公平锁会检查等待队列里有没有排队的线程,有的话新来的线程乖乖去队尾;非公平锁则允许新来的线程直接插队抢一把。
从吞吐量看,非公平锁明显更高,因为减少了线程唤醒和上下文切换的开销。公平锁虽然能避免饥饿,但代价是性能下降,而且它保证的“公平”也只是相对的。我的建议是:除非业务明确要求严格的先来后到(比如某些资源分配场景),否则默认用非公平锁就行。绝大多数项目里,非公平锁的“插队”行为反而提升了整体效率。
4.4 Condition:比wait/notify更好用的线程协作
Condition是Lock体系里我很喜欢的一个能力。synchronized配合wait()/notify()只能有一个等待队列,而一个Lock可以new出多个Condition,实现分组唤醒。
经典的生产者消费者模型用两个条件变量写出来是这样的:
ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition(); Queue<String> queue = new LinkedList<>(); int capacity = 10; // 生产者 void produce(String item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.add(item); notEmpty.signal(); } finally { lock.unlock(); } } // 消费者 String consume() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } String item = queue.poll(); notFull.signal(); return item; } finally { lock.unlock(); } }注意几个要点:await()必须放在while循环里而不是if里,因为线程被唤醒后可能条件又不满足了(虚假唤醒),得重新检查。signal()只唤醒对应条件队列里的一个线程,比notifyAll()精准得多,减少了无谓的竞争。这套写法比wait/notify清晰,也不容易出错。
5. 手写一个简易同步器:把AQS真正用起来
光看ReentrantLock源码还是隔了一层,我建议自己继承AQS写一个最简单的同步器,写完你对state和模板方法的理解会立刻上一个台阶。下面这个例子实现一个“最多允许N个线程同时进入”的限流器,本质是个简化版Semaphore。
5.1 定义同步器内部类
class SimpleLimiter { private final Sync sync; SimpleLimiter(int permits) { sync = new Sync(permits); } private static class Sync extends AbstractQueuedSynchronizer { Sync(int permits) { setState(permits); } // 共享模式获取 @Override protected int tryAcquireShared(int acquires) { for (;;) { int available = getState(); int remaining = available - acquires; if (remaining < 0) { return remaining; // 负数表示失败 } if (compareAndSetState(available, remaining)) { return remaining; // 非负表示成功 } } } // 共享模式释放 @Override protected boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); int next = current + releases; if (compareAndSetState(current, next)) { return true; } } } } public void acquire() { sync.acquireShared(1); } public void release() { sync.releaseShared(1); } }5.2 这段代码里的关键点解读
tryAcquireShared的返回值语义要记牢:负数表示获取失败,0表示成功但后续线程也别想拿了,正数表示成功且还有余量。这个返回值直接决定AQS要不要继续唤醒后面的节点,写错了会导致唤醒逻辑异常。
循环里用for(;;)配合CAS,是AQS子类的标准写法,因为CAS可能失败,失败了得重读state再试。这里没有用锁,全靠compareAndSetState这个原子操作保证线程安全。
acquireShared(1)和releaseShared(1)是AQS提供的模板方法,它们内部会调用我们重写的tryAcquireShared/tryReleaseShared,并在失败时负责入队、阻塞、唤醒。我们完全不用管排队逻辑,这就是AQS的价值——把复杂的部分封装好,你只填业务规则。
写完这个例子,再回头看ReentrantLock的tryAcquire,你会发现套路一模一样,只是独占模式判断的是“持有者是不是当前线程”,共享模式判断的是“余量够不够”。
6. 常见问题与排查实录:那些年踩过的锁坑
理论和代码都过了一遍,最后这部分是我觉得最有价值的内容——实际项目里真正会遇到的坑,以及怎么排查。这些问题文档里往往不写,但踩一次能记一辈子。
6.1 死锁:最经典也最容易被忽视
死锁的四个必要条件(互斥、占有且等待、不可抢占、循环等待)大家都知道,但实际写代码时还是会中招。我遇到过一次:两个线程分别持有锁A和锁B,然后互相去拿对方的锁,直接卡死。
排查手段我常用两个。一是用jstack打印线程栈,死锁的线程会显示BLOCKED或者WAITING,并且能看到它持有哪把锁、在等哪把锁。二是代码层面预防,统一加锁顺序是最有效的办法——所有线程都按“先A后B”的顺序加锁,循环等待就断了。另外tryLock带超时也是好办法,拿不到就放弃并释放已持有的锁,避免无限等待。
6.2 锁没释放:unlock漏写或位置错误
这个前面提过,但值得单独强调。除了finally的问题,还有一种隐蔽情况:在try块里return之前手动unlock(),结果中间抛异常就漏了。正确做法永远是finally。我现在的习惯是写完lock()立刻把try-finally骨架敲出来,再往里填业务,从源头上杜绝。
6.3 锁粒度过大导致性能塌方
有次模拟项目里,一个方法整体加了锁,里面既有共享变量操作,又有耗时的IO调用,结果并发度极低。后来把IO挪到锁外面,只锁住真正操作共享状态的那几行,吞吐量直接翻了几倍。锁的范围要尽可能小,只包住临界区,这是性能优化的第一原则。但也不能小到把该原子执行的操作拆开,那就失去锁的意义了。
6.4 Condition用错导致虚假唤醒问题
前面提过await()要放while里,这里再补充一个:signal()唤醒的线程如果条件仍不满足,它会再次await(),所以while循环是必须的。用if的话,被唤醒后直接往下执行,可能操作了不该操作的状态。这个bug很隐蔽,因为大多数时候条件确实满足了,偶尔才出问题,测试很难覆盖。
6.5 常见问题速查表
我把上面这些整理成一张表,方便遇到问题时快速对照:
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 线程全部卡住不动 | 死锁或锁未释放 | jstack看线程栈,检查unlock是否在finally |
| 并发性能差 | 锁粒度过大 | 缩小临界区,把耗时操作移出锁 |
| 偶发数据错乱 | 虚假唤醒或临界区遗漏 | await放while循环,检查共享变量是否都在锁内 |
| 中断信号丢失 | 捕获InterruptedException未重置标志 | 捕获后调用Thread.currentThread().interrupt() |
| 公平锁吞吐低 | 公平模式开销大 | 评估是否真需要公平,否则改非公平 |
6.6 一个容易被忽略的实操心得
最后分享一个我自己的习惯:在团队里推行“锁的注释规范”。每次用Lock,在加锁那行上方写清楚“保护的是哪个共享变量、为什么需要这把锁”。听起来啰嗦,但后来接手别人代码时,这个注释救了我好几次——有些锁看起来莫名其妙,看了注释才知道它在保护一个跨方法的隐式状态。并发代码的可读性本来就差,多写一行注释,能省下后来人半小时的排查时间。
另外,ReentrantLock虽然可重入,但重入次数要和unlock()次数严格配对,加了几次就得解几次。我见过在递归里加锁但只在最外层解锁的写法,直接导致锁泄漏。这种地方宁可多加一层封装,也别让重入次数失控。