悲观锁、乐观锁和CAS混淆不清?一文讲清楚它们的关系
在这篇文章中,我将介绍理解并发编程中的核心概念——悲观锁、乐观锁和CAS机制、探讨它们各自的原理、优缺点以及在实际场景中的应用,例如数据库库存更新和Java中的Atomic原子类。此外,我还会详细解析CAS的ABA问题与自旋开销问题,并提供相应的解决方案。
悲观锁:宁可错杀,不可放过
什么是悲观锁?
顾名思义,悲观锁对并发冲突持有”悲观”的态度。它认为,只要是多线程访问共享资源,就极大概率会发生数据冲突。因此,为了绝对安全,它采取了最严格的预防措施:每次访问共享资源前,必须先获取锁。
一个线程成功获取锁之后,才能对资源进行操作。其他任何试图访问该资源的线程,都必须挂起(阻塞),进入等待队列,直到锁被释放。
Java中的synchronized
关键字和ReentrantLock
的默认实现,都是典型的悲观锁。
悲观锁的优点与缺点
优点:实现简单,逻辑清晰,能有效防止数据冲突,保证数据一致性。
缺点:
- 性能开销大。无论操作是读还是写,无论冲突是否真的会发生,它都会强制加锁,导致线程阻塞。
- 如果同步代码块的执行时间很短,线程挂起和唤醒的开销(涉及用户态和内核态的切换)可能远远大于代码执行本身的时间。
- 特别是在读多写少的场景下,多个线程其实可以安全地同时读取数据,但悲观锁依然会让他们排队等待,造成了不必要的性能损耗。
乐观锁:大胆假设,小心求证
既然悲观锁在某些场景下效率低下,有没有一种不加锁的方式来保证并发安全呢?答案就是乐观锁。
什么是乐观锁?
乐观锁对并发冲突持有”乐观”的态度。它假设在操作共享资源时,大概率不会有其他线程来修改它。因此,它不会在操作前加锁,而是在提交更新的时候,去验证这个假设是否成立。
如果验证通过(数据没有被其他线程修改过),则成功更新
如果验证失败(数据已经被修改),则放弃本次操作,并根据具体策略决定是重试还是抛出异常。
CAS (Compare-And-Swap) 是乐观锁的核心实现。
深入理解CAS机制
我们可以用一个更直观的例子来理解CAS:
想象现在有一个共享的计数器,初始值为
100
。多个线程都要对这个计数器进行+1
操作。线程A和线程B同时读取了计数器的当前值,都得到了
100
(这是他们各自内存中的预期旧值,Old Value)。线程A跑得快,先一步尝试执行CAS操作:
- **比较 (Compare)**:检查内存中的计数器值是不是它预期的
100
。—— 是的,匹配!- 交换 (Swap):立刻把内存中的计数器值从
100
更新为101
(这是新值,New Value)。 操作成功,计数器现在是101
。随后线程B也尝试执行CAS操作:
- **比较 (Compare)**:检查内存中的计数器值是不是它预期的
100
。—— 不是,现在是101
了!- **交换 (Swap)**:操作失败,无法将计数器更新为
101
(因为它期望的是100
)。 线程B发现操作失败,就知道有其他线程抢先修改了。这时它可以选择:- **自旋 (Spin)**:重新读取当前值
101
,然后再次尝试CAS操作,直到成功为止。- 放弃:等待一段时间后,如果还是不成功,就放弃本次操作。
在这个例子中:
- 共享资源:共享计数器。
- 状态值:计数器的当前数值。
- 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
修饰的int
或long
变量。 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读取内存中的值为
A
。 - 线程2介入,将值从
A
修改为B
,然后又修改回A
。 - 线程1此时执行CAS操作,发现内存值仍然是
A
,与预期相符,于是成功更新。
对于线程1来说,它无法感知到值曾被改变过。在某些业务场景下,这会引发严重的问题。
不影响的场景:比如库存。库存从10件变成9件,又因为退货变回10件。对于后续的扣减操作,只要库存是10就可以,过程并不重要。在这个场景下,ABA问题似乎并没有那么严重。
致命的场景:
- 比如一个栈。线程1读取栈顶元素是
A
,想把它换成B
- 但在此期间,线程2将
A
出栈,又压入了其他元素,最后又压入了另一个内容相同但地址不同的元素A
。 - 线程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类我们之前其他类都不用了)。
悲观锁、乐观锁和CAS混淆不清?一文讲清楚它们的关系
https://yelihu.github.io/2025/06/22/悲观锁、乐观锁和CAS混淆不清?一文讲清楚它们的关系/