多线程理解(五) 线程安全性CAS和原子类

一、锁的策略:多线程编程中,我们有时候会认为共享资源大部分时间只会被一个线程所独占,这是一种乐观的想法;有时候我们会认为共享资源在很长一段时间会被多个线程争夺,这是一种悲观的想法。这两种派系映射到并发编程中就如同加锁与无锁的策略,即加锁是一种悲观策略,无锁是一种乐观策略。而无锁总是假设对共享资源的访问没有冲突,线程可以不停执行,无需加锁,无需等待,一旦发生冲突,无锁策略可以采用一种名为CAS的技术来保证线程执行的安全性,这项CAS技术就是无锁策略实现的关键。

二、CAS是什么:

CAS,Compare And Swap即比较并交换。执行函数:CAS(V,E,N)。

CAS有三个参数,V表示要更新的变量,E表示预期值,N表示新值。当且仅当V=E时,更新V的值为N,否则什么都不做。

CAS由于是在硬件方面保证的原子性,不会锁住当前线程,所以执行效率是很高的。

三、CAS的优缺点:

  1. 优点: CAS由于是在硬件层面保证的原子性,不会锁住当前线程,它的效率是很高的。 
  2. 缺点:
  • ABA问题。CAS在操作值的时候检查值是否已经变化,没有变化的情况下才会进行更新。但是如果一个值原来是A,变成B,又变成A,那么CAS进行检查时会认为这个值没有变化,但是实际上却变化了。ABA问题的解决方法是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就变成1A-2B-3A。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。
  • 并发越高,失败的次数会越多,CAS如果长时间不成功,会极大的增加CPU的开销。因此CAS不适合竞争十分频繁的场景。
  • 只能保证一个共享变量的原子操作。当对多个共享变量操作时,CAS就无法保证操作的原子性,这时就可以用锁,或者把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

 

四、unsafe类:unsafe类存在于sun.misc包中,从其名称就可以看出是不安全的类,Java官方也不支持直接使用unsafe类。但是JAVA中CAS操作的执行是依赖于unsafe类的。unsafe类中的所有方法都是用native修饰的,说明unsafe类中的方法都直接调用操作系统底层的资源执行相应任务。

五、原子类:java.util.concurrent.atomic包:原子类的小工具包,支持在单个变量上解除锁的线程安全编程。

 1.举个栗子:AtomicInteger 代码如下

public class AtomicExample1 {
    // 请求总数
    public static int clientTotal = 5000;
    // 同时并发执行的线程数
    public static int threadTotal = 200;
    public static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newCachedThreadPool();
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal ; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (Exception e) {
                    log.error("exception", e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}", count.get());
    }
    private static void add() {
        count.incrementAndGet();
        // count.getAndIncrement();
    }
}

开启200个线程,执行5000个累加的请求。观察最后结果:

结果为5000,说明这个累加操作是线程安全的。

 2.为什么AtomicInteger 是线程安全的(JDK为1.8):

我们查看了AtomicInteger的源码,发现incrementAndGet()这个方法的实现,调用的是unsafe.getAndAddInt(this, valueOffset, 1)

 

查看unsafe的getAndAddInt这个方法的实现

我们看下getIntVolatile(var1,var2)和compareAndSwapInt(var1,var2,var5,var5+var4)这两个方法。

//获得给定对象的指定偏移量offset的int值,Offset - value属性在内存中的位置(需要强调的是不是value值在内存中的位置)使用volatile语义,总能获取到最新的int值。 

public native int getIntVolatile(Object o, long offset);

 

//compareAndSwapInt有4个参数,this - 当前AtomicInteger对象,Offset - value属性在内存中的位置(需要强调的是不是value值在内存中的位置),expect - 预期值,update - 新值,根据上面的CAS操作过程,当内存中的value值等于expect值时,则将内存中的value值更新为update值,并返回true,否则返回false。

public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);

可看出getAndAddInt通过一个while循环不断的重试更新要设置的值,直到成功为止,调用的是Unsafe类中的compareAndSwapInt方法,是一个CAS操作方法。这里需要注意的是,上述源码分析是基于JDK1.8的,如果是1.8之前的方法,AtomicInteger源码实现有所不同,是基于for死循环的。

3.AtomicLong 与 LongAdder的比较:

LongAdder是java8为我们提供的新的类,跟AtomicLong有相同的效果。那么问题来了,为什么有了AtomicLong还要新增一个LongAdder呢? 
原因是:CAS底层实现是在一个死循环中不断地尝试修改目标值,直到修改成功。如果竞争不激烈的时候,修改成功率很高,否则失败率很高。在失败的时候,这些重复的原子性操作会耗费性能。

LongAdder类的实现核心是将热点数据分离,比如说它可以将AtomicLong内部的内部核心数据value分离成一个数组,每个线程访问时,通过hash等算法映射到其中一个数字进行计数,而最终的计数结果则为这个数组的求和累加,其中热点数据value会被分离成多个单元的cell,每个cell独自维护内部的值。当前对象的实际值由所有的cell累计合成,这样热点就进行了有效地分离,并提高了并行度。这相当于将AtomicLong的单点的更新压力分担到各个节点上。在低并发的时候通过对base的直接更新,可以保障和AtomicLong的性能基本一致。而在高并发的时候通过分散提高了性能。

如果在统计的时候,如果有并发更新,可能会有统计数据有误差。实际使用中在处理高并发计数的时候优先使用LongAdder,而不是AtomicLong。在线程竞争很低的时候,使用AtomicLong会简单效率更高一些。比如序列号生成(准确性)。

4.AtomicStampReference与CAS的ABA问题:

什么是CAS问题:

CAS操作的时候,其他线程将变量的由A改成B,又由B改成A,本线程在CAS方法中使用期望值A与当前变量进行比较的时候,发现变量的值未发生改变,于是CAS就将变量的值进行了交换操作。但是实际上变量的值已经被其他的变量改变过,这与设计思想是不符合的。所以就有了AtomicStampReference。源码:

private static class Pair<T> {
    final T reference;
    final int stamp;
    private Pair(T reference, int stamp) {
        this.reference = reference;
        this.stamp = stamp;
    }
    static <T> Pair<T> of(T reference, int stamp) {
        return new Pair<T>(reference, stamp);
    }
}

private volatile Pair<V> pair;

private boolean casPair(Pair<V> cmp, Pair<V> val) {
    return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}

AtomicStampReference的处理思想是,每次变量更新的时候,将变量的版本号+1,之前的ABA问题中,变量经过两次操作以后,变量的版本号就会由1变成3,也就是说只要线程对变量进行过操作,变量的版本号就会发生更改。从而解决了ABA问题。

解释一下上边的源码: 
类中维护了一个volatile修饰的Pair类型变量current,Pair是一个私有的静态类,current可以理解为底层数值。compareAndSet方法的参数部分分别为期望的引用、新的引用、期望的版本号、新的版本号。return的逻辑为判断了期望的引用和版本号是否与底层的引用和版本号相符,并且排除了新的引用和新的版本号与底层的值相同的情况(即不需要修改)的情况(return代码部分3、4行)。条件成立,执行casPair方法,调用CAS操作。

六、原子性操作各方法间的对比:

Synchronized:不可中断锁,适合竞争不激烈,可读性好。

Lock:可中断锁,多样化同步,竞争激烈时能维持常态。

Atomic:竞争激烈时能维持常态,比Lock性能好,每次只能同步一个值

猜你喜欢

转载自blog.csdn.net/linjiaen20/article/details/81188867
今日推荐