JUC(8) : CAS | 无锁的执行者

1,931 阅读9分钟

我正在参加「掘金·启航计划」

前言

前面我们针对多线程开发产生的三大问题,例如原子性、可见性、有序性等都可以通过加锁来实现,而今天要讲的则是通过无锁来实现,CAS 技术是无锁实现的关键。

一、CAS 概述

1.1 为什么要无锁

其实不管是加锁还是不加锁,都是一种悲观锁和乐观锁的体现。

我们之前的加锁都是悲观锁的思想,它总会认为数据会被修改,所以在操作一部分代码块之前都会先加一把锁,操作完毕之后再释放。

例如下面的案例,要想使其安全,必须加锁,而加锁本身是一种很重的行为,而使用原子类后,我们就无需加锁,即可保证线程安全。

    volatile int number =0;
    public int getNumber(){
        return number;
    }
    public synchronized void num(){
        number++;
    }

而无锁算法,比如 CAS,它会认为别人去拿数据的时候不会修改,但是在修改数据的时候会去判断一下数据此时的状态,这样的读多的情况下性能会得到大幅提升,所以加锁和不加锁都是针对不同场景来说的,不管是加锁还是无锁都有其优劣,后面我们在讲。

1.2 什么是 CAS

CAS 是 compare and swap 的缩写,比较并交换,是实现并发算法常用到的一种技术。

它包含三个操作数--内存位置V、预期原值A、更新值B。

执行 CAS 操作的时候,将内存位置的值与预期原值比较;

  • 如果相匹配,那么处理器回自动将该位置值更新为新值;
  • 如果不匹配,处理器不做任何操作,多个线程同时执行 CAS 操作,只有一个会成功。

即,当且仅当旧的预期 A 和 内存值 V 相同时,将内存 V 修改为 B,否则什么都不做或重来,当它重来重试的这种行为称为:自旋。

【注意】:是否当刚好判断 V 中值等于 A 的值的时候,另一个线程来修改了V 的值这种情况呢?答案是不会的,CAS 操作的最终实现是依赖 CPU 原子性指令实现的,怎么说呢?CAS 是一种操作系统原语范畴的指令,是连续的,不允许被打断,不会造成数据不一致问题。

二、Unsafe 类(重要)

2.1 引出

CAS 是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较--更新的原子性。

它是非阻塞的且自身具有原子性,也就是说这玩意效率更高且通过硬件保证,说明这玩意更可靠。

CAS 是一条 CPU 的原子指令,不会造成所谓的数据不一致问题,UnSafe 提供的 CAS 方法(compareAndSwapxx)底层实现即为 CPU 指令 cmpxchg。

执行 cmpxchg 指令的时候,会判断当前系统是否为多核系统,如果是就给总线加锁,只有一个线程会对总线加锁成功,加锁成功之后执行 CAS 操作,也就是说 CAS 的原子性实际上是 CPU 实现独占的,比起 synchronized 重量级锁,这里的排他时间要短很多,所以在多线程情况下性能会比较好。

而这些方法的引出都是来源于 unsafe类,那么 unsafe 类是什么呢?

2.2 详解

1、何为 Unsafe

它是 CAS 的核心类,由于 Java 方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe 相当于一个后门,基于该类可以直接操作特定内存的数据。

Unsafe 类存在于sun.misc 包中,其内部方法操作可以像 C 的指针一样直接操作内存,因为 Java 中 CAS 操作的执行依赖于 Unsafe 类的方法。

注意:Unsafe 类中的所有方法都是 native 修饰的,也就是说 Unsafe 类中的方法都直接调用操作系统底层资源执行相应任务。

2、变量 valueOffset

该值标识在内存的偏移地址,因为 Unsafe 就是根据内存偏移地址获取数据的。

原子类主要通过 CAS + volatile 和 native 方法来保证原子操作,从而避免锁的高开销。

3、变量value 用 volatile 修饰,保证了线程之间的可见性

2.3 汇编源码分析

    public final int getAndSetInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var4));

        return var5;
    }

由这步继续查看 compareAndSwapInt 方法。通过该方法定位查看汇编如下

总结:

  • CAS 是靠硬件实现的从而在硬件层面提示效率,最底层还是交给硬件来保证原子性和可见性
  • 实现方式是基于硬件平台的汇编指令,在 intel 的CPU中,使用的是汇编指令 cmpxchg 指令。
  • 核心思想是:比较要更新变量的值 V 和预期值 E ,相等才会将 V 的值设为 新值 N,如果不相等自旋再来。

三、原子引用

AtomicInteger原子整型,可否有其它原子类型?

有的,可以利用 AtomicReference 传入泛型对象即可。

  • AtomicBook
  • AtomicOrder
@Getter
@ToString
@AllArgsConstructor
class User
{
    String userName;
    int    age;
}


public class AtomicReferenceDemo
{
    public static void main(String[] args)
    {
        User z3 = new User("z3",24);
        User li4 = new User("li4",26);

        AtomicReference<User> atomicReferenceUser = new AtomicReference<>();

        atomicReferenceUser.set(z3);
        System.out.println(atomicReferenceUser.compareAndSet(z3,li4)+"\t"+atomicReferenceUser.get().toString());
        System.out.println(atomicReferenceUser.compareAndSet(z3,li4)+"\t"+atomicReferenceUser.get().toString());
    }
}

四、自旋锁,借鉴CAS思想

自旋锁(spinlock)

是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁, 当线程发现锁被占用时,会不断循环判断锁的状态,直到获取。这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

CAS 是实现自旋锁的基础。

/**
 * 题目:实现一个自旋锁
 * 自旋锁好处:循环比较获取没有类似wait的阻塞。
 *
 * 通过CAS操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒钟,B随后进来后发现
 * 当前有线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到。
 */
public class SpinLockDemo
{
    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    public void myLock()
    {
        Thread thread = Thread.currentThread();
        System.out.println(Thread.currentThread().getName()+"\t come in");
        while(!atomicReference.compareAndSet(null,thread))
        {

        }
    }

    public void myUnLock()
    {
        Thread thread = Thread.currentThread();
        atomicReference.compareAndSet(thread,null);
        System.out.println(Thread.currentThread().getName()+"\t myUnLock over");
    }

    public static void main(String[] args)
    {
        SpinLockDemo spinLockDemo = new SpinLockDemo();

        new Thread(() -> {
            spinLockDemo.myLock();
            try { TimeUnit.SECONDS.sleep( 5 ); } catch (InterruptedException e) { e.printStackTrace(); }
            spinLockDemo.myUnLock();
        },"A").start();

        //暂停一会儿线程,保证A线程先于B线程启动并完成
        try { TimeUnit.SECONDS.sleep( 1 ); } catch (InterruptedException e) { e.printStackTrace(); }

        new Thread(() -> {
            spinLockDemo.myLock();
            spinLockDemo.myUnLock();
        },"B").start();

    }
}

五 CAS 缺点

优点:避开重量锁的使用。

5.1 循环时间长开销大

如果 CAS 失败,会一直进行尝试,如果 CAS 长时间一直不能成功,可能会给 CPU 带来很大的开销。

5.2 ABA 问题(狸猫换太子)

CAS会导致“ABA问题”。

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

比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且线程two进行了一些操作将值变成了B,然后线程two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后线程one操作成功。

尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。

如果是基本类型是没有问题的,但是如果是引用类型呢,引用类型的数据发生变化,并不直到修改了。

我们可以通过加版本来避免。Java 中主要通过 atomicStampedReference 解决。

atomicStampedReference 是一个带有时间戳的对象的对象引用,在每次更新时,先对数据本身和时间戳进行比对,两者都符合时才会写入,修改时,记录下更新的时间戳,解决了 CAS 带来的问题。案例如下:

public class ABADemo
{
    static AtomicInteger atomicInteger = new AtomicInteger(100);
    static AtomicStampedReference atomicStampedReference = new AtomicStampedReference(100,1);

    public static void main(String[] args)
    {
        new Thread(() -> {
            atomicInteger.compareAndSet(100,101);
            atomicInteger.compareAndSet(101,100);
        },"t1").start();

        new Thread(() -> {
            //暂停一会儿线程
            try { Thread.sleep( 500 ); } catch (InterruptedException e) { e.printStackTrace(); };            System.out.println(atomicInteger.compareAndSet(100, 2019)+"\t"+atomicInteger.get());
        },"t2").start();

        //暂停一会儿线程,main彻底等待上面的ABA出现演示完成。
        try { Thread.sleep( 2000 ); } catch (InterruptedException e) { e.printStackTrace(); }

        System.out.println("============以下是ABA问题的解决=============================");

        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+"\t 首次版本号:"+stamp);//1
            //暂停一会儿线程,
            try { Thread.sleep( 1000 ); } catch (InterruptedException e) { e.printStackTrace(); }
            atomicStampedReference.compareAndSet(100,101,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
            System.out.println(Thread.currentThread().getName()+"\t 2次版本号:"+atomicStampedReference.getStamp());
            atomicStampedReference.compareAndSet(101,100,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
            System.out.println(Thread.currentThread().getName()+"\t 3次版本号:"+atomicStampedReference.getStamp());
        },"t3").start();

        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+"\t 首次版本号:"+stamp);//1
            //暂停一会儿线程,获得初始值100和初始版本号1,故意暂停3秒钟让t3线程完成一次ABA操作产生问题
            try { Thread.sleep( 3000 ); } catch (InterruptedException e) { e.printStackTrace(); }
            boolean result = atomicStampedReference.compareAndSet(100,2019,stamp,stamp+1);
            System.out.println(Thread.currentThread().getName()+"\t"+result+"\t"+atomicStampedReference.getReference());
        },"t4").start();
    }
}

六、总结

CAS 它是比较并交换的简称嘛,也是无锁的执行者,通过不加锁的方式,使用 CAS 可以保证我们的线程安全。它是一种乐观锁的体现,乐观锁就是每次去拿数据的时候都认为别人不会修改,但在修改的时候会判断下数据是否被修改。这种思想在读多写少的场景下可以提高性能。

CAS 算法,它内部有三个值嘛,内存位置V、预期值A,修改值B

它会拿内存位置上的值与预期值进行一个对比,如果相等,就会将内存值修改为新值

如果不相等,就会通过 CAS 的方式自旋重试这种机制,多个线程执行 CAS 操作只会有一个成功。

CAS 的底层使用的是 unsafe 这个类,它是不安全的嘛,它是一种本地方法,可以调用操作系统资源直接操作内存,CAS 它是在硬件层面保证它的原子性操作的,底层是一个CPU 指令 cmpxchg。执行 cmpxchg 指令的时候,会判断当前系统是否为多核系统,如果是就给总线加锁,只有一个线程会对总线加锁成功,加锁成功之后执行 CAS 操作,也就是说 CAS 的原子性实际上是 CPU 实现独占的。

CAS 它有两个缺点:

  • ABA 问题:
    • ABA 问题就是当我这个线程将值改为 B后又改回A,然后其他线程过来的时候就认为它没有修改,然后就执行成功。这种方式在对基本数据类型时是没有问题的,但是在面对引用数据类型时,可能会出现引用地址不变,但是引用的对象发生了改变。解决方式就是通过 Java 提供的 atomicStampedReference 来解决,原理就是通过一个时间戳,每次比较的时候要比较这个时间戳来判断到底有没有发生ABA 问题,都符合的时候才进行修改。
  • 循环等待过长