跳转至

乐观锁和悲观锁详解

基本概念

  • 悲观锁:假设一定会发生并发冲突,所以在修改前先加锁,其他线程只能等待锁释放,比如 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 不匹配,被其他线程修改过 => 乐观锁失败,做重试或提示用户
}

关键点:不加锁,靠版本号在更新时“比对是否被改过”。

  1. 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/原子类。
  • 最后说场景:写多、逻辑复杂、冲突多用悲观锁;读多写少、冲突少、可重试用乐观锁。