上个月值班的时候,线上一个库存扣减接口突然变慢,平均响应时间从50毫秒涨到了3秒。我第一反应就是上jstack,结果发现一堆线程全卡在同一个加锁方法上。当时同事在群里问:这不是加了synchronized吗?为什么还会这样?我沉默了一会儿,因为答案不是简单一句“锁用错了”能说清的。
这个问题的根源,是你对锁策略的理解够不够深。多线程并发是后端开发绕不开的坎,而锁策略又是多线程里面最容易被问懵、也最容易写出性能隐患的一块。悲观锁、乐观锁、公平锁、非公平锁、可重入锁、自旋锁、读写锁、分段锁、锁升级……名字多得让人头大,但实际工作中你未必全用得上,却必须全都看得懂。因为面试考你,线上故障也考你。
这篇文章我想把这些常见锁策略从头到尾捋一遍,每个都讲清楚设计思路、适用场景、代码长什么样,以及我踩过的坑。适合正在准备多线程面试题的开发者,也适合被线上并发问题搞得焦头烂额的维护人员。你不需要一次全记住,但看完之后,再遇到锁的问题,心里会有一张清晰的图。
1. 锁到底在解决什么问题
1.1 从一段跑不对的代码说起
先说一个最简单也最经典的现象。假如你写了一个计数器,两个线程同时执行 count++ 一百万次,你觉得结果应该是两百万。但实际跑出来,往往只有一百二十万上下,甚至更低。
java复制public class Counter {
private int count = 0;
public void increment() {
count++;
}
}
看起来 count++ 就是一行代码,但它并不是一个原子操作。它在底层分三步:读取count的当前值、把值加1、把新值写回去。当两个线程同时执行时,完全可能都读到同一个值,比如100,然后各自加1,再各自写回101。那这期间的两次递增,实际就只生效了一次。专业点说,这叫“丢失更新”,是典型的竞态条件。
解决思路也很直接:让这三步操作变成一个不可分割的整体。这个“不可分割”,就是原子性。而锁,就是用来保证原子性最常用的手段。把这段代码围起来,同一时间只允许一个线程进入,其他线程排队等待,问题就解决了。
1.2 临界区与竞态条件
在并发编程里,操作共享数据的代码区域叫临界区。竞态条件指的是多个线程同时进入临界区,执行结果依赖于线程调度顺序,导致数据不一致的情况。
打个比方,临界区就像卫生间。卫生间只有一个坑位,不装门锁的话,两个人同时冲进去,那画面就不可控了。装上锁,一个人进去把门反锁,其他人只能在外面排队,等里面的人出来再进去。这就是互斥。
Java里加锁非常简单,synchronized关键字是最原始也最广为人知的一种:
java复制public synchronized void increment() {
count++;
}
一个同步方法加上去,count++ 这段临界区就被保护起来了。
不过这里要注意一个细节:锁保护的是临界区,而不是方法本身。如果你在同步方法里做了大量跟共享数据无关的操作,比如磁盘IO、远程调用、数据库查询,那么锁的范围就被你无意中放大了。其他线程明明不碰这个数据,也必须排队等你做完,吞吐量直接腰斩。所以后面我们聊锁策略,永远要记住一个核心指标:临界区越小,锁的粒度越细,并发度才越高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 悲观锁与乐观锁:先锁后做还是先做后验
2.1 悲观锁的思路与适用场景
如果把锁策略按“如何看待冲突”来划分,第一大类就是悲观锁和乐观锁。
悲观锁的核心假设是“并发冲突大概率会发生”。所以它的做法是:在操作数据之前,先把锁拿到手,让别人进不来,做完再释放。
Java里的synchronized和ReentrantLock都是典型的悲观锁。比如你写一个电商下单接口,扣减库存的时候,如果不用锁,两个用户同时买最后一件商品,就可能出现超卖。用悲观锁就能强制让这两个请求串行执行:
java复制private final ReentrantLock lock = new ReentrantLock();
public void deductStock() {
lock.lock();
try {
// 检查库存是否充足
// 执行扣减
} finally {
lock.unlock();
}
}
数据库层面的 select ... for update 也是同样的思路。查询的时候把相关行锁住,其他事务想改这行就必须等。
悲观锁的优点是实现简单,数据一致性有保障;缺点是并发性能受限。因为只要线程拿不到锁,就必须阻塞等待。阻塞意味着操作系统要挂起线程、再恢复线程,这个上下文切换的成本其实很高。
2.2 乐观锁的CAS与版本号实现
乐观锁正相反,它认为“冲突是少数情况”。所以它不提前加锁,而是先把操作做了,在最后提交结果的时候检查一下,有没有人在这个过程中改了数据。如果改了,那就重试一次。
乐观锁最常见的实现方式有CAS和版本号两种。
CAS,即Compare-And-Swap,比较并交换。它的逻辑是:如果内存中的当前值等于我读到的旧值,说明没人改过,我就把新值写进去;如果当前值不等于旧值,说明有人改过了,操作失败,回到第一步重新读。
Java里的原子类就是基于CAS实现的,比如 AtomicInteger:
java复制AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
底层调用的是 Unsafe.compareAndSwapInt,最终由CPU指令 cmpxchg 保证原子性。整个过程没有加锁,也没有线程阻塞,所以在低竞争场景下性能比悲观锁好很多。
版本号方式在数据库里更常见。比如更新一条数据时,带上版本号条件:
sql复制update product
set stock = stock - 1, version = version + 1
where id = ? and version = ?
如果影响行数为0,说明版本号不匹配,有其他人改过这条数据,你就需要重新查询,再次尝试。
这里面有个值得注意的问题:CAS会产生ABA问题。比如线程A读到值为A,线程B把值改成B又改回A,线程A再去比较的时候发现还是A,就认为没人动过。实际上数据被改了两回。对这个敏感的场景,可以用 AtomicStampedReference,通过版本号来解决,类比成“除了看值,还看修改次数”。
2.3 这两种锁怎么选
选悲观锁还是乐观锁,主要看两个指标:冲突概率和临界区耗时。
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心思想 | 先锁后做,相信冲突很多 | 先做后验,相信冲突很少 |
| 实现方式 | synchronized、ReentrantLock、for update | CAS、版本号 |
| 优点 | 数据一致性强,实现直观 | 无锁竞争,吞吐量高 |
| 缺点 | 线程阻塞,上下文切换开销大 | 并发冲突多时重试成本高 |
| 适用场景 | 写多读少、冲突严重、强一致性要求 | 读多写少、冲突较少、可以容忍偶尔重试 |
举两个我实际碰过的例子。扣库存这种操作,如果并发量非常大,用悲观锁会导致大量线程排队,接口延迟飙升;但很多人认为扣库存必须严格正确,不能超卖,所以宁愿牺牲一点性能,也用悲观锁或数据库行锁。而像统计访问量、记录日志、更新用户最后登录时间这种场景,冲突概率极低,用乐观锁或者原子类就够了,性能好太多。
我的建议是:别一开始就纠结选哪个,先把共享数据列出来,估算一下并发冲突概率,再决定。如果冲突概率小于5%,优先乐观锁;如果超过20%,悲观锁更稳妥;中间地带,那就压测说话。
3. 公平锁与非公平锁:排队还是插队
3.1 公平与非公平的本质区别
锁的第二个常见分类,是公平锁和非公平锁。这个分类主要针对锁的获取顺序。
公平锁的规则很明确:线程获取锁的顺序,严格按照请求锁的顺序。先来的线程优先,后来的线程去排队。这就像银行柜台叫号,不管你多急,都得按号码顺序来。
非公平锁就不管这些了。新来的线程一到,先尝试直接抢锁。如果抢到了,就直接执行,不需要排队。只有抢不到的时候,才会进入等待队列。
Java里 ReentrantLock 提供了一个带布尔参数的构造方法:
java复制ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁,默认就是false
如果你不传参,默认就是非公平锁。synchronized从JVM层面讲,本身也属于非公平锁。
两者的底层实现都依赖AQS(AbstractQueuedSynchronizer)维护的同步队列。公平锁每次获取锁之前,都要检查等待队列里有没有排在我前面的线程;非公平锁则先尝试直接CAS抢锁,抢不到再老实入队。
3.2 为什么很多时候我们默认用非公平锁
很多人会觉得公平锁更“公正”,应该优先使用。但事实上,绝大多数并发框架的默认选择都是非公平锁。
原因在于唤醒线程的代价太高。公平锁要求一个线程释放锁之后,必须唤醒队列里等待最久的下一个线程。这个唤醒过程涉及线程挂起和恢复,也就是一次完整的上下文切换。而如果使用非公平锁,新来的线程正好碰上锁释放,直接就能拿到锁继续执行,完全省掉了唤醒开销。
举个例子,线程A持有锁,线程B、C、D在队列里排队。A释放锁后,按公平锁逻辑,B要被唤醒才能执行。但如果D此时恰好来请求锁,非公平锁模式下D可能直接抢到锁,瞬间就干完活了。抢不到的时候再排队。这一来一回,吞吐量就差出来了。
有同事之前跟我争论这个问题,我给他的建议很简单:如果系统里锁竞争不激烈,非公平锁的吞吐量明显更高,用默认就好。只有在业务上严格要求顺序、并且确实出现线程饥饿时才考虑公平锁。并发量一大,非公平锁的“插队”现象会让极少数线程长时间得不到锁,这是它的代价。
3.3 哪些场景需要公平锁
那有没有必须用公平锁的场景?有。比如任务调度系统,一批任务按优先级分发到工作线程,如果某些任务一直因为插队而得不到执行机会,就会出现饥饿现象。我负责过一个对账单生成服务,多个线程同时往队列里塞任务,然后消费线程去执行。有段时间发现某个客户的对账任务总是延迟,排查到最后,就是因为默认用了非公平锁,部分任务被新任务不断插队,久久排不上。
改成公平锁之后,任务执行顺序稳定了,延迟问题也消失了。但代价是整体吞吐量下降了一截,因为每次获取锁都要检查队列状态,开销比非公平锁高。
这里要说个容易误解的点:公平锁并不是绝对公平。AQS队列里的公平,只是线程在队列内部的排队顺序;线程调度、CPU时间片分配这些,操作系统有自己的策略。所以你指望公平锁解决所有顺序问题,那是想多了。它只能保证“竞争同一把锁时,先到先得”,而不是“线程执行的绝对先后顺序”。
4. 可重入、自旋与锁升级:锁的三个进阶形态
4.1 可重入锁:同一个线程能连续加锁吗
可重入,意思是同一个线程可以重复获取同一把锁,而且不会把自己锁死。
为什么要强调这个?因为现实代码里,同步方法经常互相调用。比如一个类里有两个同步方法,第一个方法内部调用第二个方法。如果锁不可重入,线程进入第一个方法时已经持有锁,再进入第二个方法时就发现“锁还被自己占着”,直接死锁了。
Java里的 synchronized 和 ReentrantLock 都是可重入锁。ReentrantLock的实现方式是给锁加一个持有计数,每获取一次锁,计数加1;每释放一次锁,计数减1。计数减到0,锁才真正释放。看这个例子:
java复制public synchronized void methodA() {
// 业务逻辑
methodB(); // 这里还能直接调用,不会死锁
}
public synchronized void methodB() {
// 业务逻辑
}
这种设计非常实用,否则你在递归方法或者多层方法调用里,就得自己维护锁的状态,代码会变得很难看。面试里问到可重入锁,本质就是在考察你对锁持有状态的理解。
4.2 自旋锁:用CPU时间去换线程切换
线程拿不到锁的时候,通常有两种选择:一种是阻塞自己,等锁释放了再被唤醒,这叫阻塞锁;另一种是在原地循环检查,不停尝试获取锁,这就是自旋锁。
自旋锁的思想是:线程切换的开销很大,如果临界区执行时间很短,锁马上就能释放,那阻塞和唤醒反而亏了。不如让线程先“转几圈”,趁着等锁的时间把临界区跑完。
可以这样理解:在热门餐厅排队,你是在门口站着等叫号(阻塞),还是在附近溜达一会儿再回来问(自旋)?如果排队的人多、等得久,站着等合适;如果马上就到号了,来回溜达几圈再问,省了走远的麻烦。
Java里,synchronized 在轻量级锁阶段会使用自旋。JVM还做了自适应自旋,根据同一个锁上一次自旋成功与否,动态调整下一次自旋的次数。所以你不必手动配置,JVM慢慢会自己总结出“该转几圈就放弃”。
自己写自旋锁也很简单,用AtomicReference:
java复制public class SimpleSpinLock {
private final AtomicReference<Thread> owner = new AtomicReference<>();
public void lock() {
Thread current = Thread.currentThread();
while (!owner.compareAndSet(null, current)) {
// 空转等待,直到成功获取锁
}
}
public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}
但自旋锁有个大坑:单核CPU上不能用。因为线程在自旋等待时占着CPU,其他线程根本没机会运行去释放锁,那自旋就是白白浪费CPU。就算多核环境,如果临界区执行时间太长,自旋也会让CPU飙高。所以自旋锁只适合临界区特别短、锁竞争不激烈的场景。
4.3 偏向锁到重量级锁:JVM做的锁优化
synchronized在Java SE 1.6之前,性能确实一般,所以很多人叫它重量级锁。但1.6之后,JVM对 synchronized 做了一整套锁升级优化:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。这也是面试的高频考点。
偏向锁的设计思路是:大多数情况下,一把锁可能始终被同一个线程获取,根本没有竞争。那能不能让这个线程每次加锁都不用做太多操作?于是JVM在对象头里记录持有锁的线程ID。下次这个线程再来,只需比较一下ID,相同就直接进入,不做CAS。
一旦有其他线程来竞争,偏向锁就撤销,膨胀为轻量级锁。轻量级锁用CAS去抢,抢不到就自旋。自旋也失败了,说明竞争确实很激烈,再膨胀为重量级锁。重量级锁会使用操作系统的互斥量,拿不到锁的线程进入阻塞状态,等待唤醒。
不过大家可能已经发现了:现在很多讨论里,偏向锁的活跃度正在下降。原因也很直接,偏向锁在高并发场景里收益并不大,反而因为撤销偏向、暂停线程这些机制引入额外开销。所以在较新的JDK版本中,偏向锁逐步被禁用甚至移除。如果你在用新版本JDK,不用去纠结怎么开启偏向锁,默认就够用了。
这里我想额外说一句。很多人背锁升级的流程背得很熟,但面试官如果追问“偏向锁撤销为什么成本高”,可能就卡住了。偏向锁撤销时要让持有偏向锁的线程走到安全点,如果这个线程正在执行长任务,JVM得等它,这个等待成本是不可忽略的。理解了这一层,你才真正理解了为什么偏向锁在新时代会被淘汰。
5. 读写锁与分段锁:为读多写少量身定做
5.1 读写锁分离的核心思想
前面讲的锁,都是同一时间只允许一个线程进入临界区。但实际业务里,很多场景是读操作远多于写操作。比如配置中心,99%的请求都在读配置,只有极少数请求在修改配置。如果都用互斥锁,那读线程之间也会互相阻塞,并发能力被白白浪费。
读写锁就是为了解决这个问题。它把锁拆成读锁和写锁:
- 读锁是共享锁,多个线程可以同时持有读锁;
- 写锁是互斥锁,一个线程持有写锁时,其他线程不能持有读锁,也不能持有写锁;
- 读和写之间互斥,防止读到一半的数据。
Java里 ReentrantReadWriteLock 是标准实现。我写过一个简单的缓存工具类:
java复制class MyCache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
public V get(K key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
public void put(K key, V value) {
writeLock.lock();
try {
map.put(key, value);
} finally {
writeLock.unlock();
}
}
}
使用读写锁,读多写少场景下吞吐量提升非常明显。但要注意一个细节:读写锁不是万能的,如果写操作频繁,锁竞争基本都集中在写锁上,性能反而可能不如互斥锁。因为读锁和写锁之间还要做状态转换,维护成本更高。所以在引入读写锁之前,先统计一下读写比例,确认是真正的“读多写少”再用。
再说一个面试常考的进阶点:锁降级。所谓锁降级,是指线程持有写锁时,先把数据读出来,然后获取读锁,再释放写锁。这样保证在写锁释放、读锁持有的过程中,其他线程无法修改数据。目的是提高读操作的并发度,同时保证读到的一致性。ReentrantReadWriteLock支持这种写法,但反过来,读锁升级为写锁是不支持的,搞不好会死锁。
5.2 StampedLock与乐观读
ReentrantReadWriteLock已经不错了,但还有一点遗憾:读锁会阻塞写锁。极端情况下,大量读线程持有读锁,写线程一直在外面等,写饥饿问题就出现了。
JDK 8引入的 StampedLock 提供了一种新的思路:乐观读。乐观读不加真正意义的锁,而是做一次“标记”,然后正常读取数据。读取完以后,验证一下这个标记在读取期间是否被改变过。如果没变,说明数据一致,读取成功;如果变了,再升级为悲观读锁重新读取。
代码大概是这样的:
java复制StampedLock lock = new StampedLock();
public double read() {
long stamp = lock.tryOptimisticRead();
// 读取共享数据
double value = this.data;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
value = this.data;
} finally {
lock.unlockRead(stamp);
}
}
return value;
}
乐观读的好处是,读到一半时,写线程不需要阻塞等待,因为读线程根本没有持有真正的锁。这在整个系统“读多、但偶尔有写”的场景下,效率比ReentrantReadWriteLock还要高。
但用StampedLock要小心几个坑。第一,它不可重入。同一个线程不能对同一把StampedLock反复加锁,否则会死锁。第二,它不支持条件变量,Condition相关操作用不了。第三,如果代码里有中断逻辑,要用 readLockInterruptibly 这类支持中断的方法,不然可能无法响应中断。总之,StampedLock是更底层的工具,适合对性能有极致要求的场景,一般业务代码优先考虑ReentrantReadWriteLock就够了。
5.3 ConcurrentHashMap与分段锁思想
分段锁是降低锁粒度的一种经典思想。以前JDK 7的ConcurrentHashMap,内部维护了多个Segment,每个Segment都继承ReentrantLock。往map里put数据的时候,先根据hash定位到某个Segment,只需要锁住这个Segment,其他Segment的读写不受影响。原理上,就是把一把大锁拆成多把小锁,让不同分段的线程可以并行操作,从而提升了并发度。
JDK 8的ConcurrentHashMap做了一次大改,放弃了Segment,直接用 synchronized 锁每个数组桶(bin)的头节点,配合CAS操作。锁粒度从Segment级进一步细化到桶级。为什么敢直接用synchronized了?因为前面说的锁升级让synchronized在低竞争场景下开销很小。锁基本上没有竞争时,偏向锁或轻量级锁很快就能搞定。
分段锁思想并不局限于Map。我在处理一批大数据量任务时,也用过类似思路:把任务列表拆成多个切片,每个切片一把锁,多线程并行处理不同切片,最后合并结果。这种写法的核心是:锁的粒度越小,并发度越高,但代价是锁数量变多,管理和调试难度也变大。所以分段,不是段数越多越好,要根据实际并发度和数据分布来定。
6. 死锁排查与锁策略决策指南
6.1 死锁的四个必要条件
聊锁策略,不能绕过死锁。死锁是并发里最让人头疼的问题,一旦发生,线程互相等待对方释放锁,谁也没办法继续跑。
死锁必须同时满足四个条件:
- 互斥:资源只能被一个线程占用;
- 持有并等待:线程已经持有一个资源,还想拿另一个资源;
- 不可剥夺:已经持有的资源在没用完之前,不能被其他线程抢走;
- 循环等待:多个线程之间形成环形等待链。
经典的ABBA死锁:
java复制public void transfer() {
synchronized (a) {
synchronized (b) {
// 业务逻辑
}
}
}
public void transfer2() {
synchronized (b) {
synchronized (a) {
// 业务逻辑
}
}
}
线程1执行transfer,先拿锁a,阻塞在等锁b;线程2执行transfer2,先拿锁b,阻塞在等锁a。两边互相等,谁也放不下手里的锁,死锁产生。
解除死锁的办法,就是打破四个条件中的任意一个。最少成本的做法,是统一加锁顺序,让所有线程都按同一顺序获取锁。其次可以用 tryLock 加超时,拿不到锁就不等待,直接失败重试,这相当于打破了“持有并等待”或“不可剥夺”。
6.2 一次线上死锁的排查过程
我之前排查过一次死锁,过程可以分享给你。现象是某个服务的一个接口响应时间突然飙高,然后部分请求直接超时报警。拿到线程快照后,搜索 deadlock 关键字,立刻发现了JVM报告的死锁信息,明确指出了两个线程各自持有的锁和等待的锁。
排查步骤大概是:
jps -l找到目标Java进程ID;jstack PID > dump.log导出线程快照;- 在dump文件里搜索
deadlock,JVM会主动检测并输出死锁链路; - 如果JVM没有检测到,那就看
BLOCKED状态的线程,分析它们各自在等哪把锁; - 定位到具体代码行,梳理锁的获取顺序,找出循环等待的环节。
我当时那个问题,就是两个服务之间互相调用,A服务先锁自己的业务对象再调B服务,B服务先锁自己的业务对象再调A服务。碰上特定时序,两边就卡死了。修复方案也很简单,统一两个服务的加锁顺序,让嵌套调用路径保持一致。另外顺手加了 tryLock 超时兜底,即使将来出现新增的锁路径,也只会拿到异常和重试,不会让线程全部挂死。
这里我劝大家,不要在包里只有jstack的时候才想起来排查。平时就可以写一个脚本,需要时一条命令把线程快照抓下来。线上问题每一分钟都是钱,早抓到dump,早定位问题。
6.3 锁策略速查表
说了这么多,最后给一张锁策略决策表,方便你头脑不清醒的时候快速对照:
| 业务场景 | 推荐策略 | 推荐理由 |
|---|---|---|
| 简单计数器、累加器 | 原子类(CAS) | 无锁,性能最高 |
| 秒杀扣库存、写多读少 | 悲观锁 / 数据库行锁 | 保证强一致,不超卖 |
| 缓存、配置类,读多写少 | 读写锁 / StampedLock | 提高读并发能力 |
| 大量并发读写同一个Map | ConcurrentHashMap | 分段/桶级锁,粒度细 |
| 多个线程竞争少、但对顺序敏感 | 公平锁 | 避免线程饥饿 |
| 临界区极短、竞争低 | 自旋锁 | 避免线程切换开销 |
| 多个线程按固定顺序获取多个锁 | synchronized / ReentrantLock | 可重入,配合统一顺序 |
| 需要获取锁时可能被长期阻塞 | tryLock 超时 | 避免死锁 |
有一个经验我想强调一下:锁策略没有银弹。你以为选了个读写锁就万事大吉,结果业务上写操作密集,反而被读写锁的状态切换拖垮。你以为乐观锁性能好,结果并发冲突率太高,重试次数直接把数据库打爆。任何锁,都要结合你实际的读写比例、临界区耗时、并发量来做选择。
另外补充一个非常实用的习惯:写并发代码时,始终在 finally 里释放锁。ReentrantLock不像synchronized会自动释放,如果加锁后代码抛出异常,锁是绝对不会自动解锁的。这个坑我见过不止一次,有个同事漏写了finally,线上锁一直不释放,最后所有线程全部卡死,服务彻底不可用。
我在实际项目中还有一个体会:锁的范围一定要小,锁内永远不要做耗时操作。锁内做远程调用,这次锁就会被长时间占用,其他线程排队时间越来越长,最终从毫秒级延迟变成秒级超时。我在优化接口性能时,第一件事就是看锁内代码,把非共享数据的操作全部挪到锁外面。
最后分享一个我觉得很有用的小习惯:给锁命名。ReentrantLock构造时可以传一个名字(new ReentrantLock("orderLock")),好的名字可以让你在jstack线程快照中一眼认出是哪把锁。线上排查时的体验差距,真的就是从这种小细节开始的。
