剖析悲观锁与乐观锁:从宏观概念到CAS的微观实现
从面试官的角度深入理解悲观锁与乐观锁
简历就是秒杀项目,高并发高可用,面试一问悲观锁和乐观锁是啥,你绞尽脑汁想了半天就蹦出几个词…
相信很多程序员都有过这样的经历:简历上写着”精通Java”、”精通高并发”、”精通JUC”,但面试时被问到悲观锁和乐观锁的具体实现时,却支支吾吾说不出个所以然。今天我们就来深入剖析这两个重要概念。
什么是悲观锁?
悲观锁的核心思想很简单:悲观地认为如果不严格加锁,那么多线程执行一定会出问题。
因此,悲观锁的策略是:
- 每次访问共享资源都必须加锁
- 线程必须获得锁之后才能正常执行
- 如果获取不到锁,线程就阻塞等待
悲观锁确实好使,但它也确实慢。为什么慢呢?
- 大量读操作场景:如果程序中存在大量的读操作,实际上没有必要每次读的时候都加锁
- 同步代码块执行时间短:如果同步代码块执行的时间非常短,远小于线程阻塞切换的时间,那加锁就得不偿失了
乐观锁:不加锁的解决方案
那有没有一种方法可以不加锁就对多线程访问共享资源做出限制呢?有!这就是乐观锁。
CAS(Compare And Swap)就是乐观锁的一个经典实现。
CAS的工作原理
让我用一个生动的例子来解释CAS的工作原理:
想象女神的门口有个牌子:
- 牌子是0:代表空闲
- 牌子是1:代表女神比较繁忙
当A、B两个线程看到门上的牌子是0时:
- A线程快速跑过去,把牌子上的0改成了1,然后进入女神房间
- B线程走到门口一看,牌子是1不是0,所以B进不去了
对应到代码里:
- 女神 = 共享资源
- 牌子上的值 = 状态值
- 0 = oldValue(期望值)
- 1 = newValue(新值)
CAS的三个步骤
Compare And Swap:
- Compare(对比):对比牌子上的值和自己第一次看到的oldValue是否一致
- Swap(交换):如果一致,就将牌子上的值和自己的newValue交换
当线程B来对比时,发现它的oldValue是0,但牌子上已经是1了,不一致!这说明已经有线程在访问共享资源了。
线程B进不去怎么办?可以自旋等待,不断尝试用CAS进行修改,直到线程B耐心耗尽为止。
乐观锁的本质
乐观锁的本质其实是不加锁。当线程想要访问共享资源时,总是乐观地认为没有人和它竞争,所以不会加锁,只是比较状态值是否等于预期值:
- 相等:说明真的没有被其他线程修改,然后修改状态值并访问共享资源
- 不相等:说明被其他线程修改了,需要重试
CAS的原子性保证
等等,这里有个问题:Compare And Swap是先比较再交换,这是两步操作,怎么保证原子性?
有人可能会说:”保证原子性你加锁就行了”。但这不是扯淡吗?CAS就是为了在不加锁的情况下保证并发安全,为了不加锁得先加把锁?
硬件层面的支持
在Java中,直接使用CAS需要调用Unsafe类的方法:
compareAndSwapInt()
compareAndSwapLong()
compareAndSwapObject()
这些方法都是由native
修饰的本地方法,也就是说是C++实现的。
但即便用C++代码写,比较交换也是两步操作,C++也没有办法保证原子性。怎么办?
这里的C++本地方法依赖了CPU的原子指令。比如在X86架构下,指令是compareAndExchange
,这个指令是由硬件直接支持、确保原子性的。
硬件是怎么做的呢?其实是:
- 通过锁定总线避免其他CPU访问共享资源
- 或者锁定缓存行来实现
CAS真的无锁吗?
万万没想到,一个简单的CAS操作软件居然是无法实现的,最终还是要依赖硬件来实现。
那CAS真的无锁吗?
从硬件层面看
锁定总线或者锁定缓存行的操作其实就是硬件锁。想想之前悲观锁的概念,是不是硬件层面悲观地认为不锁定总线就会出现线程安全问题,所以做了锁定总线的操作?
所以从硬件上来看,CAS其实还是加锁的悲观锁。
从软件层面看
但是硬件的操作性能很高,代价很低,而且不阻塞线程。从软件层面来看,它没有用操作系统的mutex,避免了软件层面的锁竞争和线程阻塞问题。
所以从软件层面上来说,它就是无锁的。
Atomic类的实现原理
当你了解了CAS,你就自然而然地了解了AtomicInteger是怎么实现的:
java
1 | // AtomicInteger的实现原理 |
- volatile保证可见性
- CAS保证原子性
AtomicLong的实现你也知道了,无非就是换了个long类型的变量。
AtomicReference的实现
那么问题来了:AtomicReference能保证原子地修改一个对象,它是怎么实现的?
答案是:AtomicReference比较交换比较的是对象的地址。
数据库层面的乐观锁
除了Java代码层面的乐观锁,数据库操作层面也可以采用CAS的思想实现乐观锁。
库存场景示例
sql
1 | UPDATE 某张表 SET 库存 = 新值 WHERE 库存 = 旧值 |
当库存等于之前的旧值时才能修改为新值,这就是CAS。
版本号机制
更常用的是给表里增加一个版本号字段:
sql
1 | UPDATE 某张表 SET 库存 = 新值, 版本号 = 版本号 + 1 WHERE 版本号 = 旧版本号 |
怎么保证原子性? 单条SQL的原子性是由数据库直接保证的,所以你不需要考虑。
数据库层面真的无锁吗?
从数据库层面来看,执行UPDATE语句需要加行锁或者表锁。以加行锁为例,为什么加行锁?因为数据库悲观地认为不加行锁就会出现并发安全问题。
所以:
- 从数据库底层来看:还是加锁了(悲观锁)
- 从上层的SQL来看:没加锁
多层级视角的理解
CAS到底是乐观还是悲观?加锁还是无锁?从不同的角度、不同的层级来看就是不一样的:
- 对我们写业务代码这种偏上层的开发来说:CAS就是无锁的
- 从底层的视角来看:就未必是无锁了
你也不用纠结CAS是否真的无锁,你只需要知道它性能很好就可以了。
CAS的经典问题
ABA问题
CAS会产生一个经典问题:ABA问题。
当线程1读取一个值为A,想修改为C,但线程2拿到时间片后把值从A修改为B,然后又修改为A。当线程1拿到时间片执行CAS操作时,发现还是A,以为没有被修改过,其实已经被修改过了。
库存场景的ABA
第一个线程读取库存为100,然后第二个线程扣减了库存,然后又退货了给库存再加回100。第一个线程拿到时间片后发现库存还是100,继续操作。
这种情况下ABA问题其实不需要解决。
栈操作的ABA
线程1读取栈顶元素为A,然后线程2做了两次出栈操作。线程1的CAS对比发现栈顶还是A,这个时候线程1将A修改为B,修改的那个A还是曾经读取的那个A吗?
并不是!这里就很容易出问题,因为这是两个不同的元素。
ABA问题的解决方案
CAS只进行值的比较,值相同但未必就是同一个数据。怎么解决?
一般会使用版本号,版本号一直递增,把值对比改为版本号的对比就可以了。这也是AtomicStampedReference
的原理。
自旋性能问题
CAS操作往往是配合自旋一块用的。当线程CAS操作失败了就不断地自旋重试,如果长时间不成功就很浪费CPU。
这个问题怎么解决呢?
synchronized的轻量级锁就是CAS加自旋,自旋的时间长了或者线程竞争大了就会浪费CPU,然后升级为重量级锁。
synchronized的重量级锁通过操作系统的mutex,加上锁监视器的等待队列、对象池等,实现了一套线程阻塞唤醒的机制来避免长时间自旋导致的CPU性能问题。
总结
悲观锁和乐观锁的核心区别在于对并发冲突的态度:
悲观锁:
- 悲观地认为一定会发生冲突
- 每次访问都加锁
- 性能开销大,但安全性高
乐观锁(CAS):
- 乐观地认为不会发生冲突
- 不加锁,只在更新时检查
- 性能好,但有ABA问题和自旋开销
无论是悲观锁还是乐观锁,在不同的层级都可能有不同的实现方式。关键是要根据具体的业务场景选择合适的并发控制策略。