1. 锁的核心逻辑:你到底在防谁
前几天群里一位朋友发了个多线程累加计数的Demo,跑了几次结果都不对。他用了 synchronized,还加了 volatile,我问他你确定锁加对地方了吗,他说加了关键字不是就行了吗。这就是典型的没把锁策略想清楚——你要防的是多个线程同时读写同一个共享变量造成的竞态条件,不是单纯加个关键字就完事。
锁策略这个词听起来特别正式,其实本质就一句话:在多线程并发访问共享资源时,你选择用什么样的机制来保证数据的一致性。这背后是一整套权衡——是用乐观锁还是悲观锁、是公平的还是非公平的、锁的粒度要多大、锁内代码执行多久合适。选错了策略,轻则性能拉胯,重则死锁、数据错乱,线上事故直接找上门。
我见过太多人把 synchronized 当万能药,也见过有人上来就用 ReentrantReadWriteLock 结果性能更差。锁策略这个主题几乎出现在所有并发编程面试题、实际项目性能调优和线上问题排查中。这篇文章我把自己在实际项目中用过、踩过坑的锁策略方案,从原理到选型到实操完整梳理一遍。适合正在写多线程代码的开发者、准备面试的人,以及已经在排查并发问题但思路还不太清晰的朋友。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常见锁策略逐个拆解
要把锁策略说透,得先搞明白它解决什么问题。多线程之所以出幺蛾子,根源在于多个线程同时操作共享数据时存在“读改写”三步操作不是原子的。锁就是用来保证这个“原子性”的。但保证原子性的方式五花八门,每种方式都有自己的适用场景和代价,于是就有了各种各样的策略。
2.1 乐观锁与悲观锁:面对冲突的两种心态
这是最基础的二分法,也是面试必问的问题。悲观锁的心态是“这数据肯定会被别人改”,所以我在读的时候你谁也别想动,直接加锁把资源锁死。数据库里的 select for update、Java 里的 synchronized、C++ 里的 std::mutex,都是典型的悲观锁实现。
乐观锁的心态相反——"这数据大概率没人跟我抢,我先直接干,提交的时候再检查有没有人动过"。最经典的实现方式就是 CAS(Compare And Swap)机制,在 Java 里 AtomicInteger 的 incrementAndGet() 底层就是 CAS:先读出当前值,计算新值,在写入前比较内存里的值是否还是刚才读到的那个值,如果一致就写入,不一致就重试。
注意:CAS 并不是完全无锁,它只是把锁从“阻塞别的线程”变成了“让当前线程自旋重试”。本质上还是用 CPU 时间换阻塞唤醒的开销。
选乐观锁还是悲观锁,核心看冲突概率。写操作很少、读操作很多、冲突不频繁的场景,乐观锁(CAS)能避免线程挂起和唤醒的巨大开销,性能非常好。但如果写操作特别频繁,比如秒杀场景里大家都来扣减库存,CAS 会反复失败、反复自旋,CPU 空转严重,这时候反而退化成了一种“更昂贵的锁”。我在一个库存扣减接口上亲测过,热点商品高并发下 CAS 自旋次数飙升,GC 时间变长,换成悲观锁配合数据库行锁反而更稳。
2.2 公平锁与非公平锁:排队还是插队
公平锁的意思是按照线程申请锁的顺序来获取锁,先来先得,像排队买奶茶。非公平锁则允许“插队”——新来的线程可能直接抢到锁,而排在队伍前面的线程还在睡觉。
Java 的 ReentrantLock 默认是非公平锁,构造方法传 true 可以创建公平锁。synchronized 则只能是非公平的(JVM 层面实现就是这样)。
非公平锁为什么是默认?因为公平锁的“排队”需要线程切换上下文,频繁地唤醒等待线程,这本身有成本。非公平锁让新线程先抢占一下,如果刚好抢到就能省掉一次唤醒开销,整体吞吐量更高。代价是可能造成“线程饿死”——排在后面的线程一直抢不到锁。
我在实际项目中就这么用:批量任务调度器里多个 worker 共享一个任务队列,为了保证任务分配均匀,我用 ReentrantLock(true) 公平模式;但普通的业务接口,比如用户查询,直接默认非公平锁就行,吞吐量更重要,而且业务上根本不关心谁先谁后。公平锁不是银弹,如果竞争不激烈,公平锁带来的排队开销反而让性能变差。
2.3 可重入锁与不可重入锁:同一个线程能否再次拿锁
可重入锁(Reentrant Lock)的意思是,同一个线程在已经持有锁的情况下,可以再次获取同一把锁而不会把自己卡死。synchronized 和 ReentrantLock 都是可重入的,C++ 里 std::recursive_mutex 也是。
为什么可重入很重要?看个场景:
java复制public synchronized void methodA() {
// 做一些事
methodB();
}
public synchronized void methodB() {
// 这个方法也需要锁
}
如果锁不可重入,methodA 调用 methodB 时,当前线程发现自己已经持有锁,但不可重入锁不允许再次获取,于是直接死锁。可重入锁的设计让这种嵌套调用在单线程内是安全的,因为锁记住了持有者线程和持有次数。递归场景下这个特性更是救命。
不可重入的锁也存在,比如自旋锁如果实现得不好就不会处理重入,C++ 里 std::mutex 也不可重入(用 std::recursive_mutex 专门解决这个问题)。实际开发中我几乎不需要自己实现锁,但理解可重入性是必要的:如果你在锁内调用了另一个加锁的方法,而两把锁不正交,很容易出现意外的死锁。尤其是 A 线程持锁 1 去拿锁 2,B 线程持锁 2 去拿锁 1,这种情况面试最爱考,线上排查也最爱见。
2.4 共享锁与独占锁:读读不互斥,写写才互斥
共享锁(读锁)允许多个线程同时持有,适合“大家都只是读”的场景。独占锁(写锁)同一时刻只能一个线程持有,适合写操作。ReentrantReadWriteLock 就是同时提供这两种锁的经典实现:读锁之间不互斥,读锁与写锁互斥,写锁与写锁互斥。
“读多写少”的缓存场景是读写锁最典型的用武之地。比如一个配置中心的本地缓存,配置基本不变,但每秒可能有几十万次读取。如果用 synchronized 包住整个“读缓存”的方法,所有读线程全部串行,性能直接崩。用读写锁之后,并发读互不阻塞,只有写的时候才独占,吞吐量能提升一个数量级。
但我要泼一盆冷水:读写锁不是用了就一定快。如果写操作特别频繁,写锁不停抢占,读锁会被频繁阻塞,而且因为写锁优先级通常更高(这是为了避免写线程饿死),极端情况下读线程大量阻塞,性能可能还不如简单的互斥锁。所以读写锁的关键在于“写”的比例——我一般会在写占比低于 10% 的场景才考虑用它。另一个需要注意的就是“锁降级”:持有写锁时获取读锁,然后释放写锁,这能保证当前线程能继续读到它刚写的数据,JDK 官方文档里推荐了这种用法,但很多人没注意。
2.5 自旋锁:用 CPU 时间去等锁
自旋锁是乐观锁的极端形态——线程在拿不到锁时不会阻塞挂起,而是“原地打转”不断尝试获取锁,直到成功。JVM 的 synchronized 在 JDK 1.6 之后引入了“适应性自旋”:如果上次自旋成功,下次多自旋一会儿;如果最近经常失败,直接放弃自旋转阻塞。
自旋锁的优势是避免了线程阻塞和唤醒时两次上下文切换的开销。但缺点也明显:自旋本身占用 CPU,如果锁被持有时间很长,自旋的线程就是在空烧 CPU。Java 里 ConcurrentLinkedQueue、LongAdder 的底层都大量使用 CAS 自旋;C++ 里可以用 std::atomic_flag 配合循环自己实现一个简单的自旋锁。
我在高并发场景里统计过:锁内代码执行时间如果只有几十纳秒,自旋锁比阻塞锁快几倍;但一旦锁内操作超过几微秒,比如发生了一次 IO,自旋锁就是灾难。所以我在代码里看到有人直接在锁内做网络请求、数据库查询,都会提醒他:锁内代码越短越好,任何可能阻塞甚至超时的操作都不要放进锁里,否则你就是在给并发性能埋雷。
2.6 JVM 对 synchronized 的调优:偏向锁到重量级锁
JDK 1.6 之前 synchronized 就是重量级锁的代名词,性能差。1.6 之后 JVM 做了重大优化,引入了锁升级机制:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。
偏向锁的核心逻辑是:如果一段代码只有一个线程反复访问,锁根本没有竞争,干脆让这个“偏向”的线程每次进来直接获得锁,连 CAS 都省了。一旦出现第二个线程竞争,偏向锁撤销,升级为轻量级锁。轻量级锁用 CAS 操作来获取锁,如果竞争再激烈,CAS 失败次数多了,就膨胀为重量级锁,走操作系统互斥量。
这个机制本质上是 JVM 帮我们做了一层“锁策略的动态调整”:根据实际竞争情况,从乐观到悲观自动切换。这给我一个很重要的启发——锁策略不是写死了就完事,而是应该根据运行时的竞争情况去适配。你以为的“写死一把重锁”在低竞争场景下其实运行在偏向锁模式,根本不慢;而高竞争下即使你用了乐观锁,底层也会因为大量自旋而退化。
2.7 分段锁与锁分离:提升并发度的两种思路
分段锁的思想是把一个大锁拆成多个小锁,每个小锁管一部分数据,线程只需要锁住它操作的那一段。Java 8 之前的 ConcurrentHashMap 就是分段锁的经典实现,内部维护多个 Segment,每个 Segment 是一把独立的锁,默认 16 个段,所以理论上并发度是 16。Java 8 之后改成 CAS + synchronized 锁住单个节点,更进一步降低了锁粒度。
锁分离是另一个思路,典型代表就是 ConcurrentLinkedQueue 用的“队首锁 + 队尾锁”分离——入队只锁 tail,出队只锁 head,两个线程一个入队一个出队可以同时进行,互不干扰。读写锁其实也是锁分离思想:把“读”和“写”的竞争分离开。
实际项目里,我自己设计过一个基于内存的订单号发号器:如果用一个全局锁,所有请求串行,TPS 上不去。后来我把号段按业务维度拆分——不同的订单类型走不同的号段,每个号段有自己的锁,TPS 直接提升了几倍。这就是分段锁思路在业务层面的落地。锁粒度越小,并发度越高,但代码复杂度也越高,这中间的平衡要拿捏好,别一上来就整花活。
3. 实操选型:不同并发场景的锁怎么落地
理解了策略原理,下一步就是具体到代码里怎么选。我总结了自己在项目中比较常用的几个案例,每一个都有踩坑记录。
3.1 计数器场景与多线程累加:从 synchronized 到 LongAdder
最经典的多线程累加场景。假设你要统计网站每天的 PV,或者某个接口的调用量。最简单的写法:
java复制public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
count++ 在字节码层面是“读-加-写”三步,不锁定就会有竞态问题。用 synchronized 锁住整个方法,简单可靠,但高并发下性能一般。如果只是计数,更好的选择是用原子类:
java复制AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
AtomicInteger 底层是 CAS 乐观锁,在低竞争场景下比 synchronized 快不少。但问题来了——如果这个计数器的更新频率特别高,比如每秒几百万次的累加,CAS 自旋会让 CPU 飙升,这时候 LongAdder 才是正解。
LongAdder 的原理有点类似分段锁:它内部维护了一个 Cell 数组,每个 Cell 可以独立累加,最终求和时把所有 Cell 加在一起。多线程写入时分散到不同的 Cell 上,减少了 CAS 竞争。我用它做流量统计的计数器,相比 AtomicInteger,在 16 线程同时高频累加的场景下性能提升了 3 倍以上。
实操心得:计数类需求优先考虑
LongAdder,尤其是写多读少的统计场景。如果读的频率也很高,每次sum()都需要遍历所有 Cell,性能可能反而不如AtomicInteger。我的做法是:统计维度上每 5 秒才读一次汇总值,平时只写入,LongAdder完美匹配。
3.2 缓存读写场景:读写锁的正确与错误用法
假设你要实现一个简单的本地缓存,读多用少:
java复制private final Map<String, Object> cacheMap = new HashMap<>();
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
public Object get(String key) {
rwLock.readLock().lock();
try {
return cacheMap.get(key);
} finally {
rwLock.readLock().unlock();
}
}
public void put(String key, Object value) {
rwLock.writeLock().lock();
try {
cacheMap.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
}
这个写法本身没错,但有一个关键问题:如果缓存里没有 key,需要去数据库加载数据回填缓存,这个“读后写”的过程不能直接放在读锁里。如果你在读锁里发现缓存为空,然后释放读锁去查库,再拿写锁写回,这中间会有大量线程重复加载同一份数据——这就是缓存击穿。正确的做法之一是使用“锁降级”:
java复制public Object getWithLockDowngrade(String key) {
Object value = cacheMap.get(key);
if (value != null) {
return value;
}
// 拿写锁强制刷新
rwLock.writeLock().lock();
try {
// 二次检查,避免重复加载
value = cacheMap.get(key);
if (value == null) {
value = loadFromDB(key);
cacheMap.put(key, value);
}
// 锁降级:获取读锁再释放写锁
rwLock.readLock().lock();
} finally {
rwLock.writeLock().unlock();
}
try {
return value;
} finally {
rwLock.readLock().unlock();
}
}
这个代码看起来很绕,但你仔细体会就会发现:锁降级保证了一个线程在持有写锁的情况下拿到读锁,释放写锁后读锁仍然有效,这样其他写线程不能插进来修改数据,当前线程又能持续读取到自己刚写入的值。JDK 的 ReentrantReadWriteLock 文档明确推荐了这个用法,但实际项目中我见过不少团队根本没这么干,导致缓存击穿发生时数据库被压垮。
注意:Java 8 之后出现的
StampedLock提供了更细粒度的“乐观读”,在读操作时先不加锁直接读,读完之后检查版本号,如果版本号变化再去获取读锁。它在读多写少且一致性要求不是极端严格的场景下,性能比ReentrantReadWriteLock更好。但StampedLock不可重入,使用时要非常小心,而且在锁内不能调用其他会获取锁的代码。
3.3 线程池与队列:锁的选择影响任务调度性能
Java 的 ThreadPoolExecutor 底层用了 BlockingQueue 来存放任务,而不同的队列实现内部锁策略完全不同。LinkedBlockingQueue 使用两把锁(takeLock 和 putLock),一个入队一个出队互不干扰;ArrayBlockingQueue 只有一把锁,入队和出队必须互斥。
如果你在创建线程池时用了 ArrayBlockingQueue,在高并发任务提交场景下,提交线程和消费线程会互斥竞争同一把锁,吞吐量受限制。而 LinkedBlockingQueue 的“锁分离”设计允许同时入队和出队。这也是 Executors.newFixedThreadPool() 默认使用 LinkedBlockingQueue 的原因之一。
不过要注意:队列不只是锁的问题。SynchronousQueue 本身没有存储容量,每个任务提交必须等待一个线程空闲来取走,它的实现是 CAS 自旋 + 阻塞的组合策略,适合处理并发性要求高、但任务本身很快的场景。
我在一个网关服务里就踩过这个坑:当初为了控制线程数,用了 ArrayBlockingQueue(1000),压测时发现任务提交延迟很高,排查了半天发现队列的锁竞争太激烈。换成 LinkedBlockingQueue 后,提交和消费不再互相阻塞,P99 延迟降了 40%。这个案例让我深刻理解了“锁分离”在真实系统里的价值。
3.4 并发容器里的锁策略:ConcurrentHashMap 的演进
讲锁策略永远绕不开 ConcurrentHashMap。Java 7 的分段锁和 Java 8 的 CAS + synchronized,正好对应了两种锁优化思路。
Java 8 的做法是:数组元素为空时,用 CAS 直接写入,无锁操作;如果某个槽位不为空,则对这个 Node 加 synchronized 锁,再操作链表或红黑树。这把锁的粒度缩小到“单个 bucket”,而不是整张表或某个 Segment。扩容时的迁移操作也用了多个线程协助,降低停顿时间。
从这里我能学到一个通用策略:“能无锁就无锁,必须上锁就尽量缩小锁的范围”。这也是我从各种并发框架里看到的最频繁出现的优化思路。你在设计自己的并发数据结构时,可以先思考能不能用 CAS 完成原子更新,如果不能,再考虑对数据结构的不同部分施加不同的锁,而不是一把大锁罩住全部。
4. 常见问题与排查技巧实录
锁这个东西,平时不出问题岁月静好,一出问题就是线上事故。下面这几种情况是我在工作中反复遇到过的,也是排查并发问题时的重灾区。
4.1 死锁的经典场景与排查方法
死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。实际代码里最典型的就是“两个线程按不同顺序获取两把锁”:
java复制// 线程A
synchronized (lockA) {
Thread.sleep(10);
synchronized (lockB) { ... }
}
// 线程B
synchronized (lockB) {
Thread.sleep(10);
synchronized (lockA) { ... }
}
两个线程各持有一把锁,都在等对方释放另一把锁,程序就卡死了。排查这种问题最快的办法是拿到进程的线程转储。Java 项目直接用 jstack 命令:
bash复制jstack <pid> > thread_dump.txt
然后搜索 deadlock 关键词。JVM 会自动检测死锁循环并报告具体的线程 ID、锁对象和栈帧,一清二楚。C++ 项目可以用 gdb attach 进程后执行 thread apply all bt 来看线程栈,或者用 TSan(ThreadSanitizer)在编译期加 -fsanitize=thread 做检测。
预防死锁的核心策略只有一个:统一锁的获取顺序。所有线程都先拿 lockA 再拿 lockB,循环等待就不存在了。另外 ReentrantLock 提供了 tryLock(timeout) 方法,拿锁时设置超时时间,超过时限就放弃并回滚,避免无限期等待。我通常会在可能产生死锁的代码里加这个机制,作为最后的兜底。
4.2 锁竞争激烈导致性能瓶颈
锁竞争激烈的表现是:CPU 使用率很高(线程在自旋或者频繁上下文切换),但业务吞吐量上不去。排查时我用 jstat 或 JFR 看线程状态,如果大量线程处于 RUNNABLE 但长时间没有进展,或者大量线程处于 BLOCKED 状态,基本可以断定是锁竞争问题。
优化手段按优先级排列:
- 减少锁内代码:把耗时的 IO 操作、外部调用全部移出锁,只保留必要的共享变量操作。
- 降低锁粒度:把一个全局锁拆成多个分段锁或独立锁。
- 锁分离:读写分离,或者用
ConcurrentHashMap、LongAdder这类专门设计过的并发容器替代手动加锁。 - 无锁化:用 CAS、
ThreadLocal、Copy-On-Write 等无锁或近乎无锁的方案替代传统锁。
我在实际项目里见过一个典型的反面教材:有人把 N 个业务操作全塞进一个 synchronized 方法里,里面还带 Redis 调用和远程服务调用,压测一上来 CPU 直接 100%,但 QPS 不到 200。优化后锁内只剩一条 map 的 put 操作,其他全部移出去,QPS 直接涨到 5000+。锁内的代码越短,并发性能越好,这是并发优化的第一课。
4.3 锁的误用与"三反"经验
误用锁的情况太多了,我总结出三个最容易犯的错误,姑且叫“三反经验”。
第一,反粗粒度:有人为了省事,直接给整个方法加 synchronized,表面上代码简单了,但并发完全退化成了串行。正确思路是只锁操作共享数据的那几行。Java 的 synchronized 加在方法上等价于锁住 this,整个对象的所有同步方法全部互斥,锁范围极大。
第二,反锁内做阻塞操作:锁内调用远程接口、执行 Thread.sleep()、做数据库查询,这些操作会让其他线程干等超长时间。如果被卡住的线程又持有其他锁,还可能引发连环死锁。我在代码评审时看到锁内出现 IO 操作,一定会打回去重写。
第三,反锁持有时间不可控:比如拿到锁之后走一个循环,循环次数不确定,锁的持有时间就无法估算。更好的做法是事先准备好数据再拿锁,或者用 tryLock 限制等待时间。
另外提一个 Python 特有的情况,Python 的多线程受限于 GIL(全局解释器锁),CPU 密集型任务用多线程其实是被 GIL 串行化的,所以 Python 里实现“伪并发”时锁策略的思维模型完全不同。如果你用 threading.Lock 保护共享变量,在 GIL 下依然能保证正确性,但想靠多线程提升 CPU 密集型任务的性能是没戏的——这种情况要考虑 multiprocessing 而不是锁。分清语言和运行时的边界,也是锁策略的一部分。
4.4 锁策略选型速查表
| 场景特征 | 推荐策略 | 反例警示 |
|---|---|---|
| 写操作少、读操作极多、冲突少 | 乐观锁 / CAS / StampedLock 乐观读 | 全程阻塞锁导致读吞吐暴跌 |
| 写操作频繁、冲突激烈 | 悲观锁 / synchronized / ReentrantLock | CAS 自旋空转烧 CPU |
| 读多写少、读性能要求高 | ReentrantReadWriteLock 读写分离 | 写频繁时读线程被大量阻塞 |
| 多个线程按顺序处理 | 公平锁 | 非公平锁可能造成线程饿死 |
| 锁内代码极短(纳秒级) | 自旋锁 / 适应性自旋 | 锁内操作耗时过长导致 CPU 烧满 |
| 高频计数器统计 | LongAdder / 分段累加 | 用全局锁导致严重竞争 |
| 高并发线程池任务排队 | LinkedBlockingQueue(双锁分离) | ArrayBlockingQueue 单一锁导致竞争 |
| 嵌套或递归调用场景 | 可重入锁 | 不可重入锁造成自己锁死自己 |
这张表不是教条,是让我在写代码前快速做决策的检查清单。每次有并发需求时,都先对着看一遍,至少能避开 80% 的常见坑。
5. 最后再分享几个实战心得
锁策略学到后面,你会发现它其实不是“哪种锁最好”的问题,而是“给当前并发场景找到最不亏的方案”的问题。我经历了几次线上事故之后,把经验压缩成了几条原则,写代码时反复提醒自己。
第一条,先弄清读多还是写多。这个会直接决定你走乐观还是悲观、要不要读写分离。判断依据可以是业务语义,也可以是压测数据。
第二条,永远把锁的范围缩到最小。锁内只保留对共享变量的操作,其他全部移出去。这一点怎么强调都不过分。
第三条,能不用锁就不用锁。优先考虑 ThreadLocal、不可变对象、Copy-On-Write 这类方案,它们连锁都不用。实在要用,再考虑并发容器、原子类、读写锁,最后才是 synchronized。
还有一个小技巧是:加锁代码务必把 unlock 放在 finally 块里,或者直接用 try-with-resources 风格的封装,否则一旦锁内抛异常,锁永远不会释放,线上直接就是一个隐蔽的“锁泄漏”,排查起来极难。
多线程和锁策略是那种“理论上全懂,实战全懵”的领域。我自己也是踩过无数坑才慢慢建立起这套判断框架。如果你现在正在被并发问题折磨,别慌,拿着上面的速查表对照一下你的场景,再想想“冲突多不多、读多还是写多、锁内操作长不长”,大概率能找到方向。并发编程没有银弹,但有方法论——把方法论固化下来,你已经赢过大多数只靠直觉写锁的人了。
