上个月值班的时候,线上一个库存扣减接口突然变慢,平均响应时间从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.i
