CAS意为Compare and Swap(比较并交换),是设计并发算法时使用的一种技术。基本上,CAS将变量值与预期值进行比较,如果值相等,则将变量值交换为新值。没有锁的状态下可以保证多个线程对同一个值的更新的线程一致性
比较并交换过程
线程1想对 i 进行修改操作,那么先读取i的当前值E,然后对其进行修改操作得到结果值V,接下来需要将修改后的值写入到i,才算完成修改,如果是CAS,那么此时在写入之前再去读取i的值,记为N,接下来比较E和N,如果E和N相等,说明没有其他线程动过i的值,那么此时直接将i更新为V,完成修改;如果E和N不相等,说明有其他线程修改过,于是继续重复开始的操作,直到E和N相等为止,然后写入V完成修改。整个过程中是不需要加锁的。
Check Then Act
下面代码中:lock()首先判断locked是否为false,如果为false,则退出while循环,并且将locked设为true,表示上锁成功。这就是一个典型的Check Then Act操作。
如果多个线程可以访问同一个ProblematicLock实例,则不能保证lock()
方法能够正常工作。例如:如果线程A检查locked的值并发现它为false(预期值),那么它将退出while循环以执行该检查。如果在线程A将locked的值设置为true之前,线程B也检查了locked值,那么线程B也将退出while循环以执行该检查,存在竞态条件。
public class ProblematicLock {
private volatile boolean locked = false;
public void lock() {
while(this.locked) {
// busy wait - until this.locked == false
}
this.locked = true;
}
public void unlock() {
this.locked = false;
}
}
线程阻塞
当两个线程试图同时进入同步代码块时,其中一个线程将被阻止,另一个线程被允许进入同步块。当进入同步块的线程再次退出该同步代码块时,之前等待的线程被允许进入同步代码块。如果线程被允许访问,那么进入同步代码块并没有很大的花销。但是如果由于另一个线程已经在同步代码块内执行而导致自己线程被阻塞,那么阻塞线程的代价很高。此外,当同步代码块再次释放时,无法保证被阻塞线程何时被解除阻塞。这通常取决于操作系统或执行平台来协调阻塞线程的解除阻塞。当然,被阻塞的线程被解除阻塞并允许进入不会花费几秒钟或几分钟的时间,但被阻塞的线程可能会在访问共享数据结构时浪费一些时间。下图说明了这一点:
同时,阻塞线程会导致上下文切换,降低性能,而CAS不会。
硬件提供原子CAS操作
现代CPU对原子性的CAS操作提供了内置支持,CAS操作可以被用在一些情况下替代同步代码块或者其他阻塞的数据结构。CPU会保证同一时间只有一个线程能执行CAS操作,甚至跨CPU的多个核。
当使用硬件/CPU提供的CAS功能而不是操作系统或执行平台提供的同步、锁、互斥等原语时,操作系统或执行系统不需要处理线程的阻塞和解除阻塞。这会缩短线程等待执行CAS操作的时间,从而减少拥塞并提高吞吐量。如下所示:
如图所示,试图进入共享数据结构的线程从未被完全阻塞。它一直尝试执行CAS操作,直到成功并被允许访问共享数据。线程可以进入共享数据结构之前的延迟将会被最小化。
当然,如果线程在CAS的重复执行中等待很长时间,它可能会浪费大量的CPU周期,而这些周期本来可以用于其他任务(其他线程)。但在许多情况下,情况并非如此。这取决于共享数据结构被另一个线程使用的时间。实际上,共享数据结构的使用时间并不长,因此上述情况不应经常发生。但同样,这取决于具体情况、代码、数据结构、尝试访问数据结构的线程数、系统负载等。相反,被阻塞的线程则根本不使用CPU。
CAS操作不涉及到用户态到内核态的切换
CAS存在的问题
- ABA:根据具体场景看是否会影响执行的结果。
- 自旋长时间消耗cpu资源
- 只能对一个共享变量操作,且存在可见性问题
ABA 问题
问题描述:
设想一个线程(线程 A)读取某个变量的值(假设为 A)。随后,这个变量的值被另一个线程(线程 B)修改为 B,然后又被修改回 A。当线程 A 重新检查这个变量的值时,它发现值仍然是 A,因此认为它可以安全地执行某个操作(如更新或删除),而实际上这个值在其间经历了变化。
例如:假设有一个变量 x,其初始值为 10:
- 线程 A 读取 x 的值(10)。
- 线程 B 将 x 修改为 20。
- 线程 B 又将 x 修改回 10。
- 线程 A 决定基于它读取的值(10)执行某个操作(例如,增加 5),而此时它并不知道 x 在它检查期间经历了变化。
这种情况下,线程 A 的操作可能会导致逻辑错误或数据不一致,因为它没有意识到在它检查和操作之间,变量的状态已经被改变过。
如何解决ABA问题
- 将值加一个版本号或者时间戳,当任何一个线程对值进行修改时都会更改版本号,比较值时需要同时比较版本号和值
- 使用一些高级的并发数据结构,例如 AtomicReference,AtomicStampedReference等。它可以封装一个对象并附加一个版本号,从而防止 ABA 问题。
- 使用锁机制:在某些情况下,使用传统的锁(如 synchronized 或 ReentrantLock)可以更简单地避免 ABA 问题,但是会引入一定的性能开销
大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
CAS 的 ABA 问题
虽然 CAS 是一种用于实现无锁数据结构的有效机制,但它并不能完全避免 ABA 问题
可见性问题
CAS 操作虽然能够保证原子性,但可能会在可见性方面带来问题。为了确保线程之间的数据一致性和可见性,通常需要结合其他同步机制或设计模式。
public class CASVisibilityProblem {
private int value = 1;
static Unsafe unsafe;
static long fieldOffset;
static {
try {
unsafe = getUnsafe();
fieldOffset = unsafe.objectFieldOffset(CASVisibilityProblem.class.getDeclaredField("value"));
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
public int getValue() {
return value;
}
public boolean update(int expected, int x) {
return unsafe.compareAndSwapInt(this, fieldOffset, expected, x);
}
public static void main(String[] args) throws InterruptedException {
CASVisibilityProblem problem = new CASVisibilityProblem();
new Thread(new Runnable() {
int i = 0;
@Override
public void run() {
while (problem.getValue() == 1) {
i++;
}
}
}).start();
Thread.sleep(1000);
if (problem.update(1, 0)) {
System.out.println(problem.getValue());
}
}
public static Unsafe getUnsafe() throws NoSuchFieldException, IllegalAccessException {
Unsafe unsafe;
//通过反射获取unsafe
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
return unsafe;
}
}
可以观察到
使用 CAS
https://blogs.oracle.com/javamagazine/post/the-unsafe-class-unsafe-at-any-speed
- 直接使用 Unsafe
// CAS操作:如果obj的指定偏移量的值等于 expected,则将其设置为 x。返回操作是否成功。
compareAndSwapInt(Object obj, long offset, int expected, int x):
// 类似于 compareAndSwapInt,但操作对象引用类型
compareAndSwapObject(Object obj, long offset, Object expected, Object x):
// 将指定偏移量的 int 值与 delta 相加,并返回旧值。
getAndAddInt(Object obj, long offset, int delta):
- 使用AtomicXxx类,内部使用了Unsafe + volatile