一文读懂乐观锁与悲观锁

x33g5p2x  于2021-12-18 转载在 其他  
字(2.3k)|赞(0)|评价(0)|浏览(358)

一、乐观锁

何谓乐观锁

乐观锁是一种思想,在进行读写操作的时候都不会加锁,但在进行写操作的时候会判断数据是否被修改过,这一点可以使用版本号机制或 CAS 算法实现。乐观锁适用于读多写少的场景,可以提高吞吐量,数据库的 write_condition 机制(通过版本号机制实现)和 JUC 包下的原子变量类(通过 CAS 算法实现)都是基于乐观锁思想实现的。

实现方式

1.版本号机制

版本号机制一般都是在数据库中加一个 version 字段作为判断依据,举例如下:

假设数据库中有一个余额表,balance 字段表示当前余额,值为 10000;version 字段表示当前版本,值为 1。

  1. 线程 A 读出 balance = 10000,version = 1,并将执行 balance = balance - 2000 的操作。
  2. 在线程 A 执行操作的过程中,线程 B 也读出了 balance = 10000,version = 1,并将执行 **balance = balance - 4000 **的操作。
  3. 在线程 B 执行操作的过程中,线程 A 已经完成了操作,并将 balance = 8000,version = 1 提交给数据进行更新,当然在更新前会先与数据库中的 version 字段进行对比,如果相等了才会更新成功。
  4. 在数据更新后,线程 B 也完成了操作,并试图将 balance = 6000,version = 1 提交给数据库进行更新,但在对比 version 字段后发现不相等,因此线程 B 的提交将会被驳回。

这样就避免了线程 B 提交的数据覆盖线程 A 提交的数据,从而导致数据丢失的情况的发生了。

2.CAS 算法

CAS 全拼是 Compare And Swap(比较与替换),CAS 算法涉及到三个基本的操作数:

  • V:需要读写的内存值
  • A:旧的预期值
  • B:拟写入的新值

当且仅当 V 和 A 的值相等时,CAS 通过原子的方式(比较和替换两个操作在 CAS 算法中是原子的)将新值 B 赋值给内存值 V,如果 V 和 A 的值不相等的话,一般情况下会进行自旋操作,即重新获取内存值,然后继续之前的流程,直到操作成功。

只看上面一段话可能有点抽象,下面我将通过图示法来进行解析。

我们来分步写一下 CAS 算法的整个操作流程。

  1. 线程 t1 从主存中读取到 V 的值,并将 V 的值赋给 A1,此时 B1 还未进行计算。
  2. 线程 t2 也从主存中读取到 V 的值,并将 V 的值赋给 A2。
  3. 线程 t1 已执行完 (B1 = A1 + 1) 的操作,并且打算将 B1 的值更新给主存中的 V,通过比较得知 A1 与 V 相等,成功将 B1 的值更新给 V,此时主存中的 V = 21。
  4. 此时线程 t2 也执行完了 (B2 = A2 + 1) 的操作,打算将 B2 的值更新给主存中的 V,但此时 V = 21,通过比较得知 A2 和 V 不相等,提交更新失败。
  5. 线程 t2 提交更新失败后一般情况下并不会放弃,而是进行自旋操作,即重复步骤 2 和步骤 4,直到成功。
CAS 的缺点:

1.ABA 问题

我们先思考一个问题,如果一个变量 V 在初次读取时值为 A,在准备赋值的时候检测到 V 的值还是 A 就一定能说明 V 没有被其他线程修改过吗?当然不,因为有可能 V 的值先别修改为 B,然后又被修改为 A,那么 CAS 算法就会认为 V 的值没有被修改过。这个问题就被称为 ABA 问题

JDK 1.5 以后的 AtomicStampedReference 类提供了解决 ABA 问题的办法,其中的 compareAndSet 方法会首先检查当前引用是否等于预期引用,然后再检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

2.长时间循环执行造成 CPU 开销较大

在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

3.只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在同一个对象里来进行 CAS 操作。当然也可以使用 Synchronized

二、悲观锁

悲观锁是一种思想,每次读或写操作都会加锁,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程,适用于写多读少的场景。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁都是悲观锁思想的实现。

三、CAS 与 synchronized 的使用情景

简单的来说 CAS 适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)

  1. 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
  2. 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

补充: Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞竞争切换后继续竞争锁稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

参考文章:CAS算法的理解及应用,面试必备之乐观锁与悲观锁

相关文章