深入理解乐观锁与悲观锁的区别及CAS算法在Java中的实现和应用

在这篇文章中,我们将深入探讨一个常见但重要的话题——乐观锁与悲观锁,这也是面试中极为频繁的问题之一。

图片

悲观锁与乐观锁的比较

如果将悲观锁和乐观锁与现实生活中的行为相类比,悲观锁就像一个常常忧虑的人,总是预期最坏的结果,以避免潜在的问题。而乐观锁则像一个乐观主义者,通常相信一切都会顺利,在问题出现之前迅速采取行动。

悲观锁的定义

悲观锁总是假设在每次访问共享资源时都会发生冲突(例如,共享数据可能被修改),因此它会在每次获取资源时加锁。这意味着其他线程在试图访问该资源时将被阻塞,直到持有锁的线程释放它。简而言之,共享资源每次只能由一个线程使用,其他线程需要等待,完成后资源才会被转交给下一个线程

在Java中,synchronizedReentrantLock等独占锁机制是悲观锁思想的具体体现。

public void performSynchronisedTask() {  
    synchronized (this) {  
        // 需要同步的操作  
    }  
}  
  
private Lock lock = new ReentrantLock();  
lock.lock();  
try {  
   // 需要同步的操作  
} finally {  
    lock.unlock();  
}

在高并发场景下,激烈的锁竞争可能导致线程阻塞,造成大量线程等待,这将引发系统的上下文切换,从而增加性能开销。此外,悲观锁还可能导致死锁(当线程获取锁的顺序不当时),进而影响代码的正常执行。

乐观锁的定义

乐观锁则持有相反的观点,认为在访问共享资源时不会发生冲突,因此线程可以自由地执行,无需加锁或等待。在提交更改时,乐观锁会验证共享数据是否被其他线程修改(可以通过版本号或CAS算法来实现)。

在Java中,java.util.concurrent.atomic包下的原子变量类(如AtomicIntegerLongAdder)就是使用乐观锁的一种实现方式,并且是通过 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。

  1. 操作员 A 读取时version为1,并从账户余额中扣除 $50。
  2. 在操作员 A 操作期间,操作员 B 也读取该用户信息(version仍为1),并从账户余额中扣除 $20。
  3. 操作员 A 完成修改,提交数据版本号(version变为2)及余额(balance为$50)更新,而此时提交版本号与数据库当前版本匹配,数据更新成功。
  4. 操作员 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问题)。

  1. 比较i与1,如果两者相等,则可以将i值设置为6。
  2. 如果不相等,说明i已被其他线程修改,当前线程则放弃更新,CAS操作失败。

Java中CAS的实现

在Java中,实现CAS(Compare-And-Swap)操作的一个重要类是Unsafe

Unsafe类位于sun.misc包中,提供低级别、不安全的操作。由于其强大的功能与潜在的危险性,它通常用于JVM内部或需要极高性能的库,不推荐普通开发者在应用程序中使用。关于Unsafe类的详细介绍,可以阅读相关文献。

sun.misc包中的Unsafe类提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong等方法来实现对Objectintlong类型的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指令有两个重要作用:

  1. 延迟流水线执行指令:可以减少CPU资源消耗,具体延迟时间取决于处理器实现版本。
  2. 避免内存顺序冲突:在退出循环时,可以避免因内存顺序冲突而导致的CPU流水线被清空,从而提高CPU执行效率。

只能保证单个共享变量的原子操作

CAS操作仅对单个共享变量有效,当涉及多个共享变量时,CAS操作便无能为力。但是,自JDK 1.5起,Java提供了AtomicReference类,允许我们保证引用对象之间的原子性。我们可以将多个变量组合在一个对象中进行CAS操作,因此在需要操作多个共享变量时,可以使用锁或利用AtomicReference类将多个共享变量合并为一个来处理。

总结

本文深入探讨了乐观锁与悲观锁的概念、实现方式及其在Java中的应用:

  • 悲观锁基于对共享资源冲突的悲观假设,因此在每次操作时都会加锁,导致其他线程阻塞,直到锁被释放。Java中的synchronizedReentrantLock是悲观锁的典型实现。这种锁机制能够有效避免数据竞争,但在高并发场景下可能导致线程阻塞和频繁的上下文切换,影响系统性能,并可能引发死锁问题。
  • 乐观锁基于对共享资源冲突的乐观假设,认为在访问时不会发生冲突,因此无需加锁,只在提交更改时验证数据是否被其他线程修改。Java中的AtomicIntegerLongAdder等类通过CAS(Compare-And-Swap)算法实现乐观锁。乐观锁避免了线程阻塞和死锁问题,适用于读多写少的场景。但在写操作频繁时,频繁的重试可能会影响性能。
  • 乐观锁主要通过版本号机制或CAS算法实现。版本号机制通过比较版本号确保数据一致性,而CAS通过硬件指令实现原子操作,直接比较和交换变量值。CAS是一种高效的无锁算法,但也需注意ABA问题、循环时间长等问题。
  • 在Java中,CAS通过Unsafe类中的native方法实现,这些方法直接调用底层硬件指令来完成原子操作。由于其实现依赖于C++内联汇编和JNI调用,因此CAS的具体实现与操作系统及CPU紧密相关。

悲观锁与乐观锁各有优劣,适用于不同的应用场景。在实际开发中,选择合适的锁机制能够有效提升系统的并发性能与稳定性。虽然CAS具有高效的无锁特性,但也需要关注ABA问题及长时间循环的开销。