1.CAS(比较并交换)
CAS指compare and swap,用于在多线程环境下实现同步功能,是一种无锁优化。它的作用是让CPU比较内存中某个值是否和预期的值相同,如果相同则将这个值更新为新值,不相同则不做更新。CAS是原子性的操作,其实现方式是通过借助C/C++调用CPU指令完成的,同时其避免了上锁、阻塞等一系列操作,效率非常高。
当多个线程尝试使用CAS同时更新同一个变量时,只有一个线程可以更新变量的值,其他线程都会失败,失败的线程并不会挂起,而是告知这次竞争中失败了,并可以再次尝试。
①原子性
假定有两个操作A和B(A和B可能都很复杂),如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。
要实现原子操作可以使用锁,锁机制可以满足基本的需求。synchronized就是基于阻塞的锁机制,当一个线程拥有锁的时候,访问同一资源的其它线程需要等待,直到该线程释放锁。在这个过程中阻塞会造成线程上下文的切换,线程间切换实际是需要做很多事情的,比如要保存当前线程执行的指令以及它的程序计数器、内部的一些数据等。
这时候可以使用CAS。
synchronized是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。
CAS是一种乐观锁,它每次不加锁,假设没有冲突就去完成某项操作,如果因为冲突失败就重试,直到成功为止。
CAS是一种非阻塞算法,一个线程的失败或者挂起不应该影响其他线程的失败或挂起。
②CAS
使用CAS指令实现原子操作更加灵活。每一个CAS操作过程都包含三个运算符:一个内存地址V,一个期望的值A和一个新值B,操作时如果这个内存地址上存放的值等于期望的值A,就将地址上的值赋为新值B,否则不做任何操作。
循环CAS就是在一个循环里不断的做cas操作,直到成功为止。
2.CAS的缺点
①循环时间长,CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。即自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
②只能保证一个共享变量的原子操作。
当对一个共享变量执行操作时,可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。即CAS机制能保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性更新,就不得不使用Synchronized了。
解决办法:可以用锁;把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
注:其实对于线程安全,CAS和Synchronized这两种机制没有绝对的好与坏,关键得看场景,在并发量高的情况下,反而使用使用Synchronize更合适。
3.ABA问题
CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说线程1从内存位置V中取出A,这时候线程2也从内存中取出A,并且线程2进行了一些操作变成了B,然后又将V位置的数据变成A,这时候线程1进行CAS操作发现内存中仍然是A,然后操作成功。尽管线程4的CAS操作成功,但是不代表这个过程就是没有问题的。如果链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。
现有一个用单向链表实现的堆栈,栈顶为A,这时线程T1已经知道A.next为B,然后希望用CAS将栈顶替换为B:
head.compareAndSet(A,B);
在T1执行上面这条指令之前,线程T2介入,将全部元素出栈,再将A、B入栈,此时堆栈结构发生变化;此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B,但实际上B.next为null,所以此时的情况变为堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,平白无故就把C、D丢掉了。
这就是由于ABA问题带来的隐患,为了解决这个问题,可以使用版本戳,每修改一次,则版本号加1。在比较的时候不仅要比较当前变量的值,还需要比较当前变量的版本号。
各种乐观锁的实现中通常都会用版本戳version来对记录或对象标记,避免并发操作带来的问题,在Java中AtomicStampedReference也实现了这个作用,它通过包装[E,Integer]的元组来对对象标记版本戳stamp,从而避免ABA问题。
AtomicStampedReference内部维护了对象值和版本号,在创建AtomicStampedReference对象时需要传入初始值和初始版本号。它会检查当前引用是否等于预期值引用,其次检查当前标志是否等于预期标志,如果都相等就会以原子的方式将引用和标志都设置为新值。
private static AtomicStampedReference< String> asr = new AtomicStampedReference< >("A", 1);
//线程1修改对象的值为A->B->A
new Thread(() -> {
Log.e(TAG, "线程1拿到的当前时间戳版本号为:" + asr.getStamp());
//休眠1秒,让线程2也拿到同样的初始版本号
TimeUnit.SECONDS.sleep(1);
//通过CAS自旋算法锁修改值
atomicStampedReference.compareAndSet( "A", "B", asr.getStamp(), asr.getStamp() + 1);
asr.compareAndSet("B", "A", asr.getStamp(), asr.getStamp() + 1);
Log.e(TAG, "线程1完成修改:A ->B -> A");
}, "线程1").start();
//线程2去读取内存值并设置新值
new Thread(() -> {
int stamp = asr.getSt amp();
Log.e(TAG, "线程2拿到的当前时间戳版本号为:" + stamp);
//线程休眠2秒,为了让线程A完成ABA操作
TimeUnit.SECONDS.sleep(2);
//判断是否修改成功
boolean isSuccess = asr.compareAndSet("A", "C", stamp, asr.getStamp() + 1);
Log.e(TAG, "最新版本号:" + asr.getStamp() + ",线程2是否修改成功:" + isSuccess + ",当前值是:" + asr.getReference());
}, "线程2").start();
程序运行结果:
线程1拿到的当前时间戳版本号为:1
线程2拿到的当前时间戳版本号为:1
线程1完成修改:A ->B -> A
最新版本号:3,线程2是否修改成功:false,当前值是A
根据log可以看出:线程1完成CAS操作后,最新版本号已经变成3,线程2执行修改操作时,由于与之前拿到的版本号1不相等,所以操作失败。