乐观锁和悲观锁

1 阅读4分钟

乐观锁

乐观锁是一种并发控制机制,它的核心思想是:假设多线程并发访问同一数据时,发生冲突的概率很低。因此,在数据读取阶段不进行加锁,而是在数据提交更新时,才会正式检测数据在此期间是否被其他线程修改过。如果没有被修改,则更新成功;如果被修改了,则更新失败,通常需要重试或抛出异常。

为什么要使用乐观锁

传统的悲观锁(如synchronizedReentrantLock)在读写数据前就直接加锁,保证了数据安全,但带来了显著的性能开销:

  • 降低并发度:线程排队执行。
  • 可能死锁:锁的竞争可能导致死锁。
  • 性能损耗:加锁、释放锁、线程上下文切换都需要资源。

乐观锁适用于读多写少的场景,它解决了上述问题:

  • 提高吞吐量:在低冲突场景下,不加锁的操作让并发度大幅提升。
  • 避免死锁:因为没有锁的竞争。
  • 实现简单:通常借助数据库或原子变量即可实现,无需复杂的锁管理。

核心价值:在保证数据一致性的前提下,最大化系统的并发性能。

在“读多写少”的场景下,大量的读操作本可以并发进行、互不干扰,但如果使用悲观锁,这些读操作也会被不必要的串行化,导致系统吞吐量急剧下降。

而使用乐观锁,读操作没有任何的锁开销,所有读操作都可以并发执行,性能极高。这正是“读多”场景梦寐以求的特性。 如果是"写多读少"的场景,冲突的概率很高,使用乐观锁会导致大量的更新失败和重试,大量性能损失,可能还不如一开始就用悲观锁,让写操作有序排队来的高效。

在“读多写少”的场景下,冲突发生的概率本身就很低。这意味着:

  • 我们享受了读操作完全无锁带来的巨大性能收益。
  • 只付出了极小的、在少数写冲突时才发生的版本检查与重试代价。
  • 总体系统吞吐量达到最优。

乐观锁的实现

版本号

这是数据库层面最经典的实现。为数据表增加一个版本号字段(如version)。 原理

  1. 读取数据时,同时获取当前版本号(假设为 1)。
  2. 更新数据时,在SQL的WHERE条件中,除了指定主键,还要指定读取到的版本号。
  3. 执行更新,并将版本号+1
  4. 数据库检查:如果WHERE条件中的版本号与当前行版本号一致,说明数据未被他人修改,则更新成功(同时版本号变为2);如果不一致,则更新0行,代表失败。

CAS (Compare And Swap) 机制

这是CPU指令级别的乐观锁实现,Java在java.util.concurrent.atomic包中提供了基于CAS的原子类。

在修改变量时,检查当前值是否等于预期值(之前读取的值)。如果相等,则更新为新值;如果不相等,说明已被其他线程修改,则更新失败。

CAS的问题

ABA问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

悲观锁

悲观锁是一种并发控制策略,其核心思想是“悲观地”假设在数据处理过程中,其他线程大概率会来竞争修改同一份数据。因此,在访问共享资源(如数据、变量、文件)之前,它会先获取锁,将资源独占,确保在自己操作完成并释放锁之前,其他任何线程都无法访问该资源。

为什么要使用悲观锁

主要目的是保证强一致性,适用于写多读少、竞争激烈的场景。

  • 优点:简单直接,能彻底杜绝数据冲突,确保临界区代码的串行执行,数据安全最有保障。
  • 代价:性能开销大。加锁、释放锁本身需要成本,更重要的是,它会阻塞其他所有需要该资源的线程,导致线程挂起和唤醒,降低系统的整体吞吐量。不当使用还易引发死锁