Java实现CAS的原理 | Java程序员进阶之路
美团终面:CAS确定完全不需要锁吗?
CAS是Compare-And-Swap
(比较并交换)的缩写,是一种轻量级的同步机制,主要用于实现多线程环境下的无锁算法和数据结构,保证了并发安全性。它可以在不使用锁(如synchronized、Lock)的情况下,对共享数据进行线程安全的操作。
CAS操作主要有三个参数:要更新的内存位置、期望的值和新值。CAS操作的执行过程如下:
前面提到,CAS是一种原子操作。那么Java是怎样来使用CAS的呢?我们知道,在Java中,如果一个方法是native的,那Java就不负责具体实现它,而是交给底层的JVM使用c或者c++去实现。
Unsafe类是JDK提供的一个不安全的类,它提供了一些底层的操作,包括内存操作、线程调度、对象实例化等。它的作用是让Java可以在底层直接操作内存,从而提高程序的效率。但是,由于Unsafe类是不安全的,所以只有JDK开发人员才能使用它,普通开发者不建议使用。它里面大多是一些native
方法,其中就有几个关于CAS的:
boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);
具体的实现过程如下:
compareAndSwapInt
、compareAndSwapLong
或compareAndSwapObject
方法时,会传入三个参数,分别是需要修改的变量V、期望的值A和新值B。由于CAS操作是基于底层硬件支持的原子性指令来实现的,所以它可以保证操作的原子性和线程安全性,同时也可以避免使用锁带来的性能开销。因此,CAS操作广泛应用于并发编程中,比如实现无锁数据结构、实现线程安全的计数器等。
前面说到Unsafe类中的几个支持CAS的方法,而在Java中便有这么一些类应用到了这些方法,其中一类便是原子操作相关类,在juc包中的atomic包下。
这里我们以AtomicInteger
类的getAndAdd(int delta)
方法为例,来看看Java是如何实现原子操作的:
public final int getAndAdd(int delta) {
return U.getAndAddInt(this, VALUE, delta);
}
这里的U其实就是一个Unsafe
对象:
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
所以其实AtomicInteger
类的getAndAdd(int delta)
方法是调用Unsafe
类的方法来实现的:
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
前面我们讲到,CAS是“无锁”的基础,它允许更新失败。所以经常会与while循环搭配,在失败后不断去重试。这里声明了一个v,也就是要返回的值。从getAndAddInt
来看,它返回的应该是原来的值,而新的值的v + delta
。
这里使用的是do-while循环。这种循环不多见,它的目的是保证循环体内的语句至少会被执行一遍。这样才能保证return 的值v
是我们期望的值。
public final boolean weakCompareAndSetInt(Object o, long offset, int expected, int x) {
return compareAndSetInt(o, offset, expected, x);
}
public final native boolean compareAndSetInt(Object o, long offset, int expected, int x);
可以看到,最终其实是调用的我们之前说到了CAS native
方法。那为什么要经过一层weakCompareAndSetInt
呢?
根据深入浅出 Java 多线程中介绍可知,这是与volatile关键字有关的。weakCompareAndSet
操作仅保留了volatile
自身变量的特性,而除去了happens-before规则带来的内存语义。也就是说,weakCompareAndSet
无法保证处理操作目标的volatile变量外的其他变量的执行顺序( 编译器和处理器为了优化程序性能而对指令序列进行重新排序 ),同时也无法保证这些变量的可见性。这在一定程度上可以提高性能。
ABA问题指在CAS操作过程中,如果变量的值被改为了 A、B、再改回 A,而CAS操作是能够成功的,这时候就可能导致程序出现意外的结果。
在高并发场景下,使用CAS操作可能存在ABA问题,也就是在一个值被修改之前,先被其他线程修改为另外的值,然后再被修改回原值,此时CAS操作会认为这个值没有被修改过,导致数据不一致。
为了解决ABA问题,Java中提供了AtomicStampedReference
类,该类通过使用版本号的方式来解决ABA问题。每个共享变量都会关联一个版本号,CAS操作时需要同时检查值和版本号是否匹配。因此,如果共享变量的值被改变了,版本号也会发生变化,即使共享变量被改回原来的值,版本号也不同,因此CAS操作会失败。
下面是一个使用AtomicStampedReference
类解决ABA问题的示例代码:
public void test() {
AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<>(1, 0);
// 重现ABA问题
int oldStamp = atomicStampedRef.getStamp();
int oldValue = atomicStampedRef.getReference();
// 将值从 1 改为 2,并使版本号自增
atomicStampedRef.compareAndSet(oldValue, 2, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
// 将值从 2 改回 1,并使版本号自增
atomicStampedRef.compareAndSet(2, 1, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
// 使用旧版本号修改时,即使值和旧值一样,但版本号已经发生了变化,导致修改失败
atomicStampedRef.compareAndSet(2, 1, oldStamp, oldStamp + 1);
}
在上面的示例中,通过getStamp()
和getReference()
方法分别获取共享变量的版本号和值,然后使用compareAndSet()
方法进行CAS操作,每次操作都会更新版本号。这样,就可以避免ABA问题。
除了ABA问题,CAS操作还可能会受到自旋时间过长的影响,因为如果某个线程一直在自旋等待,会浪费CPU资源。
为了解决上述问题,可以采用自适应自旋锁的方式,即在前几次重试时采用忙等待的方式,后面则使用阻塞等待的方式,避免浪费CPU资源。
public class SpinLock {
private AtomicReference<Thread> owner = new AtomicReference<>();
private int count;
public void lock() {
Thread currentThread = Thread.currentThread();
// 已经获取了锁
if (owner.get() == currentThread) {
count++;
return;
}
// 自旋等待获取锁
while (!owner.compareAndSet(null, currentThread)) {
// 自适应自旋
if (count < 10) {
count++;
} else {
// 阻塞等待
LockSupport.park(currentThread);
}
}
}
public void unlock() {
Thread currentThread = Thread.currentThread();
// 当前线程持有锁
if (owner.get() == currentThread) {
if (count > 0) {
count--;
} else {
// 释放锁
owner.compareAndSet(currentThread, null);
// 唤醒其他线程
LockSupport.unpark(currentThread);
}
}
}
}
这里解释一下上面代码:
在设计时使用了AtomicReference
来保存当前持有锁的线程对象,这样可以保证线程安全。
当一个线程请求获取锁时,如果当前线程已经持有锁,则将计数器加1,否则使用CAS操作来获取锁。这样可以避免了使用synchronized关键字或者ReentrantLock等锁的实现机制。
当线程获取锁失败时,使用自旋等待的方式,这样可以避免线程进入阻塞状态,避免了线程上下文切换的开销。当重试次数小于10时,使用自旋等待的方式,当重试次数大于10时,则使用阻塞等待的方式。这样可以在多线程环境下保证线程的公平性和效率。
在释放锁时,如果计数器大于0,则将计数器减1,否则将锁的拥有者设为null,唤醒其他线程。这样可以确保在有多个线程持有锁的情况下,正确释放锁资源,并唤醒其他等待线程,保证线程的正确性和公平性。
CAS在多线程并发编程中被广泛应用,它通常用于实现乐观锁和无锁算法。以下是CAS的一些应用场景:
上面说过,CAS是一个无锁机制的操作,底层是通过Unsafe类使用native本地方法进行的CAS操作,但是大家有没有想过这样的问题:硬件层面CAS又是如何保证原子性的呢?真的完全没加锁吗?
拿比较常见的x86架构的CPU来说,其实 CAS 操作通常使用 cmpxchg
指令实现的。
可是为啥 cmpxchg
指令能保证原子性呢?主要是有以下几个方面的保障:
cmpxchg
指令是一条原子指令。在 CPU 执行 cmpxchg
指令时,处理器会自动锁定总线,防止其他 CPU 访问共享变量,然后执行比较和交换操作,最后释放总线。cmpxchg
指令在执行期间,CPU 会自动禁止中断。这样可以确保 CAS 操作的原子性,避免中断或其他干扰对操作的影响。cmpxchg
指令是硬件实现的,可以保证其原子性和正确性。CPU 中的硬件电路确保了 cmpxchg 指令的正确执行,以及对共享变量的访问是原子的。所以,在操作系统层面,CAS还是会加锁的,通过加锁的方式锁定总线,避免其他CPU访问共享变量。