今天继续更新并发锁机制系列,前两篇文章更新了悲观锁中的Synchronized
和ReentrantLock
,相比于悲观锁,乐观锁认为并发冲突是小概率事件。在访问共享资源时不会加锁,而是通过一定机制(如CAS机制、版本号机制)来检测是否存在其他线程的干扰。
本文将从CAS机制与自旋、Atomic原子类、版本号机制三部分来探究乐观锁。
- 1.CAS机制与自旋
- 1.1 CAS机制
- 1.2 自旋
- 2.Atomic原子类
- 3.版本号机制
- 3.1 为什么要引入版本号机制
3.2 版本号机制执行流程
1.CAS机制与自旋
1.1 CAS机制
CAS(Compare And Swap,比较并交换) 是一种由 CPU 原生指令支持的原子操作,常用于实现并发环境下的非阻塞算法(Lock-Free Algorithm)。在 Java 中,CAS 被广泛用于实现乐观锁机制,它的最大特点是无需加锁,就可以确保变量的并发安全更新。
CAS 操作涉及三个核心参数:
参数 | 含义 |
---|---|
V(Value) | 变量当前在主内存中的真实值 |
E(Expected) | 操作者认为变量应该具有的期望值 |
N(New) | 若变量值等于期望值时,想要写入的新值 |
这三个值共同决定了 CAS 操作的行为。
可以将 CAS 的核心逻辑想象成一个函数,伪代码如下:
boolean CAS(address,expectValue,swapValue) {
if(&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
(1)进入CAS操作前:线程 T1 尝试更新一个共享变量 x
,首先会读取该变量在主内存中的值,这个值被作为当前线程的“预期值(Expected,E)”。
(2)进入CAS操作时:线程进入 CAS 操作时,CPU 会再次读取变量当前的实际值 V,并与自己保留的预期值 E 做比较。如果相等,说明在读取到 V 到此刻之间,变量未被其他线程修改过,则执行写操作,将变量更新为 N。
事实上CAS 操作是一条由 CPU 硬件支持、原子的硬件指令,而这一条指令就可以完成上述伪代码的功能,所以它不会发生线程上下文切换或中断,保证了线程安全性。
1.2 自旋
自旋(Spin)是指线程在尝试某项操作失败时,并不直接挂起或阻塞自己,而是在 CPU 上持续重试操作的一种行为。
在 CAS 操作中,自旋表现为:如果 CAS 更新失败,则再次获取当前值,并继续尝试 CAS,直到成功为止或超过重试上限。
自旋可以避免线程阻塞,但是也会带来性能问题:
- CPU 空转,浪费资源:若竞争严重,线程会在 CPU 上不断循环,占用 CPU 时间片,导致资源紧张。
- 没有退出机制可能导致活锁:如果没有设置合理的重试次数或退避机制,线程可能永远在重试,形成“活锁”。
所以通常会对自旋做出以下优化:
- 设置最大重试次数
int retries = 0;
while (!compareAndSet(expected, update) && retries++ < MAX_RETRIES) {
// optional: yield(), sleep(), etc.
}
- 加入退避策略:在每次失败后,等待一小段时间再重试。
Thread.sleep(ThreadLocalRandom.current().nextInt(1, 10)); // 简单退避
- 自适应自旋:JDK、HotSpot 等 JVM 实现中会根据前一次是否成功、CPU核数等因素动态调整是否自旋。
2.Atomic原子类
CAS 是一种由 CPU 提供的原子操作指令,那么我们该如何使用CAS机制呢?
在Java中我们是通过JDK提供的原子类(java.util.concurrent.atomic
) 和JVM 的封装层(Unsafe)来间接使用这些指令。
Java 并发包中封装了多种 CAS 支持的原子类:
类名 | 支持的数据类型 | 特点 |
---|---|---|
AtomicInteger | int | 最常用 |
AtomicLong | long | 高精度计数 |
AtomicBoolean | boolean | 并发状态标志 |
AtomicReference |
对象引用 | 用于任意对象 |
AtomicStampedReference |
对象 + 版本号 | 解决 ABA 问题 |
AtomicMarkableReference |
对象 + 布尔标记 | 轻量状态管理 |
比如要把一个值为 0 的Integer变量修改为 1,就可以使用AtomicInteger
来保证并发安全。
AtomicInteger atomicInt = new AtomicInteger(0);
boolean success = atomicInt.compareAndSet(0, 1);
compareAndSet
是怎么实现的?
以 AtomicInteger
为例,其核心方法如下:
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
这里的Unsafe
是JVM 内部保留的,拥有直接操作内存、绕过访问控制、执行底层并发原语等能力,通过反射得到。
而compareAndSwapInt(...)
是个 native 方法,会调用 JVM 的本地 C++ 实现(比如 HotSpot 中的 unsafe.cpp
文件,会映射到CPU的原子指令)。
native 方法 是 Java 中一种调用非 Java 语言实现的方法,通常用 C/C++ 编写。
![link-1][]原子类内部实现机制
不只是AtomicInteger
,所有的 Java 原子类(如 AtomicInteger
, AtomicReference
)背后都调用了 Unsafe
提供的 compareAndSwapXXX()
方法。
3.版本号机制
3.1 为什么要引入版本号机制
CAS机制虽然基于CPU 原子指令实现了无所并发,但是也存在局限性:
(1)ABA问题CAS 本质只比较当前值是否与预期值相等。如果一个变量值被修改后又恢复为原值(例如 A → B → A),CAS 是无法感知这种“修改再还原”的过程的。
来看一个例子:假设有存款1000,此时有两个线程同时读取到账户余额为 1000,并准备各自扣除 500 元。
假设线程 t1 扣款成功后余额变为 500,但在 t2 执行前,账户又被其他转账操作充值500,存款变回 1000。此时线程 t2 仍然认为余额未变,继续扣款500,最终导致账户被错误扣款两次共计 1000 元。
(2)只能作用于单一变量:CAS 操作仅支持单变量的原子性更新,不支持多个字段或对象的联合更新。
例如,如果需要同时更新一个对象的两个字段(如余额与积分),使用 CAS 无法保证这两个操作的一致性。
3.2 版本号机制执行流程
版本号机制是指在共享资源(如数据库记录或内存对象)中引入一个额外的 version
字段,用于标识该数据当前状态的“版本”。每次更新时,通过比较版本号判断数据是否在此期间被其他线程修改。
来举一个简单的例子说明版本号机制的执行流程。
假设我们有一个用户信息表,结构如下:
字段名 | 值 |
---|---|
id | 1001 |
user@abc.com | |
version | 1 |
(1)操作员 A 与操作员 B 同时读取用户信息:操作员 A 打算将邮箱改为 a@example.com
,操作员 B 打算将邮箱改为 b@example.com
,两人读取到的数据都是:email=user@abc.com, version=1
(2)操作员 A 修改后,执行更新语句:
UPDATE user
SET email = \'a@example.com\', version = version + 1
WHERE id = 1001 AND version = 1;
数据库记录被成功更新为:email =a@example.com
,version = 2。
(3)操作员 B 随后也提交更新:
UPDATE user
SET email = \'b@example.com\', version = version + 1
WHERE id = 1001 AND version = 1;
由于数据库中当前版本号为 2
,而 B 的更新条件仍然是 version = 1
,所以这条 SQL 更新失败,B 的更改被驳回。
最后结果就是:操作员 A 的修改先提交成功,操作员 B 的提交被乐观锁拦截,避免了“后来者覆盖先提交者”的风险,系统数据保持一致,更新逻辑合理
这就相当于应用层实现了一种“版本比对的 CAS(Compare-And-Swap)”。
其实MySQL中的MVCC机制就是采用了基于版本控制的乐观锁思想。