乐观锁
乐观锁是一种并发控制机制,它的核心思想是:假设多线程并发访问同一数据时,发生冲突的概率很低。因此,在数据读取阶段不进行加锁,而是在数据提交更新时,才会正式检测数据在此期间是否被其他线程修改过。如果没有被修改,则更新成功;如果被修改了,则更新失败,通常需要重试或抛出异常。
为什么要使用乐观锁
传统的悲观锁(如synchronized、ReentrantLock)在读写数据前就直接加锁,保证了数据安全,但带来了显著的性能开销:
- 降低并发度:线程排队执行。
- 可能死锁:锁的竞争可能导致死锁。
- 性能损耗:加锁、释放锁、线程上下文切换都需要资源。
乐观锁适用于读多写少的场景,它解决了上述问题:
- 提高吞吐量:在低冲突场景下,不加锁的操作让并发度大幅提升。
- 避免死锁:因为没有锁的竞争。
- 实现简单:通常借助数据库或原子变量即可实现,无需复杂的锁管理。
核心价值:在保证数据一致性的前提下,最大化系统的并发性能。
在“读多写少”的场景下,大量的读操作本可以并发进行、互不干扰,但如果使用悲观锁,这些读操作也会被不必要的串行化,导致系统吞吐量急剧下降。
而使用乐观锁,读操作没有任何的锁开销,所有读操作都可以并发执行,性能极高。这正是“读多”场景梦寐以求的特性。 如果是"写多读少"的场景,冲突的概率很高,使用乐观锁会导致大量的更新失败和重试,大量性能损失,可能还不如一开始就用悲观锁,让写操作有序排队来的高效。
在“读多写少”的场景下,冲突发生的概率本身就很低。这意味着:
- 我们享受了读操作完全无锁带来的巨大性能收益。
- 只付出了极小的、在少数写冲突时才发生的版本检查与重试代价。
- 总体系统吞吐量达到最优。
乐观锁的实现
版本号
这是数据库层面最经典的实现。为数据表增加一个版本号字段(如version)。
原理:
- 读取数据时,同时获取当前版本号(假设为
1)。 - 更新数据时,在SQL的
WHERE条件中,除了指定主键,还要指定读取到的版本号。 - 执行更新,并将版本号
+1。 - 数据库检查:如果
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() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
悲观锁
悲观锁是一种并发控制策略,其核心思想是“悲观地”假设在数据处理过程中,其他线程大概率会来竞争修改同一份数据。因此,在访问共享资源(如数据、变量、文件)之前,它会先获取锁,将资源独占,确保在自己操作完成并释放锁之前,其他任何线程都无法访问该资源。
为什么要使用悲观锁
主要目的是保证强一致性,适用于写多读少、竞争激烈的场景。
- 优点:简单直接,能彻底杜绝数据冲突,确保临界区代码的串行执行,数据安全最有保障。
- 代价:性能开销大。加锁、释放锁本身需要成本,更重要的是,它会阻塞其他所有需要该资源的线程,导致线程挂起和唤醒,降低系统的整体吞吐量。不当使用还易引发死锁。