悲观锁、乐观锁和CAS混淆不清?一文讲清楚它们的关系

在这篇文章中,我将介绍理解并发编程中的核心概念——悲观锁、乐观锁和CAS机制、探讨它们各自的原理、优缺点以及在实际场景中的应用,例如数据库库存更新和Java中的Atomic原子类。此外,我还会详细解析CAS的ABA问题与自旋开销问题,并提供相应的解决方案。

悲观锁:宁可错杀,不可放过

什么是悲观锁?

顾名思义,悲观锁对并发冲突持有”悲观”的态度。它认为,只要是多线程访问共享资源,就极大概率会发生数据冲突。因此,为了绝对安全,它采取了最严格的预防措施:每次访问共享资源前,必须先获取锁

一个线程成功获取锁之后,才能对资源进行操作。其他任何试图访问该资源的线程,都必须挂起(阻塞),进入等待队列,直到锁被释放。

Java中的synchronized关键字和ReentrantLock的默认实现,都是典型的悲观锁。

悲观锁的优点与缺点

优点:实现简单,逻辑清晰,能有效防止数据冲突,保证数据一致性。

缺点

  • 性能开销大。无论操作是读还是写,无论冲突是否真的会发生,它都会强制加锁,导致线程阻塞。
  • 如果同步代码块的执行时间很短,线程挂起和唤醒的开销(涉及用户态和内核态的切换)可能远远大于代码执行本身的时间。
  • 特别是在读多写少的场景下,多个线程其实可以安全地同时读取数据,但悲观锁依然会让他们排队等待,造成了不必要的性能损耗。

乐观锁:大胆假设,小心求证

既然悲观锁在某些场景下效率低下,有没有一种不加锁的方式来保证并发安全呢?答案就是乐观锁

什么是乐观锁?

乐观锁对并发冲突持有”乐观”的态度。它假设在操作共享资源时,大概率不会有其他线程来修改它。因此,它不会在操作前加锁,而是在提交更新的时候,去验证这个假设是否成立。

  • 如果验证通过(数据没有被其他线程修改过),则成功更新

  • 如果验证失败(数据已经被修改),则放弃本次操作,并根据具体策略决定是重试还是抛出异常。

CAS (Compare-And-Swap) 是乐观锁的核心实现。

深入理解CAS机制

我们可以用一个更直观的例子来理解CAS:

想象现在有一个共享的计数器,初始值为 100。多个线程都要对这个计数器进行 +1 操作。

线程A和线程B同时读取了计数器的当前值,都得到了 100(这是他们各自内存中的预期旧值,Old Value)。

线程A跑得快,先一步尝试执行CAS操作:

  1. **比较 (Compare)**:检查内存中的计数器值是不是它预期的 100。—— 是的,匹配!
  2. 交换 (Swap):立刻把内存中的计数器值从 100 更新为 101(这是新值,New Value)。 操作成功,计数器现在是 101

随后线程B也尝试执行CAS操作:

  1. **比较 (Compare)**:检查内存中的计数器值是不是它预期的 100。—— 不是,现在是 101 了!
  2. **交换 (Swap)**:操作失败,无法将计数器更新为 101(因为它期望的是 100)。 线程B发现操作失败,就知道有其他线程抢先修改了。这时它可以选择:
  3. **自旋 (Spin)**:重新读取当前值 101,然后再次尝试CAS操作,直到成功为止。
  4. 放弃:等待一段时间后,如果还是不成功,就放弃本次操作。

在这个例子中:

  • 共享资源:共享计数器。
  • 状态值:计数器的当前数值。
  • Compare-And-Swap:这个”先比较、再交换”的动作,就是一个完整的CAS操作。

所以,乐观锁的本质就是:不加锁,而是通过CAS操作在更新时校验数据

CAS的原子性与”无锁”之谜

你可能已经发现了一个关键问题:**”比较”和”交换”是两个步骤,如何保证这个过程的原子性?**

如果不能保证原子性,那么在”比较”和”交换“之间,另一个线程可能已经修改了值,导致数据不一致。

有人可能会说:”简单,给这个CAS操作加个锁不就行了?” 这就陷入了逻辑怪圈:我们正是为了避免加锁才使用CAS,现在为了实现CAS又要加锁,那CAS还有什么意义?

真正的答案:依赖硬件原子指令

软件层面确实无法直接保证CAS的原子性。最终,我们需要依赖于CPU硬件提供的原子指令

在Java中,CAS操作通常由sun.misc.Unsafe类中的compareAndSwapXxx系列方法提供。这些方法被native关键字修修饰,意味着它们的实现并非由Java代码完成,而是通过JNI(Java Native Interface)调用C/C++代码,最终会映射到CPU层面的一条原子指令。

例如,在X86架构的CPU上,这条指令可能是cmpxchg (Compare and Exchange)。当CPU执行这条指令时,它会锁定总线(Bus Locking)或锁定缓存行(Cache Locking)

  • 锁定总线:在执行指令期间,禁止其他CPU核心访问内存,确保了操作的绝对原子性,但开销较大。
  • 锁定缓存行:一种优化手段,只锁定包含共享变量的那个缓存行,而不是整个总线,性能更高。

质疑!CAS是真正的”无锁”吗

所以,CAS是真正的”无锁”吗?

这是一个分层级看的问题:

  • 从软件/应用层面看:是的,它是无锁的。因为它没有使用操作系统提供的Mutex(互斥量)原语,不会导致线程进入阻塞状态,避免了线程上下文切换的开销。
  • 从硬件/底层层面看:不是,它是有锁的。它依赖CPU的硬件锁(锁总线/锁缓存行)来保证原子性。从这个角度看,它更像是一种硬件级别的悲观锁,悲观地认为指令执行期间必须独占资源。

相似的分层级看问题的在下面的“CAS在数据库中的应用”章节也能看出相似之处。

结论:对于我们应用层开发者来说,可以认为CAS是一种高性能的无锁实现。我们无需纠结其底层是否”有锁”,只需知道它性能优异,且不会阻塞线程即可。

从CAS到Atomic原子类

理解了CAS,Java并发包(JUC)中的Atomic系列原子类就豁然开朗了。

AtomicInteger/AtomicLong

  • 内部维护一个由volatile修饰的intlong变量。
  • volatile保证了该变量在多线程之间的可见性
  • 当执行incrementAndGet()compareAndSet()等更新操作时,内部调用的就是Unsafe类的CAS方法,通过自旋的方式不断尝试,直到成功为止,从而保证了操作的原子性

AtomicReference

  • 原理类似,但它操作的是一个对象引用
  • CAS比较和交换的不再是数值,而是对象的内存地址。通过这种方式,可以实现对任意对象的原子性更新。

CAS在数据库中的应用

乐观锁的思想同样广泛应用于数据库。最经典的场景就是更新库存

在update前面,我们会用1个SQL查询当前的quantity,但是因为MySQL是快照读,所以不用担心。

我们更新语句如下,注意where条件语句

1
UPDATE product_stock SET quantity = (新库存) WHERE product_id = ? AND quantity = (旧库存);

这条SQL的WHERE子句quantity = (旧库存),就是CAS中的”Compare”操作。只有当数据库中当前的库存值等于我们之前读取到的”旧库存”时,更新才会成功。

  • 原子性保证:单条SQL语句的原子性由数据库系统自身保证(通过事务和锁机制)。
  • 这是无锁操作吗:同样是分层级看的问题。从我们编写SQL的角度,没有显式地使用SELECT ... FOR UPDATE这样的悲观锁。但从数据库底层来看,执行UPDATE语句时,数据库会为该行或该表加上行锁或表锁,这本质上是一种悲观锁,以防止并发更新导致的数据错乱。

更通用的做法:版本号机制

直接比较库存值可能不够健壮(后面ABA问题会讲到)。更通用的做法是在表中增加一个version字段。

SQL

1
UPDATE my_table SET column = 'new_value', version = version + 1 WHERE id = ? AND version = (旧版本号);

每次更新成功,version号都会加1。这样,任何基于旧版本号的更新尝试都会失败。

CAS的挑战与解决方案

CAS并非完美,它主要有两个经典问题:

ABA问题

什么是ABA问题?

  1. 线程1读取内存中的值为A
  2. 线程2介入,将值从A修改为B,然后又修改回A
  3. 线程1此时执行CAS操作,发现内存值仍然是A,与预期相符,于是成功更新。

对于线程1来说,它无法感知到值曾被改变过。在某些业务场景下,这会引发严重的问题。

不影响的场景:比如库存。库存从10件变成9件,又因为退货变回10件。对于后续的扣减操作,只要库存是10就可以,过程并不重要。在这个场景下,ABA问题似乎并没有那么严重。

致命的场景

  1. 比如一个栈。线程1读取栈顶元素是A,想把它换成B
  2. 但在此期间,线程2将A出栈,又压入了其他元素,最后又压入了另一个内容相同但地址不同的元素A
  3. 线程1执行CAS时发现栈顶还是A,就成功修改了。但此时的A已非彼时的A,整个栈的结构可能已经被破坏(如果是执行程序的栈帧,可能就要被注入木马程序了QAQ)

如何解决ABA问题?

答案是使用版本号。我们不再只关心值是否相等,还要关心版本是否匹配。

Java中的AtomicStampedReference就是为了解决这个问题而生的。它将值和一个”戳”(Stamp,可以理解为版本号)绑定在一起。CAS操作时,必须同时比较值和戳,都匹配才能更新成功。

自旋开销问题

CAS操作失败时,通常会进入一个”死循环”,即自旋,不断重试。

  • 如果线程竞争不激烈,锁被占用的时间很短,自旋一两次就能成功,那么它的效率非常高,因为它避免了线程阻塞和唤醒的开销。“占用的时间短,争抢度不高”是关键。
  • 但如果线程竞争非常激烈,或者一个线程持有锁的时间过长,那么其他线程就会长时间地空转自旋,白白消耗CPU资源

如何解决自旋开销问题?

这正是synchronized锁升级机制的智慧所在。synchronized在JDK 1.6之后引入了轻量级锁和偏向锁。

  • 轻量级锁:本质就是”CAS + 自旋”。
  • 锁升级:当自旋次数过多,或者竞争线程数量增加时,synchronized会认为当前场景不适合自旋,于是将轻量级锁升级为重量级锁,Heap空间创建Monitor对象,所有的争抢将会被记录在这个对象上。重量级锁会借助操作系统的Mutex原语,将等待的线程阻塞,从而释放CPU,避免空转。

展望:从synchronized到AQS

我们已经知道,synchronized通过锁升级机制,解决了CAS自旋的性能问题。但是,它依赖于操作系统的Mutex来实现最终的线程阻塞和唤醒。

那么,有没有一种纯粹在Java层面,不直接依赖操作系统Mutex,也能实现一套高效的线程阻塞唤醒机制的方案呢?—— 答案是肯定的。这就是JUC包的基石——**AQS (AbstractQueuedSynchronizer)**。像ReentrantLock, CountDownLatch, CyclicBarrier等我们熟知的并发工具,其底层原理都离不开AQS。

总结

悲观锁、乐观锁就是两个思想/态度,前者读写都要先锁定私有,再执行操作,后者读无所谓,写的话在执行前校验一下即可。

CAS就是乐观锁思想的实现,乐观锁既然是“写的话在执行前校验一下即可”,那么CAS的实现就是对校验规则的实现——Compare 当前值是否==原值 And Swap 当前值为新值。在电商、或者抢占名额的业务场景中,我们更新库存的SQL中对库存原值的比较,其实就是使用了CAS的方式。

乐观锁不是“无锁”,宏观层面我们确实使用了高性能的方式,不需要每次读写都去锁定资源,但是在底层,CAS的微观层面实现还是依赖一个锁去保证“比较+交换”的原子性。在SQL里面,我们有UPDATE语句的行锁保证原子性,在Java底层,CAS的原子性是由CPU指令保证的——通过锁Bus或者缓存行的方式。

CAS存在ABA问题,ABA问题的方式就是加上版本号去记录修改,除了对要保护的对象的值进行比较,还要对修改的次数进行比较,在SQL里面,刚刚的库存抢占案例我们就需要+version字段,在Java里面,我们可以选择AtomicStampXXX类来帮我们解决这个问题(当然,都需要从业务的角度考虑,不是说有了Stamp类我们之前其他类都不用了)。

作者

Ye Lihu

发布于

2025-06-22

更新于

2025-06-22

许可协议