JUC:理解CAS、CAS会出现的ABA问题、解决ABA原子引用、使用Synchronized会比CAS操作慢效率低

 


 

深入理解CAS JDK1.5

在计算机科学中,比较和交换(Conmpare And Swap)是用于实现多线程同步的原子指令。 它将内存位置的内容与期望值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值。 这是作为单个原子操作完成的。 原子性保证新值基于最新信息计算; 如果该值在同一时间被另一个线程更新,则写入将失败。 操作结果必须说明是否进行替换; 这可以通过一个简单的布尔响应(这个变体通常称为比较和设置),或通过返回从内存位置读取的值来完成。

JAVA1.5开始引入了CAS,主要代码都放在JUC的atomic包下。

  • CAS(compareAndSet比较并设置方法)在java层面是:

在这里插入图片描述

通过源码:

在这里插入图片描述

如果这个类的期望值(此刻的值)是0,那么就将更新的值赋值给这个类,成为该类的最新值,成功赋值返回true,否则返回false。

  • CAS(compareAndSwapInt比较并设置方法)在底层是:

Unsafe类是java底层源码,native修饰的,通过C++语言,通过原语对cpu进行操作。原语是若干个指令组成,并且必须是连续执行来完成一个任务,过程中不能被其他线程插入。

通过AtomicInteger atomicInteger = new AtomicInteger(0);atomicInteger.getAndIncrement();方法,得知源码

在执行CAS语句时:var1,var2获得内存此刻的值,与之前获得的内存值(期望值)var5,进行比较,比较相同,则进行var5=var5+var4并返回var5

var1为当前对象

var2为内存偏移量

var4为1

var5为获取当前对象var1和内存偏移量var2在内存中的值

我们看到有一个do while去比较更新,如果内存中值和当前值相同则新增1,不相同在接着比较。从而我们也可以知道CAS的缺点:如果CAS失败就会一直进行尝试,长时间不成功,可能会跟CPU带来很大的开销。

 

CAS会出现ABA问题

如线程1从内存X中取出A,这时候另一个线程2也从内存X中取出A,并且线程2进行了一些操作将内存X中的值变成了B,然后线程2又将内存X中的数据变成A,这时候线程1进行CAS操作发现内存X中仍然是A,然后线程1操作成功。虽然线程1的CAS操作成功,但是整个过程就是有问题的。

在这里插入图片描述

 

解决ABA 原子引用

所以JAVA中提供了AtomicStampedReferenceAtomicMarkableReference原子引用来处理会发生ABA问题的场景,主要是在对象中额外再增加一个标记来标识对象是否有过变更。

 

- AtomicStampedReference遇到的坑

AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(1,1);
// 第一个参数值是Reference值,就是需要更新的Interge类型的值
// 第二个参数值是stamp值,用于记录版本信息,用于解决ABA的问题

大坑就在此,AtomicStampedReference的构造器:

public AtomicStampedReference(V initialRef, int initialStamp) {
    pair = Pair.of(initialRef, initialStamp);
}

这里的Pair是AtomicStampedReference的内部类,用于存储更新值和邮票值,存储的Integer类型的数字在java中是有缓存的,缓存范围是-128~127,所以当AtomicStampedReference类初始化为这个之间的数字时,自动从缓存中获取,因此地址也是引用的缓存中的地址。在后续进行比较交换时:

stampedReference.compareAndSet(
                    1, 
                    2, 
                    stampedReference.getStamp(), 
                    stampedReference.getStamp() + 1);

 

期望值是1,1在缓存中,会引用缓存中的内存地址,并且初始时也是1,期望值于当前值的引用地址都是一样的,compareAndSet方法通过==进行判断值期望值于当前值是否相等:

在这里插入图片描述

因此不会有任何错误,比较交换都会成功返回ture。

但是:如果你初始化为1130并且更新值时,也是输入的1130为期望值:

AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(1130,1);
// 更新值方法
stampedReference.compareAndSet(
                    1130, 
                    2, 
                    stampedReference.getStamp(), 
                    stampedReference.getStamp() + 1);

 

这种结果会出错,会返回false。这时由于当初始化时,传入的1130已经超过了缓存值,会创建一个新的内存空间存储这个Integer类型的值,而当调用compareAndSet时,也传入了1130,这个也已经超过了缓存值,因此又会创建一个内存空间存储这个Integer值,由于compareAndSet方法是通过==进行比较的,不是通过equals进行比较值的,因此期望值于当前值的引用地址不一样导致无法更新值成功,所以会返回false。

解决办法:

既然知道原因了,那么就很好解决了,在更新值的时候,直接使用stampedReference.getReference()方法获得当前值作为期望值就可以了。这样期望值和当前值的引用地址都一样,可以确保交换成功!,此时就返回ture了。

stampedReference.compareAndSet(
                    stampedReference.getReference(),
                    2,
                    stampedReference.getStamp(),
                    stampedReference.getStamp() + 1)

 


实例:

public class Test01{
    public static void main(String[] args) throws Exception {
        AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(1,1);
        int stamp = stampedReference.getStamp(); // 获取当前邮票值 为 1

        new Thread(()->{
            // 对值进行CAS操作,并将邮票更新
            System.out.println("A "+stampedReference.compareAndSet(
                    1, 
                    2, 
                    stampedReference.getStamp(), 
                    stampedReference.getStamp() + 1));
            System.out.println("a1 => "+stampedReference.getStamp());
            // 对值进行CAS操作,并将邮票更新
            System.out.println("A "+stampedReference.compareAndSet(
                    2, 
                    1, 
                    stampedReference.getStamp(), 
                    stampedReference.getStamp() + 1));
            System.out.println("a2 => "+stampedReference.getStamp());
        }).start();


        new Thread(()->{
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 对值进行CAS操作,期望值与内存值相同,会更新为3。但是内存邮票值与期望邮票值不同,所以整个CAS操作失败。
            // 因此可以解决ABA的问题,就不会出现原有的ABA的问题了。
            System.out.println("B "+stampedReference.compareAndSet(
                    1, 
                    3, 
                    stamp, 
                    stampedReference.getStamp() + 1));
            System.out.println("b1 => "+stampedReference.getStamp());
        }).start();
    }
}

 

输出:

A true
a1 => 2
A true
a2 => 3
B false
b1 => 3

 

该原子引用,虽然使用了ABA的方式,线程A将原来的值赋了新值之后又将其赋值到原来的值,但是线程B操作的时候,会发现邮票值有变动,因此会操作失败。解决了ABA的问题。

 

CAS实现原子操作3大问题

  • ABA问题
  • CAS执行时间长,会消耗CPU资源,开销大
  • CAS操作只能执行保证一个共享变量

 

使用synchronized会比CAS操作慢和效率低

  • (悲观锁) 使用synchronized关键字,只允许一个线程进入同步代码块进行操作,而其他线程就必须在同步代码块外等待,此时其他线程就进入了BLOCKED状态,不占用CPU资源。而当前线程操作完之后,会释放锁,其他线程会去抢锁,如果抢到了就会被掉到CPU中进行执行。很明显,使用synchronized的同步代码,线程会在阻塞状态进入运行状态之间切换,有一个上下文切换的过程,该过程会浪费时间。

    上下文切换:

    同步代码中,一个线程拿到锁从阻塞状态进入运行状态的时候,就被调度CPU中执行,一个线程除了去执行代码之外,线程本身也是有一些数据的,这些数据会调度到CPU cache中;而执行完之后,会从运行状态进入阻塞状态或是其他状态,这时就会将该线程的数据和线程从CPU中拿出去,换下一个进程进来。这样个过程就是上下文切换。

  • (乐观锁) 使用CAS操作,CAS是比较并交换,因为CAS操作时原子性的,始终只会存在一个线程去执行CAS,所以不会存在线程安全问题。那么就会使得所有线程去获操作的时候,都会去比较,如果比较正确就执行交换,如果不正确就再次循环去拿新的值。当一个线程在CAS操作时,其他线程都会一直循环,空轮训,所有线程一直都是在执行中,少了上下文的切换。所以CAS操作效率会高很多,执行速度会快很多。

总结: 由于synchronized在线程调度上有上下文切换,浪费很多时间,而CAS操作的每一个线程都是一直循环然后比较值,没有上下文的切换,所以CAS会快很多。

 

posted @ 2020-06-14 17:32  张还行  阅读(332)  评论(0编辑  收藏  举报