--- title: 乐观锁和悲观锁详解 category: Java tag: - Java并发 --- 如果将悲观锁(Pessimistic Lock)和乐观锁(PessimisticLock 或 OptimisticLock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。 在程序世界中,乐观锁和悲观锁的最终目的都是为了保证线程安全,避免在并发场景下的资源竞争问题。但是,相比于乐观锁,悲观锁对性能的影响更大! ## 什么是悲观锁? 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,**共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程**。 像 Java 中`synchronized`和`ReentrantLock`等独占锁就是悲观锁思想的实现。 ```java public void performSynchronisedTask() { synchronized (this) { // 需要同步的操作 } } private Lock lock = new ReentrantLock(); lock.lock(); try { // 需要同步的操作 } finally { lock.unlock(); } ``` 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。 ## 什么是乐观锁? 乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。 像 Java 中`java.util.concurrent.atomic`包下面的原子变量类(比如`AtomicInteger`、`LongAdder`)就是使用了乐观锁的一种实现方式 **CAS** 实现的。 ![JUC原子类概览](https://oss.javaguide.cn/github/javaguide/java/JUC%E5%8E%9F%E5%AD%90%E7%B1%BB%E6%A6%82%E8%A7%88-20230814005415437.png) ```java // LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好 // 代价就是会消耗更多的内存空间(空间换时间) LongAdder longAdder = new LongAdder(); // 自增 longAdder.increment(); // 获取结果 longAdder.sum(); ``` 高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试(悲观锁的开销是固定的),这样同样会非常影响性能,导致 CPU 飙升。 不过,大量失败重试的问题也是可以解决的,像我们前面提到的 `LongAdder`以空间换时间的方式就解决了这个问题。 理论上来说: - 悲观锁通常多用于写比较多的情况下(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如`LongAdder`),也是可以考虑使用乐观锁的,要视实际情况而定。 - 乐观锁通常多于写比较少的情况下(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考`java.util.concurrent.atomic`包下面的原子变量类)。 ## 如何实现乐观锁? 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。 ### 版本号机制 一般是在数据表中加上一个数据版本号 `version` 字段,表示数据被修改的次数。当数据被修改时,`version` 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 `version` 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 `version` 值相等时才更新,否则重试更新操作,直到更新成功。 **举一个简单的例子**:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( `balance` )为 \$100 。 1. 操作员 A 此时将其读出( `version`=1 ),并从其帐户余额中扣除 $50( $100-\$50 )。 2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( `version`=1 ),并从其帐户余额中扣除 $20 ( $100-\$20 )。 3. 操作员 A 完成了修改工作,将数据版本号( `version`=1 ),连同帐户扣除后余额( `balance`=\$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 `version` 更新为 2 。 4. 操作员 B 完成了操作,也将版本号( `version`=1 )试图向数据库提交数据( `balance`=\$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。 这样就避免了操作员 B 用基于 `version`=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。 ### CAS 算法 CAS 的全称是 **Compare And Swap(比较与交换)** ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。 CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。 > **原子操作** 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。 CAS 涉及到三个操作数: - **V**:要更新的变量值(Var) - **E**:预期值(Expected) - **N**:拟写入的新值(New) 当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。 **举一个简单的例子**:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。 1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。 2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。 当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。 Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。 `sun.misc`包下的`Unsafe`类提供了`compareAndSwapObject`、`compareAndSwapInt`、`compareAndSwapLong`方法来实现的对`Object`、`int`、`long`类型的 CAS 操作 ```java /** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update); ``` 关于 `Unsafe` 类的详细介绍可以看这篇文章:[Java 魔法类 Unsafe 详解 - JavaGuide - 2022](https://javaguide.cn/java/basis/unsafe.html) 。 ## 乐观锁存在哪些问题? ABA 问题是乐观锁最常见的问题。 ### ABA 问题 如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 **"ABA"问题。** ABA 问题的解决思路是在变量前面追加上**版本号或者时间戳**。JDK 1.5 以后的 `AtomicStampedReference` 类就是用来解决 ABA 问题的,其中的 `compareAndSet()` 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。 ```java public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair current = pair; return expectedReference == current.reference && expectedStamp == current.stamp && ((newReference == current.reference && newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } ``` ### 循环时间长开销大 CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。 如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用: 1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。 2. 可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。 ### 只能保证一个共享变量的原子操作 CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了`AtomicReference`类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用`AtomicReference`类把多个共享变量合并成一个共享变量来操作。 ## 总结 - 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。不过,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。 - 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。 - CAS 的全称是 **Compare And Swap(比较与交换)** ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。 - 乐观锁的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。 ## 参考 - 《Java 并发编程核心 78 讲》 - 通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其 Java 实现!: - 一文彻底搞懂 CAS 实现原理 & 深入到 CPU 指令: