乐观锁和悲观锁详解
悲观锁和乐观锁不是两个具体的锁实现,而是两种应对并发冲突的策略:悲观派先锁住再操作,乐观派先操作、提交时再校验。理解这对策略的关键不在背定义,而在搞清楚「冲突频率」和「临界区时长」如何决定谁更划算。
它解决什么问题
并发修改同一份数据时,必须防止两个线程基于同一份旧值各自计算、互相覆盖。解决思路只有两类:
- 悲观锁:认定冲突大概率发生,访问前先拿到独占许可,把其他人挡在门外,操作完再放行。共享资源同一时刻只服务一个线程,其余阻塞等待。
- 乐观锁:认定冲突是小概率事件,不加锁直接干活,只在提交时检查「我基于的版本还是不是最新」,是则提交,否则重来。
两种思路在不同层面有完全同构的实现:Java 里 synchronized、ReentrantLock 是悲观派,CAS 原子类是乐观派;数据库里 SELECT ... FOR UPDATE 是悲观派,version 字段校验是乐观派。所以这是一个可以贯穿应用层与存储层的通用模型。
核心原理
悲观锁:先取锁,再办事
Java 中最常见的悲观锁就是内置锁和显式锁:
public synchronized void deduct(long amount) { |
悲观锁的代价来自「等待」:抢不到锁的线程被挂起,涉及上下文切换,竞争越激烈开销越大;持锁线程若崩溃且未释放(显式锁忘写 unlock),还可能造成永久阻塞;多把锁顺序不当则引发死锁。但它换来的是确定性——只要拿到锁,临界区内的操作不会被任何人打断。
乐观锁:先办事,提交时再对账
乐观锁有两种主流实现。
版本号机制:给数据加一个单调递增的版本字段。读取时记下版本号,提交时要求「当前版本仍等于我读到的版本」才允许写入,写入同时把版本号加一。典型场景是数据库更新:
UPDATE account |
若影响行数为 0,说明在读与写之间记录已被别人改过,本次提交作废,业务层选择重试或报错。这与「丢失更新」问题的解法是等价的:后提交者不能用旧版本的结果覆盖先提交者。
CAS(Compare-And-Swap):CPU 提供的原子指令,语义是「内存值仍等于期望值 E 时,才把它更新为 N」,比较和交换一步完成、不可拆分。Java 的原子类用它实现无锁自增:
AtomicInteger counter = new AtomicInteger(0); |
多线程同时 CAS 同一个变量时只有一个胜出,失败者不会被挂起,而是立即拿到失败结果自行决定重试或放弃——这是它与悲观锁「失败者排队睡觉」的根本区别。
深入机制
CAS 的三个短板
CAS 很轻快,但工程上必须正视三个问题:
- 自旋消耗 CPU。冲突激烈时大量线程反复失败重试,CPU 被空转烧掉,此时吞吐量反而低于悲观锁。缓解手段是加随机退避、限制重试次数,或直接改用 LongAdder 这类把竞争分摊到多个单元的结构。
- 只能保证单个变量的原子性。CAS 操作的对象是一个内存单元,「扣库存再写流水」这种多变量一致性,CAS 管不了,要么把多个变量封成不可变对象用 AtomicReference 整体替换,要么回到锁。
- ABA 问题。值从 A 变 B 又变回 A,CAS 检查时认为没变过,但中间状态可能已被利用。解决思路是给值附带单调递增的版本戳,Java 提供
AtomicStampedReference(int 版本号)与AtomicMarkableReference(boolean 标记):
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 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 问题靠版本戳解决。
- 选型看冲突频率与临界区时长:读多写少、操作短用乐观;写多、临界区长、必须成功用悲观;重试务必加上限与退避。

