悲观锁和乐观锁不是两个具体的锁实现,而是两种应对并发冲突的策略:悲观派先锁住再操作,乐观派先操作、提交时再校验。理解这对策略的关键不在背定义,而在搞清楚「冲突频率」和「临界区时长」如何决定谁更划算。

它解决什么问题

并发修改同一份数据时,必须防止两个线程基于同一份旧值各自计算、互相覆盖。解决思路只有两类:

  • 悲观锁:认定冲突大概率发生,访问前先拿到独占许可,把其他人挡在门外,操作完再放行。共享资源同一时刻只服务一个线程,其余阻塞等待。
  • 乐观锁:认定冲突是小概率事件,不加锁直接干活,只在提交时检查「我基于的版本还是不是最新」,是则提交,否则重来。

两种思路在不同层面有完全同构的实现:Java 里 synchronized、ReentrantLock 是悲观派,CAS 原子类是乐观派;数据库里 SELECT ... FOR UPDATE 是悲观派,version 字段校验是乐观派。所以这是一个可以贯穿应用层与存储层的通用模型。

核心原理

悲观锁:先取锁,再办事

Java 中最常见的悲观锁就是内置锁和显式锁:

public synchronized void deduct(long amount) {
balance -= amount; // synchronized:进入前必须持有 monitor
}

private final Lock lock = new ReentrantLock();

public void deductWithLock(long amount) {
lock.lock();
try {
balance -= amount; // ReentrantLock:手动配对加解锁
} finally {
lock.unlock();
}
}

悲观锁的代价来自「等待」:抢不到锁的线程被挂起,涉及上下文切换,竞争越激烈开销越大;持锁线程若崩溃且未释放(显式锁忘写 unlock),还可能造成永久阻塞;多把锁顺序不当则引发死锁。但它换来的是确定性——只要拿到锁,临界区内的操作不会被任何人打断。

乐观锁:先办事,提交时再对账

乐观锁有两种主流实现。

版本号机制:给数据加一个单调递增的版本字段。读取时记下版本号,提交时要求「当前版本仍等于我读到的版本」才允许写入,写入同时把版本号加一。典型场景是数据库更新:

UPDATE account
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;

若影响行数为 0,说明在读与写之间记录已被别人改过,本次提交作废,业务层选择重试或报错。这与「丢失更新」问题的解法是等价的:后提交者不能用旧版本的结果覆盖先提交者。

CAS(Compare-And-Swap):CPU 提供的原子指令,语义是「内存值仍等于期望值 E 时,才把它更新为 N」,比较和交换一步完成、不可拆分。Java 的原子类用它实现无锁自增:

AtomicInteger counter = new AtomicInteger(0);

// incrementAndGet 内部等价于下面这段自旋
int prev, next;
do {
prev = counter.get();
next = prev + 1;
} while (!counter.compareAndSet(prev, next)); // 失败说明被抢先,重读重试

多线程同时 CAS 同一个变量时只有一个胜出,失败者不会被挂起,而是立即拿到失败结果自行决定重试或放弃——这是它与悲观锁「失败者排队睡觉」的根本区别。

深入机制

CAS 的三个短板

CAS 很轻快,但工程上必须正视三个问题:

  1. 自旋消耗 CPU。冲突激烈时大量线程反复失败重试,CPU 被空转烧掉,此时吞吐量反而低于悲观锁。缓解手段是加随机退避、限制重试次数,或直接改用 LongAdder 这类把竞争分摊到多个单元的结构。
  2. 只能保证单个变量的原子性。CAS 操作的对象是一个内存单元,「扣库存再写流水」这种多变量一致性,CAS 管不了,要么把多个变量封成不可变对象用 AtomicReference 整体替换,要么回到锁。
  3. ABA 问题。值从 A 变 B 又变回 A,CAS 检查时认为没变过,但中间状态可能已被利用。解决思路是给值附带单调递增的版本戳,Java 提供 AtomicStampedReference(int 版本号)与 AtomicMarkableReference(boolean 标记):
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 1);
int[] stampHolder = new int[1];
String cur = ref.get(stampHolder);
// 期望值和期望版本必须同时匹配才更新
boolean ok = ref.compareAndSet(cur, "C", stampHolder[0], stampHolder[0] + 1);

数据库层的悲观与乐观

跨实例的并发控制超出 JVM 边界,只能靠数据库。悲观路径用行锁:SELECT ... FOR UPDATE 在事务内锁住目标行,其他事务的同型查询被阻塞,直到当前事务提交。它简单可靠,适合「必须改成功」的强一致场景,代价是长事务会放大锁持有时间,甚至出现死锁。

乐观路径就是版本号:读时不加锁,写时用 WHERE version = ? 兜底。MyBatis-Plus、JPA(@Version)都内置了这一模式,冲突由数据库在 UPDATE 瞬间判定。乐观方式吞吐高,但冲突率一上来,重试成本和失败率都会恶化。

工程实践

  • 先估算冲突频率,再选策略。读多写少、冲突罕见:乐观锁/CAS,省下加锁和上下文切换的开销;写多、冲突频繁:悲观锁,一次排队走完,好过反复 CAS 自旋烧 CPU。
  • 临界区越长越偏向悲观。乐观锁失败意味着整段计算作废重来,临界区里工作越多,浪费越大。只改一个计数器用 CAS 很自然,把一段含 RPC 调用的业务流程包进重试循环则是灾难。
  • 乐观重试要有上限和退避。无限自旋会把故障放大成 CPU 打满。常见做法是指数退避 + 最大重试次数,超限后降级为悲观锁或直接失败返回。
  • 数据库乐观锁注意隔离级别配合。版本号 UPDATE 依赖「读到—校验—写入」之间版本不被并发事务悄悄改变语义,RC 级别下语句本身的行锁已足够判定,但别把跨多条记录的不变量寄托在单行版本上。

对比与权衡

维度 悲观锁 乐观锁
核心假设 冲突频发,先防 冲突罕见,事后校验
典型实现 synchronized、ReentrantLock、FOR UPDATE CAS、Atomic 类、version 字段
失败代价 阻塞等待,上下文切换 重试,浪费已做计算
死锁风险 有(锁顺序不当) 无锁概念,无死锁
适合场景 写多、临界区长、必须成功 读多写少、临界区短、可重试
一致性粒度 可保护多变量复合操作 天然单变量,多变量需封装

一句话总结权衡公式:冲突概率 × 单次重试成本是乐观锁的期望浪费,等待概率 × 阻塞代价是悲观锁的期望浪费,哪边小选哪边。

面试视角

什么是乐观锁和悲观锁?各自在 Java 和数据库里怎么体现? 悲观锁假设冲突常发生,访问前先加独占锁,Java 中对应 synchronized、ReentrantLock,数据库中对应 SELECT FOR UPDATE;乐观锁假设冲突少,不加锁,提交时校验数据是否被改过,Java 中对应 CAS 原子类,数据库中对应 version 版本号字段。

CAS 有什么问题,怎么解决? 三个:一是高冲突下自旋空转耗 CPU,可用退避策略或 LongAdder 分散竞争;二是只能原子更新单个变量,多变量一致性需封装成不可变对象整体 CAS 或改用锁;三是 ABA 问题,值变回旧值时无法感知中间变化,用 AtomicStampedReference 附加版本号解决。

高并发扣库存用哪种锁? 看冲突强度。同一商品并发不高时用数据库版本号乐观锁即可;热点商品每秒数千次扣减时,单行乐观锁大量重试失败,应改为分段库存、Redis 预扣或排队串行化,本质是降低单点冲突率而不是在两种锁之间纠结。

为什么乐观锁读多写少时性能更好? 读操作完全无锁、无阻塞、无上下文切换,写少意味着校验失败重试的概率低,期望浪费小;悲观锁即使无冲突也要走加解锁流程,竞争激烈时还要挂起唤醒线程,开销始终存在。

小结

  • 悲观锁「先锁住再操作」,乐观锁「先操作、提交时校验」,是一对贯穿 Java 与数据库的并发控制策略。
  • 乐观锁的两条实现路径:CAS 适合单变量无锁更新,版本号适合记录级并发控制,ABA 问题靠版本戳解决。
  • 选型看冲突频率与临界区时长:读多写少、操作短用乐观;写多、临界区长、必须成功用悲观;重试务必加上限与退避。