锁与CAS机制

锁与CAS机制

(一)锁的代价和无锁的优势

锁是用来做并发最简单的方式,当然其代价也是最高的。内核态的锁的时候需要操作系统进行一次上下文切换,加锁、释放锁会导致比较多的上下文切换和调度延时,等待锁的线程会被挂起直至锁释放。在上下文切换的时候,cpu之前缓存的指令和数据都将失效,对性能有很大的损失。操作系统对多线程的锁进行判断就像两姐妹在为一个玩具在争吵,操作系统就是能决定他们谁能拿到玩具的父母,这是很慢的。用户态的锁虽然避免了这些问题,但是其实它们只是在没有真实的竞争时才有效。

JDK1.5之前都是靠synchronized关键字保证同步的,这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有守护变量的锁,都采用独占的方式来访问这些变量,如果出现多个线程同时访问锁,那么一些线程将被挂起,当线程恢复执行时,必须等待其它线程执行完他们的时间片以后才能被调度执行,在挂起和恢复执行过程中存在着很大的开销。锁还存在着其它一些缺点,当一个线程正在等待锁时,它不能做任何事。如果一个线程在持有锁的情况下被延迟执行,那么所有需要这个锁的线程都无法执行下去。如果被阻塞的线程优先级高,而持有锁的线程优先级低,将会导致优先级反转。下图展现了synchronized的复杂流程:

在这里插入图片描述

CAS可以解决这一类弊端,那么CAS是什么呢?对于并发控制而言,锁是一种悲观策略,会阻塞线程执行,而无锁是一种乐观策略,它会假设对资源的访问时没有冲突,既然没有冲突就不需要等待,线程也就不需要阻塞。那多个线程共同访问临界区的资源怎么办呢,无锁的策略采用一种比较并交换技术CAS(compare and swap)来鉴别线程冲突,一旦检测到冲突,就重复当前操作直到没有冲突为止。与锁相比,CAS会使得程序设计比较复杂,但是由于其天生免疫死锁(根本就没有锁,当然就不会有线程一直阻塞了),更为重要的是,使用无锁的方式没有所竞争带来的开销,也没有线程间频繁调度带来的开销,他比基于锁的方式有更优越的性能,所以在目前已经被广泛应用。

(二)乐观锁与悲观锁

刚刚提到了悲观策略和乐观策略,所以我们来看看什么是乐观锁和悲观锁:

乐观锁(也被称为无锁,实际上不是一种锁,而是一种思想):乐观地认为别的线程不会修改值,如果发现值被修改了,可以再次重试,直到成功为止。我们要讲的CAS机制(Compare And Swap)就是一种乐观锁。

悲观锁:悲观地认为别的线程会修改值。独占锁是悲观锁的一种,加锁后就能够确保程序执行时不会被其它线程干扰,从而得到正确的结果。

(三)CAS机制

CAS机制全称compare and swap,翻译为比较并交换,是一种有名的无锁(lock-free)算法。也是一种现代 CPU 广泛支持的CPU指令级的操作,只有一步原子操作,所以非常快。而且CAS避免了请求操作系统来裁定锁的问题,直接在CPU内部就完成了。

在这里插入图片描述

CAS有三个操作参数:

1. 内存位置M(它的值是我们想要去更新的)
2. 预期原值E(上一次从内存中读取的值)
3. 新值V(应该写入的新值)

CAS的操作过程:首先读取内存位置M的原值,记为E,然后计算新值V,将当前内存位置M的值与E比较(compare),如果相等,则在此过程中说明没有其它线程来修改过这个值,所以把内存位置M的值更新成V(swap),当然这得在没有ABA问题的情况下(ABA问题会在后面讲到)。如果不相等,说明内存位置M上的值被其他线程修改过了,于是不更新,重新回到操作的开头再次执行(自旋)。

我们可以看一下用C来表示的CAS算法:

int cas(long *addr, long old, long new) {
    if(*addr != old)
        return 0;
    *addr = new;
    return 1;
}

所以,当多个线程尝试使用CAS同时更新同一个变量时,其中一个线程会成功更新变量的值,剩下的会失败,失败的线程可以不断重试直到成功。简单来说,CAS的含义是“我认为原有的值应该是什么,如果是,则将原有的值更新为新值,否则不做修改,并告诉我这个值现在是多少”。

有人可能会很好奇,CAS操作,先读再比较,然后设置值,步骤这么多,会不会在步骤之间被其它线程干扰导致冲突?其实是不会的,因为在底层汇编代码中CAS操作并不是用三条指令实现的,而是仅仅是一条指令:lock cmpxchg(x86架构),因此不会出现在CAS执行过程中时间片被抢走的情况。但是这就涉及到另一个问题,CAS操作过分的依赖CPU的设计,也就是说CAS本质是CPU中的一条指令。如果CPU不支持CAS操作,CAS就无法实现。

既然在CAS中存在不断尝试的过程,那么会不会造成很大的资源浪费呢?答案是有可能的,这也是CAS的缺陷之一。但是,既然CAS是一个乐观锁,那么设计者在设计时就应该抱着的是乐观的态度,换句话说,CAS认为自己有非常大的概率是能够成功完成当前操作的,所以在CAS看来,完不成便重试(自旋)是一个小概率事件。

(四)CAS存在的问题

  1. ABA问题
    因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。Java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。

  2. 自旋时间过长
    使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。

  3. 只能保证一个共享变量的原子操作
    当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

由此可见,CAS虽然存在一些问题,但也在不断优化和解决之中。

(五)CAS的一些应用

在jdk中有个java.util.concurrent.atomic包,里面的类都是基于CAS实现的无锁操作,这些原子类都是线程安全的。Atomic包里一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。Atomic包里的类基本都是使用Unsafe实现的包装类。我们可以挑一个经典的类来讲一讲(听说好多人都是从这个类接触到CAS的),就是AtomicInteger类。

我们可以先看看AtomicInteger是如何初始化的:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // 很显然AtomicInteger是基于Unsafe类实现的
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    
    // 属性value值在内存中偏移量
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    // AtomicInteger本身是个整型,所以属性就是int,被volatile修饰保证线程可见性
    private volatile int value;

    /**
     * Creates a new AtomicInteger with the given initial value.
     *
     * @param initialValue the initial value
     */
    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    /**
     * Creates a new AtomicInteger with initial value {@code 0}.
     */
    public AtomicInteger() {
    }
}   

我们可以看出几点:Unsafe类(类似C语言中的指针)是CAS的核心,也就是AtomicInteger的核心。valueOffset是value在内存中的偏移地址,而Unsafe提供了相应的操作方法。value被volatile关键字修饰,保证了线程可见性,这是真正存储值的变量。

我们来看看AtomicInteger中最常用的getAndIncrement方法是如何实现的:

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

很显然他调用了unsafe中的getAndAddInt方法,那我们就跳转到这个方法中:

/**
 * Atomically adds the given value to the current value of a field
 * or array element within the given object <code>o</code>
 * at the given <code>offset</code>.
 *
 * @param o object/array to update the field/element in
 * @param offset field/element offset
 * @param delta the value to add
 * @return the previous value
 * @since 1.8
 */
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    return v;
}

 /**
  * Atomically update Java variable to <tt>x</tt> if it is currently
  * holding <tt>expected</tt>.
  * @return <tt>true</tt> if successful
  */
 public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

我们可以看到,getAndAddInt方法中的do-while循环相当于CAS中的自旋部分,如果无法替换成功就不断地尝试,直到成功为止。而真正的CAS的核心代码(比较并交换的过程)在compareAndSwapInt这个native方法中,调用了本地的C++代码:

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
	UnsafeWrapper("Unsafe_CompareAndSwapInt");
	oop p = JNIHandles::resolve(obj);
	//获取对象的变量的地址
	jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
	//调用Atomic操作
	//先去获取一次结果,如果结果和现在不同,就直接返回,因为有其他人修改了;否则会一直尝试去修改。直到成功。
	return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

我们终于看见了我们熟悉的指令:cmpxchg。也就是说,我们之前讲的内容已经完全串起来了。CAS的本质就是一个CPU指令,而所有的原子类的实现都是在一层层的调用这个指令而已,从而实现我们需要的无锁操作。

假如现有一个new AtomicInteger(0);现在有线程1和线程2同时要对其执行getAndAddInt操作。
1)线程1先拿到值0,此时线程切换;
2)线程2拿到值也为0,此时调用Unsafe比较内存中的值也是0,比较成功,即进行+1的更新操作,即现在的值为1。线程切换;
3)线程1恢复运行,利用CAS发现自己的值是0,而内存中则是1。得到:此时值被另外一个线程修改,我不能进行修改;
4)线程1判断失败,继续循环取值,判断。因为volatile修饰value,所以再取到的值也是1。这是在执行CAS操作,发现expect和此时内存的值相等,修改成功,值为2;
5)在第四步中的过程中,即使在CAS操作时有线程3来抢占资源,但是也是无法抢占成功的,因为compareAndSwapInt是一个原子操作。

2020年5月30日

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章