悲观锁与乐观锁:并发控制的两种核心策略
在多线程环境中,为保证共享资源的一致性,需通过同步机制控制并发访问。悲观锁与乐观锁是两种截然不同的设计思想,分别适用于不同的并发场景。理解它们的原理、适用场景及优缺点,是设计高效并发系统的基础。
悲观锁(Pessimistic Locking)
核心思想
总是假设最坏情况:每次操作共享资源时,都认为其他线程会同时修改该资源,因此必须先加锁,阻止其他线程访问,直到自己操作完成并释放锁。
实现方式
- 数据库层面:行锁、表锁、读锁(S 锁)、写锁(X 锁)等,如
SELECT ... FOR UPDATE
会对查询行加排他锁。 - Java 层面:
synchronized
关键字:隐式加锁,自动释放锁。ReentrantLock
:显式加锁(lock()
)和释放锁(unlock()
),支持可重入、中断等特性。
工作流程
- 线程访问共享资源前,先尝试获取锁(如
synchronized
块进入时); - 若获取成功,独占资源并执行操作,期间其他线程会被阻塞(进入
BLOCKED
状态); - 操作完成后释放锁(如
synchronized
块退出或unlock()
),阻塞线程被唤醒并重新竞争锁。
优缺点
优点 | 缺点 |
---|---|
实现简单,逻辑直观 | 加锁 / 解锁涉及用户态与内核态切换,开销大 |
能保证所有操作的原子性 | 线程阻塞会导致 CPU 利用率降低,吞吐量下降 |
适用于写操作频繁的场景 | 可能引发死锁(如锁顺序不一致) |
典型应用场景
- 写操作频繁的场景(如库存扣减、转账交易),避免并发修改导致的数据不一致;
- 临界区代码执行时间较长的场景(如复杂的业务逻辑),悲观锁可减少无效的重试开销。
乐观锁(Optimistic Locking)
核心思想
总是假设最好情况:每次操作共享资源时,都认为其他线程不会同时修改该资源,因此不加锁直接访问。但在更新资源时,会判断期间是否有其他线程修改过该资源,若未被修改则更新,否则重试或放弃。
实现方式
乐观锁的核心是冲突检测,常见实现方式有两种:
版本号机制
- 为资源添加一个版本号(如数据库表的
version
字段),每次更新时版本号递增; - 流程:
- 读取资源时,记录当前版本号(
v
); - 更新资源时,检查版本号是否仍为v:
- 若是,更新资源并将版本号改为
v+1
; - 若否,说明资源已被修改,放弃更新或重试。
- 若是,更新资源并将版本号改为
- 读取资源时,记录当前版本号(
示例(数据库):
1
2
3
4-- 更新时检查版本号
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 3; -- 仅当版本号为3时更新- 为资源添加一个版本号(如数据库表的
CAS 算法(Compare-And-Swap)
- 一种无锁原子操作,通过硬件指令保证原子性,核心涉及三个参数:
V
:共享变量的内存地址;A
:预期值(线程读取到的旧值);B
:要更新的新值。
- 逻辑:若内存中
V
的值等于A
,则将V
改为B
,返回成功;否则返回失败(不修改)。
示例(Java AtomicInteger):
- 一种无锁原子操作,通过硬件指令保证原子性,核心涉及三个参数:
1 | AtomicInteger count = new AtomicInteger(0); |
优缺点
优点 | 缺点 |
---|---|
无锁操作,避免线程阻塞 | 冲突频繁时,重试会消耗大量 CPU 资源 |
适用于读操作频繁的场景 | 只能保证单个变量的原子性,无法直接用于代码块 |
吞吐量高,并发性能好 | 存在 ABA 问题(可通过版本号解决) |
典型应用场景
- 读操作频繁、写操作较少的场景(如商品详情页浏览、用户信息查询);
- 低冲突场景(如计数器、序号生成器),乐观锁可减少锁竞争开销。
悲观锁与乐观锁的核心区别
维度 | 悲观锁 | 乐观锁 |
---|---|---|
加锁时机 | 操作前加锁,全程独占资源 | 操作前不加锁,更新时检测冲突 |
线程状态 | 冲突时线程阻塞(BLOCKED) | 冲突时线程自旋重试(RUNNABLE) |
底层依赖 | 操作系统的锁机制(如互斥量) | 硬件指令(如 CAS)或版本号逻辑 |
适用场景 | 写多读少,冲突频繁 | 读多写少,冲突较少 |
典型实现 | synchronized、ReentrantLock、数据库行锁 | Atomic 类、版本号机制、ConcurrentHashMap |
解决乐观锁的缺陷
1. ABA 问题
问题:变量值从A
变为B
再变回A
,CAS 会误认为未被修改,导致更新错误。
解决:
引入版本号(如AtomicStampedReference),每次修改时版本号递增,更新时同时检查值和版本号;
1
2
3
4
5// 初始化:值为100,版本号为1
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 1);
int stamp = ref.getStamp(); // 获取当前版本号
// 仅当值为100且版本号为stamp时,更新为200并递增版本号
boolean success = ref.compareAndSet(100, 200, stamp, stamp + 1);
2. 自旋开销过大
问题:高并发下 CAS 重试失败次数多,导致 CPU 空转。
解决:
- 限制重试次数(如重试 3 次后放弃);
- 结合自适应自旋(如 JVM 的轻量级锁,根据历史重试情况动态调整自旋次数);
- 高冲突场景下改用悲观锁(如
LongAdder
在高并发时自动切换策略)。
3. 多变量原子性
问题:CAS 仅支持单个变量的原子操作,无法保证多个变量的原子性。
解决:
- 使用
AtomicReference
封装多个变量为对象,通过 CAS 更新对象引用; - 复杂场景下结合锁机制(如先用乐观锁尝试,失败后降级为悲观锁)。
v1.3.10