深入理解乐观锁与悲观锁的区别及CAS算法在Java中的实现和应用
在这篇文章中,我们将深入探讨一个常见但重要的话题——乐观锁与悲观锁,这也是面试中极为频繁的问题之一。
悲观锁与乐观锁的比较
如果将悲观锁和乐观锁与现实生活中的行为相类比,悲观锁就像一个常常忧虑的人,总是预期最坏的结果,以避免潜在的问题。而乐观锁则像一个乐观主义者,通常相信一切都会顺利,在问题出现之前迅速采取行动。
悲观锁的定义
悲观锁总是假设在每次访问共享资源时都会发生冲突(例如,共享数据可能被修改),因此它会在每次获取资源时加锁。这意味着其他线程在试图访问该资源时将被阻塞,直到持有锁的线程释放它。简而言之,共享资源每次只能由一个线程使用,其他线程需要等待,完成后资源才会被转交给下一个线程。
在Java中,synchronized
和ReentrantLock
等独占锁机制是悲观锁思想的具体体现。
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(Compare and Swap) 算法实现的。
// LongAdder 在高并发场景下的性能优于 AtomicInteger 和 AtomicLong
// 代价是使用更多的内存空间(空间换时间)
LongAdder sum = new LongAdder();
sum.increment();
在高并发场景中,乐观锁相比悲观锁几乎不存在锁竞争造成的线程阻塞问题,也不容易出现死锁,因此在性能上通常表现更好。然而,如果写操作频繁,可能会导致频繁的失败和重试,从而影响系统性能并导致CPU资源飙升。
不过,频繁重试的问题可以通过一些方法来缓解,例如前面提到的 LongAdder
类,它通过空间换时间的方式来进行优化。
理论上:
- 悲观锁更适合写操作较多的场景(例如竞争较激烈的多写情况),以避免因频繁失败和重试而影响性能,悲观锁的开销是固定的。然而,如果乐观锁能够有效解决频繁失败的问题(如
LongAdder
),同样可以考虑使用乐观锁。 - 乐观锁通常适合写操作较少的场景(如读多写少),这样可以避免因频繁加锁而影响性能。乐观锁主要针对的是单个共享变量(即
java.util.concurrent.atomic
包中的原子变量类)。
乐观锁的实现方式
乐观锁常见的实现方式包括版本号机制或CAS算法,其中CAS算法更为普遍。
版本号机制
一般在数据表中添加一个版本号 version
字段,表示数据被修改的次数。每当数据被修改时,version
值就会增加。当线程 A 要更新数据时,会同时读取数据和version
值。在提交更新时,只有当读取的版本号与数据库当前版本号相同时才会更新,否则线程需要重试,直到更新成功。
简要示例:假设数据库中有一个账户信息表,其中包含一个 version
字段,当前值为 1,而 balance
字段为 $100。
- 操作员 A 读取时
version
为1,并从账户余额中扣除 $50。 - 在操作员 A 操作期间,操作员 B 也读取该用户信息(
version
仍为1),并从账户余额中扣除 $20。 - 操作员 A 完成修改,提交数据版本号(
version
变为2)及余额(balance
为$50)更新,而此时提交版本号与数据库当前版本匹配,数据更新成功。 - 操作员 B 完成操作想提交(
balance
为$80),但此时版本号不匹配,因此提交被驳回,避免了旧数据覆盖新数据。
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问题)。
- 比较i与1,如果两者相等,则可以将i值设置为6。
- 如果不相等,说明i已被其他线程修改,当前线程则放弃更新,CAS操作失败。
Java中CAS的实现
在Java中,实现CAS(Compare-And-Swap)操作的一个重要类是Unsafe
。
Unsafe
类位于sun.misc
包中,提供低级别、不安全的操作。由于其强大的功能与潜在的危险性,它通常用于JVM内部或需要极高性能的库,不推荐普通开发者在应用程序中使用。关于Unsafe
类的详细介绍,可以阅读相关文献。
sun.misc
包中的Unsafe
类提供了compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
等方法来实现对Object
、int
、long
类型的CAS操作:
/**
* 以原子方式更新对象字段的值。
*
* @param o 要操作的对象
* @param offset 对象字段的内存偏移量
* @param expected 期望的旧值
* @param x 要设置的新值
* @return 如果值被成功更新,则返回 true;否则返回 false
*/
boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
/**
* 以原子方式更新 int 类型的对象字段的值。
*/
boolean compareAndSwapInt(Object o, long offset, int expected, int x);
/**
* 以原子方式更新 long 类型的对象字段的值。
*/
boolean compareAndSwapLong(Object o, long offset, long expected, long x);
Unsafe
类中的CAS方法是native
方法,表示这些方法是用本地代码(通常是C或C++)实现的,而非用Java实现。这些方法直接调用底层硬件指令进行原子操作,意味着Java语言并没有直接使用Java实现CAS。
更准确地说,Java中的CAS是通过C++内联汇编的形式实现的,并通过JNI(Java Native Interface)调用。因此,CAS的具体实现与操作系统及CPU密切相关。
java.util.concurrent.atomic
包提供了一些用于原子操作的类。
CAS算法的潜在问题
ABA问题
ABA问题是CAS算法中常见的问题。
考虑一个变量V初始读取时是A值,当准备赋值时检查到它仍然是A值,这并不能说明它在此期间没有被其它线程修改,因为它可能经历了更改为其他值然后又改回A值的过程。这个问题被称为CAS操作的 "ABA"问题。
解决ABA问题的一种思路是在变量前加上版本号或时间戳。JDK 1.5及以后的AtomicStampedReference
类正是为了解决ABA问题而设计的,其中的compareAndSet()
方法实现了对引用的原子性操作,并确保引用值与版本号同时匹配。
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操作通常会使用自旋机制进行重试,这意味着在操作失败时会一直循环执行直到成功。如果操作长时间无法成功,会给CPU带来显著的性能开销。
如果JVM能够支持处理器提供的pause
指令,那么自旋操作的效率将得以提升。pause
指令有两个重要作用:
- 延迟流水线执行指令:可以减少CPU资源消耗,具体延迟时间取决于处理器实现版本。
- 避免内存顺序冲突:在退出循环时,可以避免因内存顺序冲突而导致的CPU流水线被清空,从而提高CPU执行效率。
只能保证单个共享变量的原子操作
CAS操作仅对单个共享变量有效,当涉及多个共享变量时,CAS操作便无能为力。但是,自JDK 1.5起,Java提供了AtomicReference
类,允许我们保证引用对象之间的原子性。我们可以将多个变量组合在一个对象中进行CAS操作,因此在需要操作多个共享变量时,可以使用锁或利用AtomicReference
类将多个共享变量合并为一个来处理。
总结
本文深入探讨了乐观锁与悲观锁的概念、实现方式及其在Java中的应用:
- 悲观锁基于对共享资源冲突的悲观假设,因此在每次操作时都会加锁,导致其他线程阻塞,直到锁被释放。Java中的
synchronized
和ReentrantLock
是悲观锁的典型实现。这种锁机制能够有效避免数据竞争,但在高并发场景下可能导致线程阻塞和频繁的上下文切换,影响系统性能,并可能引发死锁问题。 - 乐观锁基于对共享资源冲突的乐观假设,认为在访问时不会发生冲突,因此无需加锁,只在提交更改时验证数据是否被其他线程修改。Java中的
AtomicInteger
和LongAdder
等类通过CAS(Compare-And-Swap)算法实现乐观锁。乐观锁避免了线程阻塞和死锁问题,适用于读多写少的场景。但在写操作频繁时,频繁的重试可能会影响性能。 - 乐观锁主要通过版本号机制或CAS算法实现。版本号机制通过比较版本号确保数据一致性,而CAS通过硬件指令实现原子操作,直接比较和交换变量值。CAS是一种高效的无锁算法,但也需注意ABA问题、循环时间长等问题。
- 在Java中,CAS通过
Unsafe
类中的native
方法实现,这些方法直接调用底层硬件指令来完成原子操作。由于其实现依赖于C++内联汇编和JNI调用,因此CAS的具体实现与操作系统及CPU紧密相关。
悲观锁与乐观锁各有优劣,适用于不同的应用场景。在实际开发中,选择合适的锁机制能够有效提升系统的并发性能与稳定性。虽然CAS具有高效的无锁特性,但也需要关注ABA问题及长时间循环的开销。