CAS算法

什么是CAS?

Compare And Set(或Compare And Swap),CAS是解决多线程并行情况下使用锁造成性能损耗的一种机制,采用这种无锁的原子操作可以实现线程安全,避免加锁的笨重性。

JDK5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronized同步锁的一种乐观锁。

JDK5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁

CAS 利用 CPU指令 保证了操作的原子性,以达到锁的效果,循环这个指令,直到成功为止。java提供的CAS原子操作类AtomicInteger等,核心就是CAS(CompareAndSwap)。

注意:原子操作和锁是一样的一种可以保证线程安全的方式,如何让线程安全就看如何使用锁或者如何使用原子操作。CAS使用了正确的原子操作,所以保证了线程安全。

CAS算法理解

对CAS的理解,CAS是一种无锁算法。

CAS有3个操作数:

  • 内存值V
  • 旧的预期值A
  • 要修改的新值B

当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS比较与交换的伪代码可以表示为:

do{

​ 备份旧数据;

​ 基于旧数据构造新数据;

}while(!CAS( 内存地址,备份的旧数据,新数据 ))

场景理解

cas1

注:t1,t2线程是同时更新同一变量56的值

因为t1和t2线程都同时去访问同一变量56,所以他们会把主内存的值完全拷贝一份到自己的工作内存空间,所以t1和t2线程的预期值都为56。

假设t1在与t2线程竞争中线程t1能去更新变量的值,而其他线程都失败。(失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试)。t1线程去更新变量值改为57,然后写到内存中。此时对于t2来说,内存值变为了57,与预期值56不一致,就操作失败了(想改的值不再是原来的值)。

(上图通俗的解释是:CPU去更新一个值,但如果想改的值不再是原来的值,操作就失败,因为很明显,有其它操作先改变了这个值。)

当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。

容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。

原子操作类

当高并发的情况下,对于基本数据类型或者引用数据类型的操作,可能会产生线程安全问题,为了避免多线程问题的处理方式一般有加锁,但是加锁会影响性能,所以这个时候可以考虑使用原子操作类。CAS由于是在硬件方面保证的原子性,不会锁住当前线程,所以执行效率是很高的。
常见的原子操作类:

cas4

CAS算法在JDK中的应用

在原子类变量中,如java.util.concurrent.atomic中的AtomicXXX,都使用了这些底层的JVM支持为数字类型的引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时都直接或间接的使用了这些原子变量类。

Java 1.7中AtomicInteger.incrementAndGet()的实现源码为:

cas2

这段代码是一个无限循环,也就是CAS的自旋,循环体中做了三件事:

  1. 获取当前值
  2. 当前值+1,计算出目标值
  3. 进行CAS操作,如果成功则跳出循环,如果失败则重复上述步骤

Java 1.8中AtomicInteger.compareAndSet(int expect, int update)的实现源码:

cas5

compareAndSet方法的实现很简单,只有一行代码。

这里涉及到两个重要的对象,一个是unsafe,一个是valueOffset。

什么是unsafe呢?Java语言不像C,C++那样可以直接访问底层操作系统,但是JVM为我们提供了一个后门,这个后门就是unsafe。unsafe为我们提供了硬件级别的原子操作。

至于valueOffset对象,是通过unsafe.objectFiledOffset方法得到,所代表的是AtomicInteger对象value成员变量在内存中的偏移量。我们可以简单的把valueOffset理解为value变量的内存地址。

我们上面说过,CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

而unsafe的compareAndSwapInt方法的参数包括了这三个基本元素:valueOffset参数代表了V,expect参数代表了A,update参数代表了B。

正是unsafe的compareAndSwapInt方法保证了Compare和Swap操作之间的原子性操作。

注意:其中current为从内存中拷贝的旧的预期值,next为想要修改的新值。

由此可见,AtomicInteger.incrementAndGet的实现用了乐观锁技术,调用了类sun.misc.Unsafe库里面的 CAS算法,用CPU指令来实现无锁自增。

所以,AtomicInteger.incrementAndGet的自增比用synchronized的锁效率倍增。

CAS的缺点

CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题:

  1. 循环时间长开销很大
  2. 只能保证一个共享变量的原子操作。
  3. ABA问题。

循环时间长开销很大:
如果并发量很高,我们可以看到getAndAddInt方法(JDK1.8)执行时,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。

只能保证一个共享变量的原子操作:

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。

所以我们可以使用锁或者利用AtomicReference把多个共享变量合并成一个共享变量来操作,以此保证原子性。

什么是ABA问题?ABA问题怎么解决?
如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?

如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。

JDK 1.5 以后的 AtomicStampedReference就提供了此种能力,其中的compareAndSet方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

Java并发包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚 ABA 问题是否会影响程序并发的正确性。如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

解决ABA问题:
AtomicMarkableReference:内部是一个boolean类型的版本号,可以记录是否被更改过
AtomicStampedReference:内部是一个int类型的版本号,可以记录被更改的次数

例如:使用AtomicStampedReference,避免ABA问题,查看内部是int类型的版本号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class AtomicStampedReferenceDemo {

public static void main(String[] args) throws InterruptedException {
//设置初始化版本号是0
AtomicStampedReference<String> atomicStampedReference = new AtomicStampedReference("a1", 0);

//初始的值和版本号
String reference = atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();

Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("目前的值:" + reference + "............版本号:" + stamp
+ ",修改结果:" + atomicStampedReference.compareAndSet(reference, "a2", stamp, stamp + 1));
}
});

Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
String reference = atomicStampedReference.getReference();
System.out.println("目前的值:" + reference + "............版本号:" + atomicStampedReference.getStamp()
+ ",修改结果:" + atomicStampedReference.compareAndSet(reference, "a2", stamp, stamp + 1));
}
});

thread1.start();
thread1.join();
thread2.start();
thread2.join();
}

}

运行结果:

cas6

拓展-乐观锁和悲观锁

乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

乐观锁常见的两种实现方式

1. 版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

举一个简单的例子: 假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。

2. CAS算法

compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试

面试必备之深入理解自旋锁

什么是自旋锁?

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环

获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting

它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

Java如何实现自旋锁

下面是个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}

简单分析:lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环。如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock()方法释放了该锁。

自旋锁存在的问题

  1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
  2. 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

自旋锁的优点

  1. 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
  2. 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

可重入的自旋锁和不可重入的自旋锁

的那段代码,仔细分析一下就可以看出,它是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁释放之前又一次重新获取该锁,第二次就不能成功获取到。由于不满足CAS,所以第二次获取会进入while循环等待,而如果是可重入锁,第二次也是应该能够成功获取到的。而且,即使第二次能够成功获取,那么当第一次释放锁的时候,第二次获取到的锁也会被释放,而这是不合理的。

为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ReentrantSpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
private int count;
public void lock() {
Thread current = Thread.currentThread();
if (current == cas.get()) { // 如果当前线程已经获取到了锁,线程数增加一,然后返回
count++;
return;
}
// 如果没获取到锁,则通过CAS自旋
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread cur = Thread.currentThread();
if (cur == cas.get()) {
if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
count--;
} else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
cas.compareAndSet(cur, null);
}
}
}
}

关于自旋锁,大家可以看一下这篇文章,非常不错:《 面试必备之深入理解自旋锁》

参考:

https://www.jianshu.com/p/21be831e851e

https://blog.csdn.net/qq_28822933/article/details/83341633

https://blog.csdn.net/qq_32998153/article/details/79529704

https://juejin.im/post/5b4977ae5188251b146b2fc8

https://blog.csdn.net/qq_34337272/article/details/81252853

秉持初心,继续向前。
显示 Gitment 评论