乐观锁和悲观锁详解¶
基本概念¶
- 悲观锁:假设一定会发生并发冲突,所以在修改前先加锁,其他线程只能等待锁释放,比如 Java 的 synchronized、ReentrantLock。
- 乐观锁:假设冲突不常见,所以不提前加锁,读-改-写时提交前检查数据是否被改过,不一致就失败或重试,典型实现是版本号、CAS。
| 特性 | 悲观锁 (Pessimistic Lock) | 乐观锁 (Optimistic Lock) |
|---|---|---|
| 心态 | “总有刁民想害朕”。认为别人肯定会改数据,所以必须先锁住。 | “大家都是好人”。认为冲突很少发生,先不锁,提交时再检查。 |
| 机制 | 先锁后改。依赖数据库锁机制(行锁、表锁)或Java锁(Synchronized)。 | CAS / 版本号。不加锁,更新时判断“当前值”是否等于“旧值”。 |
| 阻塞 | 会阻塞。其他线程必须等待锁释放。 | 不阻塞。通常会自旋重试或直接返回失败。 |
| 适用场景 | 写多读少。竞争激烈的场景,防止大量重试消耗CPU。 | 读多写少。竞争少的场景,提高吞吐量。 |
实现方式(Java/数据库视角)¶
悲观锁实现
- Java 层:
- synchronized 关键字(对象监视器锁)。
- Lock 接口实现类,如 ReentrantLock,显式 lock()/unlock() 控制。
- 数据库层:
- MySQL 排它锁(select ... for update 等)就是悲观锁思路。
示例(Java 悲观锁):
public class PessimisticExample {
private int count = 0;
// synchronized 实现悲观锁
public synchronized void incrWithSync() {
count++; // 同一时刻只有一个线程能执行
}
// ReentrantLock 实现悲观锁
private final java.util.concurrent.locks.Lock lock = new java.util.concurrent.locks.ReentrantLock();
public void incrWithLock() {
lock.lock(); // 加锁,其他线程阻塞
try {
count++;
} finally {
lock.unlock(); // 释放锁
}
}
public int getCount() {
return count;
}
}
关键点:进入临界区前先加锁,保证同一时间只有一个线程修改共享变量。适合临界区代码复杂、写多、竞争激烈的场景。
乐观锁实现
常见两种: 1. 版本号机制(数据库常用)。 + 表里加 version 字段。 + 查询时取出 version。 + 更新时带条件:where id = ? and version = ?。 + 受影响行数 = 0 说明被别人改过,更新失败,需要重试或报错。
伪代码(Java 操作数据库):
// 查询:select balance, version from account where id = ?
Account acc = accountDao.selectById(1L);
int oldVersion = acc.getVersion();
BigDecimal newBalance = acc.getBalance().subtract(new BigDecimal("50"));
// 更新:update account set balance = ?, version = version + 1
// where id = ? and version = ?
int rows = accountDao.updateBalance(1L, newBalance, oldVersion);
if (rows == 0) {
// 说明 version 不匹配,被其他线程修改过 => 乐观锁失败,做重试或提示用户
}
关键点:不加锁,靠版本号在更新时“比对是否被改过”。
- CAS(Compare-And-Swap)机制(JUC 原子类常用)。
- 比较当前值是否等于旧值,相等则更新为新值。
- 不相等说明发生冲突,操作失败,可自旋重试。
- Java 中 AtomicInteger、AtomicLong 等就是基于 CAS 的乐观锁。
示例(CAS 乐观锁):
import java.util.concurrent.atomic.AtomicInteger;
public class OptimisticExample {
private final AtomicInteger count = new AtomicInteger(0);
public void incrWithCAS() {
while (true) {
int oldValue = count.get(); // 读旧值
int newValue = oldValue + 1; // 基于旧值计算新值
if (count.compareAndSet(oldValue, newValue)) {
// CAS 成功,更新完成
break;
}
// CAS 失败,说明有并发修改,进入下一轮重试
}
}
public int getCount() {
return count.get();
}
}
关键点:循环 CAS,不加互斥锁,冲突时重试,适合单变量高并发更新。长时间失败会浪费 CPU,需要权衡。
两者优缺点与使用场景¶
优缺点对比
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 冲突假设 | 冲突经常发生,先锁住再说。javaguide+1 | 冲突较少,先操作,最后再检查。javaguide+2 |
| 实现方式 | 互斥锁(synchronized、ReentrantLock、排它锁)。javaguide+3 | 版本号、CAS、自旋重试等。javaguide+3 |
| 并发表现 | 加锁会阻塞线程,影响吞吐;但冲突多时稳定。javaguide+2 | 无锁,不阻塞,高并发下读多写少时性能好。javaguide+2 |
| 代价类型 | 加锁/解锁有固定开销,可能上下文切换。javaguide+1 | 冲突多时频繁失败、自旋重试,CPU 消耗大。javaguide+3 |
| 死锁风险 | 有死锁风险,需要小心锁顺序等。javaguide+1 | 不会产生死锁,但可能 ABA 等问题。javaguide+1 |
| 适配对象 | 代码块、记录范围等广粒度资源。javaguide+2 | 往往针对单条记录或单个共享变量。javaguide+2 |
典型使用场景¶
- 适合用悲观锁的场景:
- 写操作多、并发竞争激烈。
- 临界区逻辑复杂、重试成本高(比如涉及多条记录、多表操作)。
- 对一致性要求极高、不允许多次重试。(如资金扣减核心流程)。
- 适合用乐观锁的场景:
- 读多写少,冲突概率低(典型:配置读取、统计计数等)。
- 允许一定的重试,且临界区逻辑简单(单记录或单变量)。
- 分布式系统里减少长事务持锁时间(比如订单状态版本控制)。
和 Java、数据库里的对应关系
- Java 并发包:
- ReentrantLock、synchronized:悲观锁思想。
- AtomicXXX、LongAdder 等:乐观锁(CAS)思想,LongAdder 通过分段计数降低冲突,以空间换时间。
- 数据库事务控制:
- 锁(排它锁等)对应悲观控制。
- MVCC、多版本并发控制可视为一种乐观锁思路,读不阻塞写。
面试&实战回答建议¶
- 先定义两者思想:悲观锁加锁阻塞,乐观锁无锁重试。
- 再说实现:悲观锁用 synchronized/Lock/排它锁;乐观锁用版本号、CAS/原子类。
- 最后说场景:写多、逻辑复杂、冲突多用悲观锁;读多写少、冲突少、可重试用乐观锁。