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::mut
