深入剖析乐观锁背后的原理
前言
在并发编程中,解决共享资源线程安全主要有两大思想:悲观锁、乐观锁
悲观锁:顾名思义,就像是一个悲观的人,总会觉得别人会修改数据,所以在操作数据之前先把数据数据锁住,自己独占使用,其他线程必须等到我释放之后才可以使用,Java中的synchronized就是悲观锁的体现(之前有文章详细的介绍),虽然会保证线程安全,但是会带来线程阻塞、上下文切换、并发吞吐量低等问题
乐观锁:就是一个乐观的人,觉得别人不会同时修改数据,所以不对数据加锁,直接操作数据,更新时在检查:有没有人动过数据?若没有人动,就更新成功;如果有人动,就放弃本次修改或重试,相比于悲观锁,更适合高并发场景
一、乐观锁核心思想
1.定义
乐观锁是一种无锁并发控制思想:默认认为多线程之间很少发生数据竞争,不上锁、不阻塞,直接读写共享数据;在提交更新的那一刻,校验数据是否被其他线程修改过:
- 若未被修改:更新成功;
- 若已被修改:放弃本次更新,或自旋重试。
2.核心特征
- 不加独占锁:无线程阻塞,无队列等待;
- 事后校验:读的时候不校验,更新时才校验;
- 冲突自愈:发现竞争冲突,不阻塞,通过自旋重试解决;
- 最终一致性:不保证瞬时强一致,保证最终数据一致。
3.与悲观锁区别
悲观锁:先加锁→再操作,悲观预判必有竞争,独占资源;
乐观锁:先操作→后校验,乐观预判少有竞争,无锁并行。
更详细的对比见文章末尾
二、乐观锁两大主流实现方式
1.CAS无锁实现(Java内存并发常用)
全称:Compare And Swap,比较并交换
是 CPU 硬件级支持的原子指令,也是 Java 中乐观锁的底层基石。详细看第三点
2.版本号机制(数据库分布式并发常用)
数据表增加version版本字段,更新时携带旧版本号匹配,不匹配则更新失败。详细看第五点
三、CAS算法(Java内存并发常用)
1.定义
是CPU 硬件级支持的原子指令,也是 Java 中乐观锁的底层基石。思想就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新
2.三个核心参数
CAS(V, E, N):
- V (Variable):内存中共享变量的实际值
- E (Expected):线程预期原值(自己读取到的值)
- N (New):想要修改的新值
3.执行逻辑(原子不可分割)
当且仅当V的值等于E时,CAS通过原子方式用新值N来更新V的值,如果不等,说明已经有其他线程更新了V,则当前线程放弃更新
举一个简单的例子:线程A要改变变量 i 的值为6,i 原值为1(V=1,E=1,N=6):i与1进行比较,如果相等,则说明没有被其他线程修改,可以设置为6;如果不相等,则说明被其他线程修改,当前线程放弃更新
当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余都会失败
4.CAS为什么能实现乐观锁
- 全程没有加锁、没有阻塞;
- 依靠读取原值 → 运算 → CAS 校验更新三步;
- 失败后不阻塞,循环重试(自旋),直到更新成功。
5.CPU底层支撑
CAS 不是操作系统实现,也不是 Java 语言实现,依赖 CPU 总线锁 / 缓存锁:
- 多核 CPU 下,通过缓存行锁定保证 CAS 指令原子性;
- 避免多线程同时修改同一缓存行数据,从硬件层面保障操作不可分割。
四、Java中CAS源码剖析
JUC 包下原子类全部基于 CAS 实现乐观锁:AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference等。
1.Atomiclnteger自增底层
AtomicInteger count = new AtomicInteger(0); count.incrementAndGet();底层核心源码(JDK8)
public final int incrementAndGet() { return getAndAdd(1) + 1; } public final int getAndAdd(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta); }里面涉及到的:
- Unsafe 类:Java 底层不安全类,可以直接操作内存、调用 CPU 原子指令;
- valueOffset:变量在内存中的偏移地址,直接定位内存数据。
Unsafe本地方法自选逻辑:
核心流程:循环读取内存最新值、尝试CAS更新、失败就继续循环(自旋乐观锁)直到成功
public final int getAndAddInt(Object o, long offset, int delta) { int v; // 自旋循环:CAS失败就一直重试 do { // 从内存中读取当前最新值 v = getIntVolatile(o, offset); // CAS比较并交换:内存值是否等于v,是则+delta,否则循环重试 while (!compareAndSwapInt(o, offset, v, v + delta)); return v; }2.volatile配合CAS的作用
原子类中变量都被volatile修饰:
volatile 保证可见性:线程每次都从主内存读最新值,不从工作内存缓存读;
volatile 不保证原子性:所以必须靠 CAS 补全原子操作;组合:volatile + CAS实现无锁原子并发。
private volatile int value;五、版本号机制(数据库场景)
1.实现思路
(1)数据库表新增字段version int default 1;
(2)查询数据时,同时查出当前version;
(3)更新数据时,必须携带旧版本号作为条件;
(4)更新成功则版本号 + 1,版本不匹配则更新失败。
2.SQL示例
(1)查询
select id, name, version from user where id = 1;(2)更新
update user set name = '新名称', version = version + 1 where id = 1 and version = 旧版本号;3.执行逻辑
- 线程 A 查到 version=1;
- 线程 B 同时查到 version=1,并抢先更新为 version=2;
- 线程 A 再用 version=1 去更新,条件不匹配,更新行数为 0,失败;
- 业务层可选择:抛出异常、重试、放弃操作。
六、CAS乐观锁三大致命问题
1.ABA问题
(1)现象
线程 1 读取值 A,准备 CAS 更新;线程 2 先把 A 改成 B,又改回 A;线程 1 CAS 发现还是 A,认为没被修改,正常更新,忽略了中间被篡改的过程。
(2)危害
会导致数据状态被覆盖,业务逻辑出错(如资金、库存)。
(3)解决方案
增加版本号:每次修改版本自增,即使值变回 A,版本也不同;
JDK 工具类:
AtomicStampedReference、AtomicMarkableReference携带版本戳 / 标记位,不仅比较值,还比较版本。
2.循环自旋消耗CPU
(1)现象
CAS 冲突严重时,会无限循环重试,一直占用 CPU,导致 CPU 飙高。
(2)解决
- 限制自旋次数,超过次数改用悲观锁;
- 自适应自旋(JDK 锁优化思想)。
3.只能保证单个变量原子性
(1)现象
CAS 只能对一个变量做原子更新;如果需要同时修改多个共享变量,CAS 无法保证原子性。
(2)解决
- 使用
AtomicReference封装对象,把多个变量装进一个对象; - 改用悲观锁
ReentrantLock/synchronized。
七、乐观锁 VS 悲观锁
| 对比层面 | 乐观锁 | 悲观锁 |
|---|---|---|
| 加锁思想 | 无锁,事后校验 | 独占锁,事前加锁 |
| 底层实现 | CAS、版本号 | synchronized、ReentrantLock |
| 线程状态 | 自旋重试,不阻塞 | 阻塞挂起,让出 CPU |
| 并发性能 | 高,无阻塞开销 | 低,有上下文切换 |
| 一致性 | 最终一致 | 强一致性 |
| 开销 | 竞争小开销低,竞争大耗 CPU | 加锁、阻塞、切换开销固定 |
| 典型场景 | 读多写少、缓存、计数 | 写多读少、资金交易、库存扣减 |
