Java并发深度总结:乐观锁CAS
执着于理想,纯粹于当下。
内容
1. 悲观锁与乐观锁
-
悲观锁:悲观锁认为被它保护的数据是极其不安全的,每时每刻都有可能变动。获取共享资源的线程对共享资源加锁,其它线程被阻塞,其他只能等待锁被释放才可以执行。 Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
-
乐观锁:乐观锁和悲观锁对应,它认为数据的变动不会太频繁。因此,所以不会对共享资源上锁。线程在更新共享资源时,才会去校验在此期间有没有其他线程去更新这个数据。 Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
简单来说:
- 悲观锁认为并发严重,每次都对共享资源加锁。
- 乐观锁认为并发不那么严重,所以让线程不断去尝试更新。
2. CAS的实现
CAS(Campare and Swap),翻译过来就是 “ 比较并替换 ” ,是乐观锁的一种实现方式。
CAS操作包含三个操作操作数:
- 要更新的字段内存位置V
- 旧的预期原值A
- 要修改的新值B
CAS操作过程:
- 首先从内存位置V,读取预期原值A;
- 更新数据时,再次读取内存位置V的值,如果该值与预期原值A相匹配,将内存位置V的值更新为新值B;
- 若从内存位置V读取的值与预期原值A不同(其他线程在此期间对内存V处的值进行了修改),则从步骤(1)重试整个过程(自旋),直到修改成功。
Tip:在更新数据时,比较并更新是一个原子操作。
Java中的原子变量类都使用了CAS机制:
class CasMain {
static AtomicInteger num = new AtomicInteger(0);
public void add() {
while (true) {
// 读取原值A
int prev = num.get();
// 要修改的值 B
int next = prev + 1;
// 原子操作:比较并替换 false:CAS失败,自旋重试 ; true:CAS成功,结束循环
if (num.compareAndSet(prev, next)) {
break;
}
}
}
}
// CAS 测试
public class CasTest{
public static void main(String[] args) throws Exception{
CasMain cas = new CasMain();
ArrayList<Thread> threads = new ArrayList<>();
for (int i = 0; i < 100; i++) {
threads.add(new Thread(() -> {
for (int j = 0; j < 10000; j++) {
cas.add();
}
}));
}
for (Thread t : threads) {
t.start();
t.join(); // 等待所有线程执行完毕
}
System.out.println(CasMain.num); // 输出:1000000
}
}
注意:上述测试代码只是用于演示CAS修改共享资源的过程。CAS并不适用于线程竞争比较激烈的多线程环境。
3. CAS 实现原理
前面我们提到过,CAS中的 “比较并替换” 是一个原子操作,那么以AtomicInteger 为例,它的比较并替换,也就是compareAndSet()方法是怎么做到具有原子性的?
// AtomicInteger 的 compareAndSet 方法源代码:
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
可以看到 compareAndSet 方法是通过 Unsafe类的 compareAndSwapInt 方法实现的。
Unsafe类位于rt.jar包,该类提供了硬件级别的原子操作。
Unsafe类中的方法能够通过直接操作字段偏移量来修改字段的值,相当于通过“指针”来操作字段,字段的偏移量需要自己计算,因此如果计算错误那么会带来一系列错误,比如会导致整个JVM实例崩溃等严重问题。因此Java并不鼓励使用该类,甚至命名为“Unsafe”。
Unsafe的compareAndSwapInt方法是一个native的方法,底层通过 C、C++调用操作系统的lock、cmpxchg 指令实现的。
- cmpxchg :比较并交换操作数。
- lock:确保cmpxchg 是原子操作。
自己实现一个AtomicInteger:
import sun.misc.Unsafe;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public class MyAtomicInteger {
private volatile int value;
private static Unsafe unsafe;
private static long valueOffset; // value字段偏移量
static {
try {
// 反射获得Unsafe实例对象
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
unsafe = (Unsafe) f.get(null);
//计算 value 字段 的 偏移量
valueOffset = unsafe.objectFieldOffset(MyAtomicInteger.class.getDeclaredField("value"));
} catch (Exception e) {
e.printStackTrace();
}
}
public MyAtomicInteger(int value) {
this.value = value;
}
public int getValue() {
return value;
}
// 减法
public void decrement(int amount) {
while (true) {
// 原值 A
int prev = getValue();
// 要修改的值 B
int next = prev - amount;
// 比较并替换
if (unsafe.compareAndSwapInt(this, valueOffset, prev, next)) {
break;
}
}
}
}
// 测试
class MyAtomicIntegerTest implements Runnable{
private static MyAtomicInteger myAtomicInteger = new MyAtomicInteger(1000000);
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
myAtomicInteger.decrement(1);
}
}
public static void main(String[] args) throws Exception{
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
threads.add(new Thread(new MyAtomicIntegerTest()));
}
for (Thread t : threads) {
t.start();
t.join();
}
System.out.println(myAtomicInteger.getValue()); // 0
}
}
4. CAS的三大问题
4.1 ABA问题
CAS在更新数据时,会将内存位置V的值和原值A比较,只有这两个值是相同的,才会去更新数据。
ABA问题指的是:CAS更新数据时,只知道内存位置V的值有没有发生改变,在内存位置V的值和原值A相同的情况下,无法感知是否有其他线程将内存位置V的值修改过(其他线程可能在此期间,先把内存V的值从A改为B,再由B改为A的情况)。
ABA问题的具体过程:
- 线程1读取了数据A;
- 线程2读取了数据A;
- 线程2通过CAS比较,发现值是A没错,可以把数据A改成数据B;
- 线程3读取了数据B;
- 线程3通过CAS比较,发现数据是B没错,可以把数据B改成了数据A;
- 线程1通过CAS比较,发现数据还是A没变,就写成了自己要改的值;
可以发现,当出现ABA问题时,CAS操作依然是成功的。下面举一个实例,说明ABA在现实事件中引发的问题:
假设有一个遵循CAS原理的提款机,小灰有100元存款,要用这个提款机来提款50元。
由于提款机硬件出了点小问题,小灰的提款操作被同时提交两次,开启了两个线程,两个线程都是获取当前值100元,要更新成50元。
.
理想情况下,应该一个线程更新成功,另一个线程更新失败,小灰的存款只被扣一次。
线程1首先执行成功,把余额从100改成50。线程2因为某种原因阻塞了。这时候,小灰的妈妈刚好给小灰汇款50元。
.
线程2仍然是阻塞状态,线程3执行成功,把余额从50改成100。
线程2恢复运行,由于阻塞之前已经获得了“当前值”100,并且经过compare检测,此时存款实际值也是100,所以成功把变量值100更新成了50。
因此,我们希望,只要在比较更新时,发现原值被修改过,就算这一次的CAS失败。具体的解决方案是:每次操作加版本号。
也就是说,在比较更新时,不仅比较原值A,还要比较原值A的版本号是否和内存位置V的值的版本号是否相同。
如:1A --> 2B --> 3A ;1A != 3A 更新失败。
Tip:
在Atomic包中,提供了一个现成的AtomicStampedReference类来解决ABA问题(除了对更新前的原值进行比较,也需要用更新前的 stamp标志位来进行比较)。
4.2 循环时间长开销大
由于线程并不会阻塞,如果CAS自旋长时间不成功,这会给CPU带来非常大的执行开销。因此CAS不适用于线程竞争激烈的多线程环境。
4.3 只能保证一个共享变量的原子操作
CAS操作单个共享变量的时候可以保证原子的操作,多个变量可以加锁 或者使用JDK 5之后的AtomicReference类,
AtomicReference类来保证引用对象之间的原子性,因此可以把多个变量放在一个对象里来进行CAS操作。
5. CAS 和 synchronized 的应用场景
-
CAS适用于读多写少的场景(冲突一般较少)
-
synchronized适用于写多读少的场景(冲突一般较多)
对于资源竞争较少的情况,使用synchronized同步锁进行线程阻塞和唤醒切换会导致用户态内核态间的切换,从而额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
对于资源竞争严重的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
本文地址:https://blog.csdn.net/qq_42080839/article/details/110352073
上一篇: 常用的字符转换
下一篇: 【老板小剧场】02-要学的东西好像还挺多