小言_互联网的博客

[JUC第三天]浅谈Java中的CAS操作

433人阅读  评论(0)

CAS问题引入

回顾计数器问题

package com.imlehr.juc.chapter1;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class NotAtomic {
    private static volatile int num = 0;

    private static AtomicInteger atomicNum= new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {

        Runnable r1 = ()->{
            for(int j=0;j<10000;j++) {
                num++;
            }
        };

        Runnable r2 = ()->{
            for(int j=0;j<10000;j++) {
                atomicNum.incrementAndGet();
            }
        };

        //先不管那么多(虽然我知道这样创建线程池不好)
        ExecutorService e = Executors.newFixedThreadPool(100);

        for(int i=0;i<20;i++)
        {
            e.execute(r1);
            e.execute(r2);
        }

        //等任务全部执行完
        TimeUnit.SECONDS.sleep(3);

        //输出结果
        System.out.println(num);
        System.out.println(atomicNum.get());

    }
}

开20个线程,从数到10000,那么输出的结果应该是20*10000

这里我们采用了两种int:

  • volatile关键字修饰的int类型
  • 另外一个叫做AtomicInteger的类

最终通过输出结果发现:

  • 前者每次都会变动,而且始终(大部分情况)没有能输出正确的结果
  • 后者就每次都是正确的

Atomic包

之前说到volatile是不能保证原子性的,所以在多线程不加锁实现计数器的时候,就需要用到AtomicInteger才能使得计数器输出正确的结果

AtomicInteger是来自Java中JUC里的Atomic包,提供了一系列的原子操作,他使得我们能够在不用加锁的情况下,继续执行这些int类型的相关操作,对应自增自减等提供了以下方法来代替从而保证了原子性:

// 对应num++
atomicInteger.getAndIncrement();
//对应++num
atomicInteger.incrementAndGet();
//对应num = num + value
atomicNum.compareAndSet(expected,value);

他们的原子性都是采用的CAS无锁算法来实现的,这个算法比你直接用synchronized来加锁轻的多的多!

什么是CAS

CAS:Compare and Swap 比较和替换

简单的说就是,我们拿我们期望的变量值和实际的变量值进行比较,如果一样的话我们就进行替换,不然就不替换,就不需要去加锁了

CAS简单案例

compareAndSet(expected,value)为例,比如在一个高并发的情况下,某段线程中有这样一段代码:

 AtomicInteger a = new AtomicInteger(8);

do{
	Integer expected = a.get();
	Integer value = expected*2;
	//。。。。。一系列的操作之后 ,比如我们让这个数乘以2
    value = expected*2;
    
}while(!a.compareAndSet(expected,value))

他要做的事情其实就是,获取到a的值之后,做一系列操作,然后把a的值乘以2,赋值,结束任务

但是由于这期间需要考虑到其他线程对于这个变量的访问,所以最后我们在赋值的时候就采用了compareAndSet方法(Compare and Swap:缩写就是CAS,比较并交换)

这个方法有两个参数:

首先说第二个参数(因为简单):value,就是你希望的修改成的值

现在看第一个参数:expected

  • 这个参数代表的是你在对这一步操作的时候预期希望的这个变量应该有的值

  • 我们一般都是希望他在我们操作期间没有被其他人修改,所以在一开始的时候我们记录我们拿到的值,作为expected参数传入(开头那两行代码)

  • expected参数会和这个原子现在主内存中的最新的值进行对比,如果一样,则进行赋值,然后返回true;

  • 如果不一样,则代表在我们操作期间,有其他线程对这个值进行了修改,所以我们这期间做的操作可能都白做了,则整个方法返回false,也不会进行后续的赋值操作

  • 这时候则继续进行循环操作,重新进行计算,直到确定,在赋值之前没有别人修改他,才会赋值

这就是CAS算法的基本思想了:比较,如果一致,则赋值

我们可以发现这种操作有这两个小特点:

  • 有点类似乐观锁,在获取值的时候不会去检查这个值是否被人修改,只有在赋值操作的时候才会去检查是否修改
  • 还有点自旋锁的味道,如果没有达到条件就一直进行循环操作,直到能顺利执行

CAS在爪哇中的实现

接着上面的方法,我们点开到AtomicInteger里的源代码查看:

    
	private volatile int value;

	private static final Unsafe U = Unsafe.getUnsafe();

	public final boolean compareAndSet(int expectedValue, int newValue) {
        return U.compareAndSetInt(this, offsetValue, expectedValue, newValue);
    }

在AtomicInteger中,实际上是通过了一个叫做Unsafe的类来实现的操作

这里值得说明的还有一个地方:private volatile int value;

有一个叫做value的属性被volatile修饰,他用来代表当前内存中最新的数据(volatile修饰)

Unsafe类

Unsafe类是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。

不过这个类中的不少方法都是native本地方法,会涉及到直接通过偏移量来对内存进行操作,说白了就是有和C语言里的指针差不多的能力,也有可能导致一些C语言一样的不安全操作的发生(所以这可能就是他为什么叫做Unsafe了

继续顺着上面的代码深入到Unsafe类中,可以发现:

@HotSpotIntrinsicCandidate
    public final native boolean compareAndSetInt(Object var1, long var2, int var4, int var5);

这确实就是最底层了,是虚拟机和C++之类的事情了,不是爪哇能实现的了(去比较这个数字在主内存中的值就需要直接和操作系统打交道了)

CompareAndSet方法

@HotSpotIntrinsicCandidate
    public final native boolean compareAndSetInt(Object var1, long var2, int var4, int var5);
	public final boolean compareAndSet(int expectedValue, int newValue) {
        return U.compareAndSetInt(this, offsetValue, expectedValue, newValue);
    }

CompareAndSet方法一共有四个参数:

  • Object var1:代表当前对象的引用
  • long var2:代表的是当前对象在内存中的偏移量
  • long var4、var5:分别代表预期的值和希望设置的新的值

这个方法其实就像是C语言一样,直接拿着这个变量的在内存中的坐标去定点打击,首先通过对象指针和他的偏移量(前两个参数)找到内存中最新的值,然后去和expectedValue比较,如果一样则修改内存,返回true,反之则返回false,代表操作失败


由于我这里举的例子是compareAndSet,需要让用户自己来实现整个自旋操作,不太好解释,所以我这里又找了另外一段代码来举例:

来自Unsafe类中的getAndAddInt操作,就是num = num + delta的意思

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = this.getIntVolatile(o, offset);
        } while(!this.weakCompareAndSetInt(o, offset, v, v + delta));

        return v;
    }

这个方法和我之前的那个基本类似:先获取v作为期望值,再执行cas方法,如果这期间v在内存里的值没有改变,则v作为期望值就和内存里的值一样,执行v+delta

底层汇编

这里的CompareAndSet调用本地方法执行CAS(Compare and Swap)比较交换时,实际上就是执行的一条CPU并发原语(不可被打断,必是个原子过程)

具体的代码可以在unsafe.cpp类里面找到(反正我暂时是没找到)

他使用了Atomic::cmpxchg实现

这是条汇编语句

以后补充…

CAS的缺点

CAS一般来说有三个缺点

前两个缺点

循环开销大

首先,再来回看一下这段代码:

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = this.getIntVolatile(o, offset);
        } while(!this.weakCompareAndSetInt(o, offset, v, v + delta));

        return v;
    }

如果操作没有成功,则就会去继续循环,那么,如果线程数量有几百万个,疯狂自旋,那么这样的开销就很可怕…、

所以,第一个缺点:循环导致的开销

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

我们这里CAS一直都是拿着一个变量去比较,所以出现多个的时候,就只能用加锁去处理了

不过我在并发编程艺术这本书上还看到一个骚操作:

还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作

比如有两个共享变量i=2,j=a,合并一下:ij=2a,然后用CAS来操作ij

tql…

ABA问题

由于这个是重点,所以单独分出来写了

什么是ABA问题

这样一个情况:

这个数一开始是A,然后变成了B,但马上又变成了A

对于另外一个线程来说,他一开始认为的值是A,最后一去对比,还是A,没毛病啊,然后就CAS了

可是这中间这个数字其实是发生了变化的…

一般来说,如果业务里这样没有影响的话倒也没什么

怎么解决?-----每次对你的数加上一个版本号即可,具体操作看下面的案例

原子引用

首先说一个原子引用(因为对于普通的int什么的ABA的情况似乎不会产生什么影响)

原子引用:AtomicReference

之前说了有AtomicBoolean,AtomicInteger,所以对于你自己创建的对象Bread、Cake、Student啊什么什么的,如果想要实现原子操作,就需要用到AtomicReference,用法如下:

AtomicReference<Cake> lehrsCake = new AtomicReference();

Cake cake = new Cake();
Cake bigCake = new Cake();

lehrsCake.set(cake);

//正常的CAS:把Cake换成bigCake
lehrsCake.compareAndSet(cake,bigCake);

这时候对于cake,他里面有很多属性,所以如果中途某些属性改变了,即使引用再换回来,也会导致操作某些属性的时候爆炸

上面这段代码还是不能避免ABA问题,所以接下来需要用到时间戳来作为这个唯一的标号

使用标记

原子引用:AtomicStampedReference

所以上述的代码变为:

        AtomicStampedReference<Cake> lehrsCake = new AtomicStampedReference();

        Cake cake = new Cake();
        Cake bigCake = new Cake();

        //放入一个编号
        lehrsCake.set(cake,1);
        
        //用之前的编号对比是否是同一个版本,然后对新放入的结果给出另外一个新的编号2
        System.out.println(lehrsCake.compareAndSet(cake, bigCake,1,2));

🎉搞定!

参考来源:

https://www.bilibili.com/video/av49096951?p=15

https://tech.meituan.com/2019/02/14/talk-about-java-magic-class-unsafe.html


转载:https://blog.csdn.net/qq_43948583/article/details/104725206
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场