Java-原子操作之CAS

原子操作:不可被中断的一个或一系列操作。

处理器如何实现原子操作

1)使用总线锁保证原子性

        第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读写操作就不是原子的,操作完之后共享变量的值会和期望的不一样。

1.  public class Test6 {  

2.      public static void main(String[] args) {  

3.          Count count=new Count();  

4.          Count count2=new Count();  

5.          count.start();  

6.          count2.start();  

7.      }  

8.        

9.        

10. }  

11. class Count extends Thread{  

12.     private static int i=1;  

13.     @Override  

14.     public void run() {  

15.         i++;  

16.         System.out.println(i);  

17.         super.run();  

18.     }  

19. }  

       这里我们期望打印出23,但是结果有可能会出现2,233;原因可能多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。想要保证都改写共享变量的操作都是原子的,就必须保持CPU1(线程1)读改写变量i的时候,CPU2(线程2)不能操作缓存了该共享变量内存地址的缓存。

        处理器的总线锁就是解决这个问题的。所谓总线锁就是使用处理器提供一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞,那么该处理器可以共享内存。

2使用缓存锁保证原子性

        第二个机制是通过缓存锁来保证原子性。在同一时刻,我们只需要保证对某个内存的操作是原子性即可。缓存加锁就是缓存在内存区域的数据如果在加锁期间,当它执行锁操作写回内存时,处理器不再利用LOCK#信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当CPU1修改缓存行中的i时使用缓存锁定,那么CPU2就不能同时缓存了i的缓存行,此时CPU2可以去做其他的事。总线锁的开销很大,目前处理器会在某些场合使用缓存锁替代总线锁来进行优化。

        有两种情况下不能使用缓存锁

        第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cacheline)时,则处理器会调用总线锁定;

        第二种情况:有些处理器不支持缓存锁定。对于Intel486Pentium处理器,就是有缓存行也会调用总线锁定;

Java如何实现原子操作:

(1)  循环CAS

        JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的,自旋的CAS实现的基本思路就是循环进行CAS操作直到成功为止

2)锁机制

        锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很大锁机制,有偏向锁,轻量级锁和互斥锁有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程向进入同步块的时候使用CAS的方式来获取锁,当它退出同步块的是很好使用循环CAS释放锁。

 

        很多情况下我们只是需要一个简单的、高效的、线程安全的递增递减方案。注意,这里有三个条件:简单,意味着程序员尽可能少的操作底层或者实现起来要比较容易;高效意味着耗用资源要少,程序处理速度要快;线程安全也非常重要,这个在多线程下能保证数据的正确性。这三个条件看起来比较简单,但是实现起来却难以令人满意。

        通常情况下,在Java里面,++i或者--i不是线程安全的,这里面有三个独立的操作:获得变量当前值,为该值+1/-1,然后写回新的值。在没有额外资源可以利用的情况下,只能使用加锁才能保证读--写这三个操作是原子性的。

        Java 5新增了AtomicInteger类,该类包含方法getAndIncrement()以及getAndDecrement(),这两个方法实现了原子加以及原子减操作,但是比较不同的是这两个操作没有使用任何加锁机制,属于无锁操作。

        在JDK 5之前Java语言是靠synchronized关键字保证同步的,这会导致有锁(后面的章节还会谈到锁)。

锁机制存在以下问题:

1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题

2)一个线程持有锁会导致其它所有需要此锁的线程挂起

(当某一个线程(T1)抢占到锁之后,那么其他线程再尝试去抢占锁时就会被挂起,当T1释放锁之后,下一个线程(T2)再抢占到锁后并且重新恢复到原来的状态大约需要经过8W个时钟周期。而假设我们业务代码本身并不具备很复杂的操作,执行整个操作可能就花费3-10个时钟周期左右,那么当我们使用无锁操作时,线程T1和线程T2对共享变量进行并发的CAS操作,假设T1成功了,T2最多再执行一次,它执行多次的所消耗的时间远远小于由于线程所挂起到恢复所消耗的时间,它基本不可能运气差到要执行几千次才能完成操作,因此无锁的CAS操作在性能上要比同步锁高很多。)

3)如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险

        volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。

        独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

锁机制和CAS不同(业务需求角度):

        使用同步锁机制锁保证的"先行发生原则(happenbefore)"过于的粗力度,它虽然可以保证线程T1的操作如果早于线程T2获取锁,那么T1一定在T2之前完成操作;而CAS操作却不能保证这样的顺序的一致性,但是CAS操作保证了关键的修改一步具有先行发生原则。在我们实际的业务场景下,由锁机制保证的这种看似所谓的有序性其实没有太大的意义,因为我们只需保证最终结果的一致性就能满足业务的需要。我们以商品秒杀为例,当多个用户并发访问时,我们其实只需确保的就是其在抢占的那一刻是一个原子操作即可,当商品数目为0时提示操作失败,而无需保证先来的用户一定能够抢到商品。因此,在业务本身的需求上,无锁机制本身就可以满足我们绝不多数的需求,并且在性能上也可以大大的进行提升。
       
我们可以再举一个生活化的例子来理解无锁的原子化操作与锁的不同,我们使用的版本控制工具与之其实非常的相似,如果使用锁来同步,其实就意味着只能同时一个人对该文件进行修改,此时其他人就无法操作文件,如果生活中真正遇到这样的情况我们一定会觉得非常不方便,而现实中我们其实并不是这样,我们大家都可以修改这个文件,只是谁提交的早,那么他就把他的代码成功提交的版本控制服务器上,其实这一步就对应着一个原子操作,而后操作的人往往却因为冲突而导致提交失败,此时他必须重新更新代码进行再次修改,重新提交。

 

CAS 操作

        上面的乐观锁用到的机制就是CASCompareand Swap

        CAS 指的是现代 CPU 广泛支持的一种对内存中的共享数据进行操作的一种特殊指令。这个指令会对内存中的共享数据做原子的读写操作。简单介绍一下这个指令的操作过程:首先,CPU 会将内存中将要被更改的数据与期望的值做比较。然后,当这两个值相等时,CPU 才会将内存中的数值替换为新的值。否则便不做操作。最后,CPU会将旧的数值返回。这一系列的操作是原子的。它们虽然看似复杂,但却是 Java 5 并发机制优于原有锁机制的根本。简单来说,CAS的含义是我认为原有的值应该是什么,如果是,则将原有的值更新为新值,否则不做修改,并告诉我原来的值是多少。(这段描述引自《Java并发编程实践》)

        当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即时没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

        CAS3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。这是一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;而Synchronized是一种悲观锁,它认为在它修改之前,一定会有其它线程去修改它,悲观锁效率很低。

:CAS其底层是通过CPU1条指令来完成3个步骤,因此其本身是一个原子性操作,不存在其执行某一个步骤的时候而被中断的可能。

非阻塞算法nonblockingalgorithms一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。

        现代的CPU提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。

        拿出AtomicInteger来研究在没有锁的情况下是如何做到数据正确性的。

        private volatile int value;

        首先毫无以为,在没有锁的机制下可能需要借助volatile原语,保证线程间的数据是可见的(共享的)。这样才获取变量的值的时候才能直接读取。

        publicfinal int get() {

                return value;

        }

        然后来看看++i是怎么做到的。

        publicfinal int incrementAndGet() {

                for(;;) {

                        int current = get();

                        int next = current + 1;

                        if (compareAndSet(current, next)) returnnext;

                 }

        }

        在这里采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。

        而compareAndSet利用JNI来完成CPU指令的操作。

        public final booleancompareAndSet(int expect, int update) {

                return unsafe.compareAndSwapInt(this, valueOffset, expect,update);

        }

        整体的过程就是这样子的,利用CPUCAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个J.U.C都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很大的提升。参考资料的文章中介绍了如果利用CAS构建非阻塞计数器、队列等数据结构。

CAS的问题

1ABA问题

        CAS会导致“ABA问题

        CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。

        比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程oneCAS操作成功,但是不代表这个过程就是没有问题的。如果链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。因此前面提到的原子操作AtomicStampedReference/AtomicMarkableReference就很有用了。这允许一对变化的元素进行原子操作。

 

1.进程P1在共享变量中读到值为A

2.P1被抢占了,进程P2执行

3.P2把共享变量里的值从A改成了B,再改回到A,此时被P1抢占。

4.P1回来看到共享变量里的值没有被改变,于是继续执行。

       虽然P1以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA问题最容易发生在lock free 的算法中的,CAS首当其冲,因为CAS判断的是指针的地址。如果这个地址被重用了呢,问题就很大了。(地址被重用是很经常发生的,一个内存分配后释放了,再分配,很有可能还是原来的地址)

       比如DeQueue()函数,因为我们要让headtail分开,所以我们引入了一个dummy指针给head,当我们做CAS的之前,如果head的那块内存被回收并被重用了,而重用的内存又被EnQueue()进来了,这会有很大的问题。(内存管理中重用内存基本上是一种很常见的行为)

2)循环时间长开销大:

        自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供pause指令,那么效率会有一定的提示。pause指令的两个作用,第一,它可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本。第二,它可以避免在推出循环的时候因内存顺序冲突而引起CPU流水线被清空,从而提高CPU的效率;

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

        当对一个共享变量操作时,我们可以使用CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作,这个时候就可以用锁。

 

参考资料:

非阻塞算法简介https://www.ibm.com/developerworks/cn/java/j-jtp04186/

流行的原子https://www.ibm.com/developerworks/cn/java/j-jtp11234/

https://www.jianshu.com/p/9ff426a784ad

http://blog.csdn.net/liaodehong/article/details/51934428

http://blog.csdn.net/anla_/article/details/78635315

 

猜你喜欢

转载自blog.csdn.net/u010898743/article/details/79463678