☰
悲观锁与乐观锁详解:从Java并发到数据库实战
2026/10/16 1:40:06 网站建设 项目流程

在 Java 面试的并发编程环节,悲观锁和乐观锁几乎是必考题。面试官通常不会满足于你背出“悲观锁默认冲突,乐观锁默认不冲突”这两句话,而是会继续追问:在项目里扣减库存用的是什么锁?为什么数据库行锁比 synchronized 更适合?如果乐观锁频繁重试怎么办?这些问题看起来是考锁的实现,实际上是在考你对共享资源竞争、临界区、数据库事务和分布式环境这几个层次的理解是否连贯。这篇文章围绕“悲观锁和乐观锁怎么实现、区别是什么”这条主线,先讲清楚两种锁的思想来源,再分别落到 Java 和数据库的典型写法上,最后用面试场景题、Spring 工程中的常见坑和排查链路收尾。读完以后,你不仅能在面试时回答得更完整,也能在自己的项目里做出更合理的锁选型。

1. 先建立坐标系:悲观锁和乐观锁解决的是同一个问题

1.1 共享资源竞争与临界区

多个线程同时访问同一个共享资源时,如果其中至少有一个线程在写,就可能出现数据不一致。这个共享资源可以是内存中的变量、数据库表中的一行记录、Redis 中的 Key,也可能是文件里的某个字段。无论资源形态如何,竞争的本质都是“先读后写”这个复合操作被多个执行流交叉执行了。

这个“先读后写”的区间就是临界区。锁的作用不是阻止并发,而是让并发线程在进入临界区时按规则排队,从而保证临界区内的操作要么整体执行,要么整体不执行。理解这一点很关键:synchronized、ReentrantLock、数据库行锁、版本号机制、CAS,本质上都是对临界区访问策略的不同实现。

所谓悲观锁和乐观锁,其实是两种不同的并发控制策略。它们的目标一致,但对“冲突是否真的会发生”这个前提判断不同,因此实现路径也不同。

1.2 悲观锁:默认会冲突,先保护再操作

悲观锁的出发点是:我假定这个共享数据在操作期间一定会被其他线程修改,所以必须先拿到锁,把资源保护起来,再开始读改写操作。在持锁期间,其他线程只能阻塞等待,直到锁释放。

从这个思想出发,你会看到悲观锁通常表现为数据库的SELECT ... FOR UPDATE,Java 里的synchronized、ReentrantLock,以及分布式环境里的 Redis 分布式锁。它们都有一个共同的形态:在操作之前先“占坑”。

悲观锁的优点是强一致性,缺点是并发性能受锁竞争影响较大。锁持有时间越长,等待线程越多,系统吞吐量越低。

1.3 乐观锁:默认不会冲突,先操作再校验

乐观锁的出发点是:我假定这个共享数据在操作期间大概率不会被其他线程修改,所以不提前加锁,直接执行读改写。但在提交修改的那一刻,我要校验一下“在我读取之后,有没有别人改过数据”。如果发现数据已经变了,就重新读取、重新计算、重新尝试;如果没变,就提交成功。

这个思路听起来有点“赌”的意味,但在读多写少的场景下非常高效,因为它省去了加锁、阻塞、唤醒这一整套重量级操作。乐观锁最常见的两种实现形式是版本号和 CAS。

版本号方式的典型写法是在数据表中加一个version字段,更新时用WHERE version = ?作为条件,成功后让version = version + 1。CAS(Compare And Swap)则是在 CPU 指令层面完成“比较并交换”,Java 中的AtomicInteger等原子类就是基于 CAS 实现的。

2. 悲观锁在 Java 中的落地:synchronized 与 ReentrantLock

2.1 synchronized 的用法与底层机制

java里最基础的悲观锁是synchronized。它的使用方式有三种:修饰实例方法、修饰静态方法、修饰代码块。这里最容易出错的是锁对象的区别。

public class AccountService { private int balance = 100; // 实例方法:锁的是当前 AccountService 实例 public synchronized void deduct(int amount) { if (balance >= amount) { balance -= amount; } } }

上面这段代码中,两个线程必须持有同一个AccountService实例,synchronized才能互斥。如果每次请求都new一个 Service 对象,锁就失去了意义。

静态方法锁的是Class对象,同一个类的所有实例共享同一把锁:

public class StockService { // 静态方法:锁的是 StockService.class public static synchronized void deductStock(int stockId, int count) { // 扣减库存逻辑 } }

代码块则可以精确控制临界区范围,避免把不需要保护的操作也放进锁里:

public void deductWithBlock(int amount) { // 锁粒度更小,只保护共享变量的修改 synchronized (this) { if (balance >= amount) { balance -= amount; } } }

在 JVM 的实现中,synchronized经历过偏向锁、轻量级锁、重量级锁的升级过程。这个升级机制是 JVM 的优化手段,不是语言规范的一部分,不同 JDK 版本的实现细节可能不同。面试时理解“锁升级”的思想比死记具体阈值更有价值,因为这解释了为什么synchronized在低竞争时轻快、高竞争时变重。

ReentrantLock的“可重入”意思是同一个线程可以重复获取同一把锁。synchronized同样可重入,所以这一点不是ReentrantLock的专利。它真正的优势在下面会展开。

2.2 ReentrantLock 比 synchronized 多出来的能力

ReentrantLock是java.util.concurrent.locks包下的显式锁,需要手动加锁和释放锁。基础用法如下:

import java.util.concurrent.locks.ReentrantLock; public class OrderService { private final ReentrantLock lock = new ReentrantLock(); private int stock = 100; public void deduct(int count) { lock.lock(); try { if (stock >= count) { stock -= count; } } finally { lock.unlock(); } } }

finally块中的unlock()是必须写的,否则一旦业务代码抛出异常,锁可能永远不会释放,其他线程会永久阻塞。这一点是使用ReentrantLock时最常见的坑。

相比synchronized,ReentrantLock多出的能力包括:

  • 可中断:lockInterruptibly()允许等待锁的线程被中断,避免死等。
  • 可超时:tryLock(timeout, TimeUnit)在指定时间内获取不到锁就返回false。
  • 公平锁:构造时传入true,让等待时间最长的线程先获得锁。
  • 多个条件队列:通过newCondition()创建多个等待条件,比wait/notify更灵活。

下面是一个带超时控制的示例:

import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; public class TimeoutLockDemo { private final ReentrantLock lock = new ReentrantLock(); public boolean tryDeduct(int count) { boolean locked = false; try { locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { System.out.println("获取锁超时,放弃本次操作"); return false; } // 执行扣减逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (locked) { lock.unlock(); } } return true; } }

这种写法在实际工程里比无条件lock()更稳健。因为在高并发下,一个线程卡在锁上等待太久,会产生线程阻塞堆积,甚至拖垮整个应用。

2.3 锁选型不是越复杂越好

ReentrantLock功能虽多,但日常开发中大部分场景用synchronized就够了。synchronized语法简单,不会忘记释放锁,JVM 还能自动优化。只有在确实需要超时、可中断、公平排队或多个条件变量时,才值得引入ReentrantLock。

我也见过有团队把所有方法都改成ReentrantLock,理由是“它更强大”,结果代码里到处是 try-finally,稍不注意就漏掉 unlock。锁的选型应该遵循“最小必要”原则:能用简单方案解决就不用复杂方案。这个原则同样适用于后面的乐观锁选型。

3. 乐观锁在 Java 中的落地:CAS、Atomic 与版本号

3.1 先理解 CAS:比较并交换

CAS 是乐观锁思想的底层原语,全称是 Compare And Swap。它接收三个参数:内存地址 V、期望值 A、新值 B。只有当 V 当前的值等于 A 时,才把 V 更新为 B,否则不做任何操作。整个过程是原子的,由 CPU 指令保证。

Java 中Unsafe类提供了 CAS 能力,但我们日常开发不会直接使用Unsafe,而是使用基于 CAS 的原子类,其中AtomicInteger最典型:

import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { // getAndIncrement 内部就是 CAS 自旋 count.getAndIncrement(); } public int get() { return count.get(); } }

CAS 的实现逻辑可以用伪代码理解:

public class CasSimulation { // current 是期望值,next 是新值,实际执行是原子的 public boolean compareAndSet(int expected, int next) { // 如果当前值等于 expected,就更新为 next // 否则返回 false,表示发现并发修改 return true; } }

CAS 的优点是避免了线程上下文切换,在低竞争场景下性能远好于悲观锁。缺点是 CPU 开销可能较高,因为它在循环中反复尝试,如果冲突严重,自旋会消耗大量 CPU 资源。

3.2 Atomic 系列类与 LongAdder

AtomicInteger、AtomicLong、AtomicReference是常用的原子类。它们的内部都存在一个循环:先读取当前值,再计算新值,最后通过 CAS 尝试更新。如果更新失败,说明有其他线程抢先修改,于是重新读取、重新计算。

在高并发写非常多的场景下,AtomicLong会因为所有线程都自旋争抢同一个变量而产生性能瓶颈。JDK 8 之后提供了LongAdder,它把热点分散到多个 Cell 上,最终汇总时再相加。这给选型提供了一个参考:如果只是统计计数,LongAdder更适合高并发写入;如果更看重强一致的返回值,AtomicLong更直观。

import java.util.concurrent.atomic.LongAdder; public class RequestCounter { private final LongAdder count = new LongAdder(); public void hit() { count.increment(); } public long total() { return count.sum(); } }

3.3 版本号方式的乐观锁

CAS 适合内存中的变量,但在数据库场景里,我们用的是版本号方式。假设订单表有库存字段stock,同时加一个version字段,更新语句写成这样:

UPDATE stock SET stock = stock - #{count}, version = version + 1 WHERE id = #{id} AND version = #{expectVersion};

执行时,如果当前行的version等于应用层之前读到的expectVersion,这次更新会成功,同时version自增。如果其他事务已经改过这行,version已经变了,那么WHERE条件不成立,受影响行数为 0。

应用层必须判断这个受影响行数:

int rows = stockMapper.deductStock(stockId, count, expectVersion); if (rows == 0) { // 说明数据已被其他事务修改,需要重试或返回失败 throw new BusinessException("库存更新失败,请重试"); }

这里的重试逻辑需要结合业务决定。可以循环重试几次,也可以直接让用户重新提交。如果无限重试,在高并发下会浪费数据库资源,也没有业务意义。

3.4 ABA 问题

CAS 有一个经典缺陷叫 ABA 问题。简单说:线程 1 读取到值 A,线程 2 把值从 A 改成 B,又改回 A。此时线程 1 再做 CAS 时,发现值还是 A,就认为数据没有被修改,于是更新成功。但中间其实发生过一次完整的修改。

解决 ABA 问题的常用办法是引入版本号或时间戳。Java 中AtomicStampedReference就提供了这样的能力:不仅比较引用值,还比较邮票标记。

import java.util.concurrent.atomic.AtomicStampedReference; public class AbaDemo { private final AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0); public void update() { int[] stampHolder = new int[1]; Integer current = ref.get(stampHolder); int currentStamp = stampHolder[0]; boolean success = ref.compareAndSet(current, 200, currentStamp, currentStamp + 1); System.out.println("更新结果: " + success); } }

在实际业务里,ABA 是否真的有害,要看业务语义。如果共享数据的“值相同”就代表“状态相同”,ABA 通常不会造成问题;如果中间过程本身就代表业务状态变更,就必须引入版本标记。

4. 悲观锁和乐观锁的区别与选型

4.1 一张表看懂二者的核心差异

对比项悲观锁乐观锁
核心假设并发冲突大概率发生并发冲突是少数情况
典型实现synchronized、ReentrantLock、数据库 FOR UPDATE版本号、CAS、Atomic 类
锁粒度需要持有锁,阻塞其他线程不阻塞其他线程,提交时校验
竞争成本阻塞、唤醒、上下文切换自旋重试,消耗 CPU
一致性强弱强一致,冲突时串行最终一致,需要重试补偿
适用场景写多读少、冲突严重读多写少、冲突较少
数据库实现SELECT ... FOR UPDATEUPDATE ... WHERE version = ?
死锁风险存在,需要控制加锁顺序较低,但要注意重试风暴

悲观锁在并发冲突严重的场景下反而更稳定,因为它让线程排队执行,避免了乐观锁反复重试带来的 CPU 浪费。乐观锁在冲突少的场景下性能优秀,但冲突频繁时会出现大量更新失败和重试。

4.2 性能不是唯一指标:读多写少与写多读少

选型不能只看“乐观锁性能好”这个结论,要看冲突概率和业务容忍度。

读多写少的场景,比如用户资料查询、商品详情展示,并发修改同一个数据的概率很低,适合乐观锁。此时加悲观锁会白白增加阻塞成本。

写多读少的场景,比如库存扣减、积分扣减、账户余额变动,同一份数据被高频修改,乐观锁的 CAS 会频繁失败,大量线程都在自旋重试,数据库压力也会被成倍放大。这种情况反而应该使用悲观锁,让修改操作串行执行。

还有一个容易被忽略的点:乐观锁要求所有更新方都遵守“先读版本号再更新”的规则。如果系统里有一段代码直接UPDATE stock SET stock = stock - 1 WHERE id = ?,没有带version条件,那么乐观锁的保护就形同虚设。这一点在团队协作和接口对接时特别重要。

4.3 分布式环境下的锁不能照搬单机实现

单机环境下,synchronized和ReentrantLock都是 JVM 进程内的锁,只能锁住当前进程里的线程。一旦应用部署了多个副本,同一个用户请求被负载均衡到不同节点,进程内锁就互相不可见了。

这时需要引入分布式锁。常见的方案有:

  • 基于数据库:用唯一索引或SELECT ... FOR UPDATE实现,但性能有限。
  • 基于 Redis:使用SET NX EX实现,需要处理锁过期和释放时的原子性问题。
  • 基于 ZooKeeper:利用临时顺序节点实现,可靠性较高,部署成本也更高。

分布式锁并不只是把单机锁换一个实现,它还会引入锁超时、锁续期、客户端宕机后锁自动释放、可重入性等一系列问题。在面试中,当你说“分布式环境用 Redis 分布式锁”之后,面试官通常会追问“锁过期了业务还没执行完怎么办”,所以不要把这一层想得太简单。

这里不展开分布式锁的具体实现,重点是理解:悲观锁思想可以跨进程落地为分布式锁,乐观锁思想同样可以落地为数据库版本号或 Redis 中的 Lua 脚本比较更新。锁思想是通用的,具体载体取决于运行环境。

5. 面试中常见的场景题和 Spring 工程里容易踩的坑

5.1 秒杀减库存:为什么单纯数据库行锁不够

面试中特别高频的一道场景题是“秒杀减库存怎么设计”。很多人上来就说“用 synchronized”,这在单机演示中可以,在真实秒杀场景中是不够的。

先看最原始的版本:

UPDATE stock SET stock = stock - 1 WHERE id = 1 AND stock > 0;

这条 SQL 本身就是原子操作。数据库在更新时会锁住这一行,两个并发事务不会同时修改成功。所以“减库存”这个动作本身用数据库自带的行锁就能保证正确。问题在于,如果库存只有 10 件,却有 10000 个请求同时进来,大量请求都会阻塞在行锁上,导致数据库连接被占满,整个服务不可用。

这也是为什么真实秒杀系统通常要做前置拦截:用 Redis 做库存预扣减,或者在应用层先控制入口流量,只有少数请求能到达数据库。单纯靠数据库行锁,正确性有保证,但扩展性不够。

如果在这个场景里讲乐观锁,就可以这样说:

UPDATE stock SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = 1;

用版本号的好处是库存只剩 0 件时,更新条件自然不成立,不需要额外判断stock > 0。但代价是并发量大时,很多人会更新失败,需要重试或直接提示“已售罄”。

5.2 Spring 事务与锁的先后顺序:锁失效的一个高频原因

这是 Spring 工程里非常容易踩的坑。很多人把锁写在事务方法内部,导致事务还没有提交,锁就先释放了。

看下面的伪代码:

@Transactional public void deductStock(Long stockId, int count) { Stock stock = stockMapper.selectById(stockId); if (stock.getStock() < count) { throw new BusinessException("库存不足"); } stockMapper.updateStock(stockId, count); }

如果这个方法的调用方再包一层synchronized或都基于同一把ReentrantLock,看起来是先加锁再执行方法,但问题出在事务边界上:方法执行完,锁释放,但 Spring 的事务提交可能发生在方法返回之后。另一个线程拿到锁后读到的是尚未提交的旧数据,于是出现重复扣减。

常见解决办法是把锁的范围扩大到事务提交之后。可以拆成两个方法,先提交事务,再释放锁;或者在事务方法外层包一层非事务方法,锁放在外层:

public void deductWithLock(Long stockId, int count) { lock.lock(); try { // 这里调用的事务方法执行完,事务可能还没提交 deductInTransaction(stockId, count); } finally { lock.unlock(); } }

这种写法也不是绝对安全,因为deductInTransaction返回时事务是否已经提交,取决于事务管理器配置和数据库驱动行为。要彻底解决,需要理解 Spring 事务的提交时机,并把“释放锁”放到事务真正提交之后。一个更稳妥的做法是使用TransactionSynchronizationManager注册事务同步回调,在afterCommit中释放锁。

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { lock.unlock(); } });

这个细节很容易在面试中展开,也是实际项目中锁失效的典型原因。

5.3 自旋等待与超时控制

乐观锁在冲突时会重试,但重试不能没有边界。设计重试时至少要考虑三个参数:最大重试次数、每次重试的间隔、超过次数后的处理策略。简单示例如下:

public boolean deductWithRetry(Long stockId, int count, int maxRetry) { for (int i = 0; i < maxRetry; i++) { Stock stock = stockMapper.selectById(stockId); int rows = stockMapper.deductByVersion(stockId, count, stock.getVersion()); if (rows > 0) { return true; } // 短暂休眠,降低对数据库的冲击 Thread.sleep(50); } return false; }

如果重试次数和间隔设置得不好,乐观锁在冲突严重时会把数据库的查询压力放大数倍。这也是为什么我不建议在秒杀这类高冲突场景下盲目使用乐观锁。

5.4 Spring 框架里乐观锁和悲观锁的应用位置

java生态中,Spring 对锁的支持体现在不同层次:

  • @Transactional管理事务边界,不提供锁语义,但悲观锁的SELECT ... FOR UPDATE需要依赖事务。
  • MyBatis 或 MyBatis-Plus 提供了@Version注解来简化乐观锁实现,底层就是在 SQL 里拼版本号条件。
  • Spring AOP 可以统一管理自定义锁注解,常用于分布式锁的切面封装。

MyBatis-Plus 的乐观锁插件用法大致如下:

public class Stock { private Long id; private Integer stock; @Version private Integer version; }
@Update("UPDATE stock SET stock = stock - #{count}, version = version + 1 WHERE id = #{id} AND version = #{version}") int deductByVersion(@Param("id") Long id, @Param("count") Integer count, @Param("version") Integer version);

使用@Version注解时要注意:每次 update 之前都必须先查询出version,否则更新条件缺少期望值,乐观锁就无法工作。很多人只加注解但不改变更新 SQL 的写法,最终发现锁没有生效。

关于 Spring AI 和大模型相关场景,这里顺带提一句:AI Agent 在执行多任务协作时也会有共享状态竞争,比如任务结果写入、幂等控制、上下文版本管理,这些场景同样可以用版本号或原子更新来避免重复提交。锁的思路不限于传统 Web 应用。

6. 排查链路与最佳实践

6.1 从阻塞、报错和数据异常倒推锁问题

如果你在项目里发现数据错乱或接口阻塞,可以按下面的清单排查。

问题现象可能原因检查方式处理建议
接口偶尔超时,线程全部卡住悲观锁锁范围过大或持锁时间过长查看线程堆栈,检查等待锁的线程缩小临界区,减少持锁时间
ReentrantLock第二次调用时永远阻塞上一次调用忘记unlock()检查代码里是否没有 finally 释放统一使用 try-finally 或 try-with-resources
乐观锁更新一直返回 0 行version条件不匹配,冲突频繁打印更新 SQL 和传入的版本号增加重试策略或改用悲观锁
SELECT ... FOR UPDATE没有生效查询没有在事务中执行检查方法是否有@Transactional先把事务边界补齐
多个实例部署后锁失效使用了 JVM 进程内锁确认应用是否多副本部署改用分布式锁
库存出现负数减库存 SQL 缺少stock > 0条件查看历史 SQL 和更新条件添加数量校验和WHERE stock >= count
事务提交前锁已释放锁在事务方法内部,释放早于提交查看锁和事务的代码顺序把锁提升到事务外层或注册 afterCommit 回调

排查时建议先看线程堆栈。jstack可以输出 Java 进程的线程状态,能直接看到哪些线程在等待锁、卡在哪个方法上:

jstack <pid> > thread_dump.txt

在 dump 文件里搜索locked和waiting to lock关键字,可以快速定位阻塞点。

6.2 项目落地时的几个硬性建议

基于实际项目经验,下面这些建议可以避免大部分锁相关问题:

第一,锁粒度要尽量小。不要把整个方法都放进锁里,只保护真正需要保护的共享变量。锁范围越大,并发度越低。

第二,持锁时间要尽量短。不要持锁执行远程调用、IO 操作、大数据量查询。如果必须做,建议把耗时不稳定的操作移到锁外面,或者调整锁策略。

第三,乐观锁必须保证所有更新路径都带版本条件。如果有一条 SQL 绕过版本号直接更新,乐观锁机制就被破坏了。可以在代码审查时重点检查这一点。

第四,重试必须有上限。乐观锁失败后不能无限循环,要设置最大重试次数、退避间隔、失败后的降级策略。

第五,synchronized和ReentrantLock只能保证单进程内的正确性。上线前确认应用是否多实例部署,如果是,就要考虑分布式锁。

第六,数据库的悲观锁依赖事务。SELECT ... FOR UPDATE必须在事务中执行,事务提交或回滚时锁才会释放。没有事务的查询,锁会在语句结束后立刻释放,这往往不是期望行为。

6.3 面试回答时的一个可用提纲

面试时被问到悲观锁和乐观锁,可以按这个顺序回答:

第一层,讲思想。悲观锁默认冲突,先加锁再操作;乐观锁默认不冲突,提交时通过版本号或 CAS 校验。

第二层,讲实现。悲观锁对应 Java 的synchronized、ReentrantLock、数据库的SELECT ... FOR UPDATE;乐观锁对应版本号、AtomicInteger等原子类。

第三层,讲区别。从冲突假设、适用场景、性能表现、实现复杂度几个维度做对比。

第四层,讲场景。读多写少选乐观锁,写多读少选悲观锁;单机用 JVM 锁,多实例用分布式锁;库存扣减等强一致场景要特别小心事务和锁的顺序。

第五层,讲自己踩过的坑。比如 Spring 事务提交前锁已经释放导致数据错乱,或者乐观锁重试没有上限导致数据库压力过大。这一步能体现真实项目经验,比单纯背八股文有说服力。

面试官追问时,最常出现的两个延伸点是“CAS 的 ABA 问题怎么解决”和“分布式锁怎么保证锁过期后业务还能安全执行”。建议把这两个问题也提前准备好,和本文内容配合练习,会比孤立地背锁的概念有效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询