悲观锁和乐观锁有什么区别?分别适用什么场景?

#Java#面试八股

悲观锁和乐观锁有什么区别?分别适用什么场景?

结论先行:悲观锁(pessimistic lock)假设冲突一定会发生,操作前先加锁,让别人排队;乐观锁(optimistic lock)假设冲突很少,先改、提交时再校验有没有被别人改过,冲突了重试。前者用阻塞换安全,后者用重试换吞吐。

1. 对比表

维度悲观锁乐观锁
核心假设冲突频繁,先锁再说冲突稀少,改完再验
实现手段synchronized、ReentrantLock、select ... for updateCAS、版本号字段、StampedLock 乐观读
冲突时的行为阻塞等待(或超时失败)检测到版本变化,失败重试
开销分布加锁/解锁固定开销,冲突时线程切换无锁开销,冲突时浪费一次计算
适用并发度写多、冲突高读多写少、冲突低
是否可能饥饿可能(非公平锁)可能(长事务持续失败)

2. 两条执行路径

悲观锁:
  T1 加锁 ──▶ 读数据 ──▶ 修改 ──▶ 写回 ──▶ 解锁
                                   T2 全程阻塞等待 ──▶ 拿到锁再重复一遍

乐观锁:
  T1 读 (id=1, version=3) ──▶ 计算 ──▶ UPDATE ... SET v=?, version=4
                                        WHERE id=1 AND version=3
  T2 同样从 version=3 开始 ──▶ UPDATE 影响行数 = 0 ──▶ 重试或报错

乐观锁的关键在于那条 UPDATE 语句:判断和更新合成一个原子操作,影响行数为 0 就代表"我读到的数据已经过期"。

3. 三种典型写法

// 1. 悲观锁:JVM 层面互斥
public synchronized void deduct(long amount) {
    if (balance >= amount) balance -= amount;
}

// 2. 乐观锁:CAS 自旋重试
public void deductCas(AtomicLong balance, long amount) {
    long expect, update;
    do {
        expect = balance.get();
        if (expect < amount) throw new IllegalStateException("余额不足");
        update = expect - amount;
    } while (!balance.compareAndSet(expect, update));   // 失败说明被人改过,重来
}

// 3. 乐观锁:数据库版本号(最常用)
// UPDATE account SET balance = balance - #{amount}, version = version + 1
//  WHERE id = #{id} AND version = #{version} AND balance >= #{amount}
int rows = mapper.deduct(id, amount, version);
if (rows == 0) {
    // 冲突:重试、提示用户刷新,或抛业务异常
}

数据库层面还有一个悲观锁写法:SELECT ... FOR UPDATE,它会对行加排他锁直到事务结束,并发度低但语义最直接。

4. 怎么选

场景选择理由
库存扣减、秒杀乐观锁(版本号)+ 限流冲突集中在少数热点行,重试成本可控
账户余额、强一致账务悲观锁(for update)不允许重试带来的语义歧义
读多写少的配置缓存乐观锁 / 无锁冲突概率极低,加锁纯属浪费
高冲突的计数器LongAdder(分段)比 CAS 自旋更抗竞争
长事务里的复杂判断悲观锁乐观锁重试会放大事务代价

5. 常见追问

追问一:乐观锁失败后怎么办? 三条路:重试(有限次数,加退避)、快速失败(返回"请重试"给前端)、降级为悲观锁。无限重试是最危险的写法——高冲突时线程全在空转,CPU 打满但吞吐为零。

追问二:版本号和 ABA 是什么关系? 版本号天然解决 ABA:即使值改回原样,version 也已经递增,WHERE version = 3 不会再命中。如果只用值做条件(WHERE balance = 100),就会出现"A→B→A"被误判为没变的情况。CAS 的 ABA 问题同理,用 AtomicStampedReference 加版本戳解决。

追问三:乐观锁能完全替代悲观锁吗? 不能。冲突率高时,乐观锁的重试次数呈指数上升,总开销会超过一把锁;而且重试意味着业务逻辑要可重放,不是所有操作都能安全重放(比如发消息、扣积分)。

追问四:Java 里的乐观读是什么? StampedLock.tryOptimisticRead():读取前拿一个 stamp,读完用 validate(stamp) 检查期间有没有写操作,没有就直接用数据(全程无锁);有则退化成悲观读。它把"读"的代价压到接近零。

小结

悲观锁是"先上锁后办事",乐观锁是"先办事后校验"。选择的分界线是冲突概率:低冲突用乐观锁省掉阻塞,高冲突用悲观锁省掉重试。工程上最常见的组合是——数据库版本号 + 有限重试 + 失败快速返回。

笔记加载中…