无锁并发之乐观锁
前言
我们说乐观锁,是基于悲观锁来对比的,悲观锁总会假设最坏的情况发生,在访问共享资源时需要先获取锁,而乐观锁总会假设最好的情况发生,也就是没有并发的修改,当共享资源出现了并发修改问题时才着手解决问题。
像 Java 中的 synchronized、Lock 等都是悲观锁的实现,那么在这篇文章我们主要讨论乐观锁的实现方式。
如何实现乐观锁
要实现一个乐观锁,常用的方式是基于 CPU 的原子指令 CAS 实现,在操作数据库时,也可以通过增加 version 字段,但更多的还是通过记录状态来控制实现乐观锁。
数据库乐观锁
数据库乐观锁可以基于版本号实现,但开发场景中可能更常用的是基于记录状态来实现。
比如一个账单的更新,当开始结算时,我们需要变更账单的状态为结算中,这里我们就可以通过订单的状态实现一个乐观锁。
一个简单的示例如下:
public void settle(SettleContext settleContext) {
// 获取账单
Bill bill = xxx;
// 开始结算
bill.startSettle();
// 变更结算单
updateBillSettleStatus(billId, BillSettleStatusEnum.UNSETTLED, bill.getSettleStatus);
}
// Bill 内的方法
public void startSettle() {
// 变更结算状态
settleStatus = BillSettleStatusEnum.SETTLING;
}
public boolean updateBillSettleStatus(Long billId, BillSettleStatusEnum from, BillSettleStatusEnum to) {
return billRepository.updateBillSettleStatus(billId, from, to) > 0;
}
// BillRepository 内
public int updateBillSettleStatus(Long billId, BillSettleStatusEnum from, BillSettleStatusEnum to) {
LambdaUpdateWrapper<BillDO> updateWrapper = Wrappers.lambdaUpdate(BillDO.class)
.eq(BillDO::getId, billId)
.eq(BillDO::getSettleStatus, from.getCode())
.set(BillDO::getSettleStatus, to.getCode());
return billMapper.update(null, updateWrapper);
}
CAS
CAS 指的是 Compare And Swap(比较与交换) ,它的思想很简单,就是用一个预期值和要更新的变量值进行比较,两个值相等才会进行更新。
并且 CAS 底层依赖于一条 CPU 的原子指令,可以保证执行不被打断。
CAS 涉及三个操作数:
- V:要更新的变量值,或者说变量的内存地址
- E:预期值
- N:拟写入的新值
CAS 原子性的将变量从 E 更新到 N,更详细一点说就是 CAS 先获取内存地址为 V 的值,看是否等于 E,如果等于再将该变量写为 N,否则写入失败(可能有其他线程已经更新了)。
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此,CAS 的具体实现和操作系统以及 CPU 都有关系。
但是在 Java 语言中,有很多的类都使用了 CAS,比如原子变量 AtomicXxx、LongAdder 等。
它们都是基于 sun.misc 包的 Unsafe 类提供的 compareAndSwapObject、compareAndSwapInt、compareAndSwapLong 方法来实现的对 Object、int、long 类型的 CAS 操作。
CAS 也存在几个问题。
首先,ABA 问题,如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?
很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA” 问题。
ABA 问题的解决思路是给变量追加上 版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前的 stamp 是否等于预期的 stamp,如果全部相等,才以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference && newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
其次,CAS 循环时间长开销大
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
最后,CAS 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效,但是从 JDK 1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。
乐观锁 vs 悲观锁
在高度竞争的情况下,锁(悲观锁)的性能将超过原子变量(乐观锁)的性能,但是更真实的竞争情况下,原子变量的性能将超过锁的性能,同时原子变量不会有死锁等活跃性问题。
这句话描述了在并发编程中两种不同的并发控制机制 — 锁(通常是悲观锁)和原子变量(通常是乐观锁)— 在不同负载条件下的性能差异及其特性。
在高度竞争的情况下(高负载)
当多个线程频繁地试图访问和修改同一个共享资源时,我们说存在“高度竞争”。在这种情况下,锁的性能可能会优于原子变量的原因如下:
- 锁的专有性:锁可以明确地告诉其他线程:“请等待,我现在正在修改资源。”这可以减少不必要的 CAS 操作从而避免 CPU 的高负载,因为一旦一个线程获得了锁,其他线程就会暂停直到锁释放。
- 减少无效尝试:在高度竞争的环境下,原子变量可能会导致很多次的 CAS 失败,因为多个线程几乎同时尝试修改同一个资源,每次失败后都需要重试,这会消耗额外的 CPU 周期。
更真实的竞争情况(适度负载)
在“更真实的”竞争条件下,也就是多个线程偶尔会访问和修改共享资源,但并不频繁,此时原子变量的性能通常会超过锁的性能。这种情况下的优点包括:
- 减少上下文切换:原子变量在读取数据时不锁定资源,只有在更新时才检查一致性。这意味着大多数情况下,读操作可以不受干扰地执行,减少了因获取锁而导致的上下文切换开销。
- 避免锁等待:在非高度竞争的环境下,原子变量的重试机制通常不会导致过多的冲突,因此大多数更新操作可以直接完成,而无需等待其他线程释放锁。
原子变量的优势
并且除了性能方面的考虑外,原子变量还有一些其他优势:
- 避免死锁:由于原子变量在读取数据时不加锁,因此不存在死锁的风险。
- 活跃性问题:原子变量的乐观锁策略通常不会导致饥饿(starvation)或活锁(livelock)等问题,因为它们不需要等待其他线程释放资源。