CAS乐观锁

1,399 阅读9分钟

1.乐观锁CAS和悲观锁synchronized

Compare and Swap,简称CAS,是一种通过乐观锁保证变量操作原子性的机制。

我们通常熟悉的加锁方式是通过synchronized关键字,可以很方便地给实例方法、静态方法和代码块加锁,保证操作的原子性。

synchronized锁是是悲观的,是同步锁,它假定更新很可能冲突,所以先获取锁,得到锁后才更新。

synchronized代表一种阻塞式算法,得不到锁的时候,进入锁等待队列,等待其他线程唤醒,有上下文切换开销。原子变量的更新逻辑是非阻塞式的,更新冲突的时候,它就重试,不会阻塞,不会有上下文切换开销。

与synchronized锁相比,CAS原子更新方式代表一种不同的思维方式。

悲观锁更新的思维方式认为,在更新数据的时候大概率会有其他线程去争夺共享资源,所以悲观锁的做法是,第一个获取资源的线程会将资源锁定起来,其他没争夺到资源的线程只能进入阻塞队列,等第一个获取资源的线程释放锁之后,这些线程才能有机会重新争夺资源。

synchronized就是java中悲观锁的典型实现,synchronized的优点是使用起来非常简单方便,但缺点是会使没争抢到资源的线程进入阻塞状态,线程在阻塞状态和Runnable状态之间切换效率较低(比较慢)。

在更新操作非常快的情况下,仍然用synchronized将其他线程都锁住,现场状态切换的成本就变得非常高,线程从Blocked状态切换回Runnable花费的时间可能比更新操作的时间还要长。

乐观锁更新的思维方式认为,在更新数据的时候其他线程争抢这个共享变量的概率非常小,所以更新数据的时候不会对共享数据加锁,但在正式更新数据之前会检查数据是否被其他线程改变过,如果未被其他线程改变过就将共享变量更新成最新值,如果发现共享变量已经被其他线程更新过了就重试,直到成功为止。CAS机制就是乐观锁的典型实现。在并发量不是很高的情况下,或者线程对共享资源的占用时间不是很长,由于避免了频繁切换线程状态,因此使用CAS机制的效率会比synchronized锁更高。

2.原子变量CAS原理

Java的原子变量内部就是通过CAS指令来实现的。

原子变量的更新逻辑是乐观的,它假定冲突比较少,但使用CAS更新,也就是进行冲突检测,如果确实冲突了,那也没关系,继续尝试就好了。

对于大部分比较简单的操作,无论是在低并发还是高并发情况下,这种乐观非阻塞方式的性能都远高于悲观阻塞式方式。

Java并发包中的基本原子变量类型有以下几种。

  • AtomicBoolean:原子Boolean类型,常用来在程序中表示一个标志位。

  • AtomicInteger:原子Integer类型。

  • AtomicLong:原子Long类型,常用来在程序中生成唯一序列号。

  • AtomicReference:原子引用类型,用来以原子方式更新复杂类型。

  • LongAdder、LongAccumulator、Double-Adder和DoubleAccumulator:Java 8增加,适合高并发统计汇总的场景。

原子变量相对比较简单,但对于复杂一些的数据结构和算法,非阻塞方式往往难于实现和理解,幸运的是,Java并发包中已经提供了一些非阻塞容器,我们只需要会使用就可以了,比如:

  • ConcurrentLinkedQueue和ConcurrentLinkedDeque:非阻塞并发队列。

  • ConcurrentSkipListMap和ConcurrentSkipListSet:非阻塞并发Map和Set。

  • AtomicLongArray、AtomicReferenceArray:如针对数组类型的类。

  • AtomicIntegerFieldUpdater、AtomicReferenceFieldUpdater:原子方式更新对象中的字段。

AtomicInteger包含一些以原子方式实现组合操作的方法,部分方法如下:

//以原子方式获取旧值并设置新值
public final int getAndSet(int newValue) 

//以原子方式获取旧值并给当前值加1
public final int getAndIncrement()

//以原子方式获取旧值并给当前值减1
public final int getAndDecrement()

//以原子方式获取旧值并给当前值加delta
public final int getAndAdd(int delta)

//以原子方式给当前值加1并获取新值
public final int incrementAndGet()

//以原子方式给当前值减1并获取新值
public final int decrementAndGet()

//以原子方式给当前值加delta并获取新值public final int
addAndGet(int delta)

//compareAndSet是一个非常重要的方法,比较并设置,我们以后将简称为CAS。
//该方法有两个参数expect和update,以原子方式实现了如下功能:
// 如果当前值等于expect,则更新为update,否则不更新,
// 如果更新成功,返回true,否则返回false。
public final boolean compareAndSet(int expect, int update)

以incrementAndGet方法为例,代码主体是个死循环,先获取当前值current,计算期望的值next,然后调用CAS方法进行更新,如果更新没有成功,说明value被别的线程改了,则再去取最新值并尝试更新直到成功为止。源码如下:

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if(compareAndSet(current, next)){
            return next;
        }
    }
}

synchronized是悲观的,它假定更新很可能冲突,所以先获取锁,得到锁后才更新。

原子变量的更新逻辑是乐观的,它假定冲突比较少,但使用CAS更新,也就是进行冲突检测,如果确实冲突了,那也没关系,继续尝试就好了。

synchronized代表一种阻塞式算法,得不到锁的时候,进入锁等待队列,等待其他线程唤醒,有上下文切换开销。

原子变量的更新逻辑是非阻塞式的,更新冲突的时候,它就重试,不会阻塞,不会有上下文切换开销。对于大部分比较简单的操作,无论是在低并发还是高并发情况下,这种乐观非阻塞方式的性能都远高于悲观阻塞式方式。

原子变量相对比较简单,但对于复杂一些的数据结构和算法,非阻塞方式往往难于实现和理解,幸运的是,Java并发包中已经提供了一些非阻塞容器,我们只需要会使用就可以了,比如:

ConcurrentLinkedQueue和ConcurrentLinkedDeque:非阻塞并发队列。 ConcurrentSkipListMap和ConcurrentSkipListSet:非阻塞并发Map和Set。

compareAndSet是怎么实现的呢?我们看代码:

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

它调用了unsafe的compareAndSwapInt方法。

unsafe是什么呢?它的类型为sun.misc.Unsafe,定义为:

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

它是Sun的私有实现,从名字看,表示的也是“不安全”,一般应用程序不应该直接使用。

原理上,一般的计算机系统都在硬件层次上直接支持CAS指令,而Java的实现都会利用这些特殊指令。

从程序的角度看,可以将compareAndSet视为计算机的基本操作,直接接纳就好。

基于CAS,除了可以实现乐观非阻塞算法之外,还可以实现悲观阻塞式算法,比如锁。

实际上,Java并发包中的所有阻塞式工具、容器、算法也都是基于CAS的(不过,也需要一些别的支持)。用AtomicInteger实现一个锁MyLock:

public class MyLock {
    private Atomicinteger status = new AtomicInteger(0);
    public void lock() {
        while(!status.compareAndSet(0, 1)) {
            Thread.yield();
        }
    }
    public void unlock() {
        status.compareAndSet(1, 0);
    }
}

在MyLock中,使用status表示锁的状态,0表示未锁定,1表示锁定,lock()、unlock()使用CAS方法更新,lock()只有在更新成功后才退出,实现了阻塞的效果,不过一般而言,这种阻塞方式过于消耗CPU,MyLock只是用于演示基本概念,实际开发中应该使用Java并发包中的类,如ReentrantLock。

3.CAS的问题与解决

CAS比悲观锁的效率高,从阻塞机制变成了非阻塞机制,减少了线程之间等待的时间,有一个前提,就是并发量不大。

因为在线程之间竞争程度大的时候,如果使用CAS,每次都有很多的线程在竞争,也就是说CAS机制不能更新成功。这种情况下CAS机制会一直重试,这样就会比较耗费CPU。

因此可以看出,如果线程之间竞争程度小,使用CAS是一个很好的选择;但是如果竞争很大,使用锁可能是个更好的选择。

在并发量非常高的环境中,如果仍然想通过原子类来更新的话,可以使用AtomicLong的替代类:LongAdder。

除了在高并发场景下耗费CPU的问题,使用CAS方式更新还有一个ABA问题。

该问题是指,假设当前值为A,如果另一个线程先将A修改成B,再修改回成A,当前线程的CAS操作无法分辨当前值发生过变化。

ABA是不是一个问题与程序的逻辑有关,即B之前的A和B之后的A不影响程序逻辑,ABA也就不是问题。

而如果确实有问题,解决方法是使用AtomicStampedReference,在修改值的同时附加一个时间戳,只有值和时间戳都相同才进行修改,其CAS方法声明为:

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)));
}

AtomicStampedReference在compareAndSet中要同时修改两个值:一个是引用,另一个是时间戳。

它怎么实现原子性呢?实际上,内部AtomicStampedReference会将两个值组合为一个对象,修改的是一个值,我们看代码:

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)));
}

这个Pair是AtomicStampedReference的一个内部类,成员包括引用和时间戳,具体定义为:

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);    
}
}

AtomicStampedReference将对引用值和时间戳的组合比较和修改转换为了对这个内部类Pair单个值的比较和修改。

对于并发环境中的计数、产生序列号等需求,应该使用原子变量而非锁,CAS是Java并发包的基础,基于它可以实现高效的、乐观、非阻塞式数据结构和算法,它也是并发包中锁、同步工具和各种容器的基础。

4.参考资料