写一篇万字长文《CAS自旋锁》送给杰伦的新专辑

803 阅读9分钟

前序

时隔多年,杰伦终于出了新专辑,《最伟大的作品》让我们穿越到1920年,见到了马格利特的绿苹果、大利的超现实、常玉画的大腿、莫奈的睡莲、徐志摩的诗...

他说“最伟大的作品”并不是自己的歌,而是这个世界上最伟大的艺术作品们。

为什么要写CAS自旋锁呢?最近看了一下Java实现随机数的几种方式,研究研究就研究到量子力学去了,所以还是回归代码上来,看了看底层实现都是用的CAS,正好又赶上周董发歌,就凑个巧吧~

CAS核心原理

CAS即Compare and Swap,翻译成比较并交换。

CAS是一种乐观锁,所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

【至于其他的各种锁,我们下一篇再谈论,本篇主要讲解CAS】

CAS 操作包含三个操作数 —— 内存位置V、预期原值A和新值B。

如果内存位置的值与预期原值相匹配,那么处理器会自动将该内存位置值更新为新值 ,否则,处理器不做任何操作。

i++和++i是原子操作么

先说答案:不是。

反编译成字节码文件就很容易看出来此非原子性操作,先是getfield,然后再iadd。

i++大体分为三步:

  1. 栈中取出i
  2. i自增1
  3. 将i存到栈

++i

在多核的机器上,cpu在读取内存i时也会可能发生同时读取到同一值,这就导致两次自增,实际只增加了一次。

++i 如何实现原子性

++i是如何实现原子性】

代码实现

public final int incrementAndGet() {
	// 死循环
    for (;;) {
    	// 预期原值(A)
        int current = get();
        // 新值(B)
        int next = current + 1;
        // CAS操作
        if (compareAndSet(current, next))
        	// 成功则返回结果
            return next;
    }
}

这里采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。

#compareAndSet利用JNI来完成CPU指令的操作

/**
 * Atomically sets the value to the given updated value
 * if the current value {@code ==} the expected value.
 *
 * @param expect the expected value
 * @param update the new value
 * @return {@code true} if successful. False return indicates that
 * the actual value was not equal to the expected value.
 */
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

深入到#compareAndSwapInt方法,你会发现它是通过JNI方法实现的

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

这个方法到底做了什么事呢?应该做了两件事情。

  1. 当前内存位置V是否等于预期原值A
  2. 如果等于就将内存位置V更新为新值B,反正返回false

为了更清晰、直观的说明这里存在的问题,我们用代码说明。

// 1.当前内存位置V是否等于预期原值A
if (V == A) {
	// 2.如果等于就将内存位置V更新为新值B
    V = B;
    return true;
} else {
	// 反正返回false
    return false;
}

这里存在一个问题,如何保证步骤1和步骤2的原子性呢?

那么,我们接下来的问题,就要探究一下compareAndSwapInt的实现了。

上面我们提到,CAS是通过调用JNI的代码实现的。

JNI:Java Native Interface为JAVA本地调用,允许java调用其他语言,JNI方法最终会通过 Jni.dvmCallJNIMethod() -> dvmPlatformInvoke() 来根据不同cpu架构实现进行调用,具体如何使用,我们下一篇再聊。

如何用Java调用C

算了,我就先给大家写一个小demo,让大家先简单的了解一下如何使用Java调用其他语言。

第一步:

写一个测试类

package com.ossa.producer.jni;

/**
 * JNI测试类
 *
 * @author issavior
 */
public class JniUnit {

    /**
     * 调用本地方法
     */
    public native void sayHello();

    /**
     * 静态块用来加载库,jni.so需要我们手动生成,放在该路径下
     */
    static {
        System.load("/Users/issavior/java/mygit/ossa/ossa-service-producer/src/main/resources/jni.so");
    }

    /**
     * 程序入口
     *
     * @param args 参数
     */
    public static void main(String[] args) {
        new JniUnit().sayHello();
    }
}

第二步:

如果是Maven项目,可以通过mvn命令来编译该Java文件,如果不是,就用javac编译即可,我这里采用mavan方式。

第三步:

class路径下,执行javah命令

issavior@issavior classes % javah com.ossa.producer.jni.JniUnit

之后会生成jni头文件com_ossa_producer_jni_JniUnit.h

/* DO NOT EDIT THIS FILE - it is machine generated */
#include "jni.h"
/* Header for class com_ossa_producer_jni_JniUnit */

#ifndef _Included_com_ossa_producer_jni_JniUnit
#define _Included_com_ossa_producer_jni_JniUnit
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_ossa_producer_jni_JniUnit
 * Method:    sayHello
 * Signature: ()I
 */
JNIEXPORT void JNICALL Java_com_ossa_producer_jni_JniUnit_sayHello
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

第四步:

编写实现的c文件jniUnit.c,引入刚才生成的头文件和方法实现

#include "com_ossa_producer_jni_JniUnit.h"
JNIEXPORT void JNICALL Java_com_ossa_producer_jni_JniUnit_sayHello
  (JNIEnv * env, jobject obj){
   printf("hello JNI");
  }

从你的$JAVA_HOME/include/ 目录和$JAVA_HOME/include/darwin/目录下分别找到jni.hjni_md.h头复制到当前目录【mac的话,首先需要shift+command+.打开隐藏文件】

![在这里插入图片描述](img-blog.csdnimg.cn/ec9107d4e0e… =500x) 执行 gcc -shared -fPIC -o jni.so jniUnit.c 进行编译生成动态库

如果此时报如下错误

![在这里插入图片描述](img-blog.csdnimg.cn/55e3e36080b… =800x) 就将com_ossa_producer_jni_JniUnit.h文件中#include <jni.h>修改为#include "jni.h"

编译成功后当前目录会出现jni.so文件,放置到指定的目录下即可。

最后测试我们的JniUnit类,成功!

![在这里插入图片描述](img-blog.csdnimg.cn/64a84036fce… =800x)

到此,我已经亲手带大家完成了Java调用其他语言的小案例,这样大家是不是对native方法有了更深的了解,而compareAndSwapInt就是借助C来调用CPU底层的指令(cmpxchg)来实现的。

cmpxchg是一个原子指令,这个指令是给数据总线进行加锁,所以是线程安全的。

那我们就来分析一下它的源码吧,那么如何找到本地方法实现的位置呢?

JNI命名规范

通过上面的案例,我们可以知道javah可以帮助我们生成头文件,那么大家就会发现native方法的本地方法名是遵循一定的规则生成的。因此可以先生成对应的本地方法名,然后再到源码中搜索。

根据 JNI 的本地方法名生成规范:

  • 前缀为 Java_
  • 完全限定的类名(包括包名和类的全路径),中间以 _ 分割
  • 方法名
  • 对于重载的 native 方法,方法名后要再跟上 __ 和参数标签

我们可以推断出 intern 方法的本地方法名:

  • 以 Java_ 开头
  • 包名转换后为 java_lang_String
  • 方法名为 intern
  • 拼接后结果为 Java_sun_misc_Unsafe_compareAndSwapInt

compareAndSwarpInt源码分析

在unsafe.cpp文件中,可以找到compareAndSwarpInt的实现:

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)) 
 
	UnsafeWrapper("Unsafe_CompareAndSwapInt"); 
    // 将Java对象解析成JVM的oop(普通对象指针)
	oop p = JNIHandles::resolve(obj); 
 	// 根据对象p和地址偏移量找到地址 
	jint* addr = (jint *) index_oop_from_field_offset_long(p, offset); 
 	// //基于cas比较并替换, x表示需要更新的值,addr表示state在内存中的地址,e表示预期值 
	return (jint)(Atomic::cmpxchg(x, addr, e)) == e; 

	UNSAFE_END

CAS缺点

CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。 ABA问题

如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。

在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

// 静态内部类,封装了 变量引用 和 版本号
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;
 
    /**
     *
     * @param initialRef  初始变量引用
     * @param initialStamp  版本号
     */
    public AtomicStampedReference(V initialRef, int initialStamp) {
        pair = Pair.of(initialRef, initialStamp);
    }

常用方法

// 构造函数,初始化引用和版本号
public AtomicStampedReference(V initialRef, int initialStamp)
 
// 以原子方式获取当前引用值
public V getReference()
 
// 以原子方式获取当前版本号
public int getStamp()
 
// 以原子方式获取当前引用值和版本号
public V get(int[] stampHolder)
 
// 以原子的方式同时更新引用值和版本号
// 当期望引用值不等于当前引用值时,操作失败,返回false
// 当期望版本号不等于当前版本号时,操作失败,返回false
// 在期望引用值和期望版本号同时等于当前值的前提下
// 当新的引用值和新的版本号同时等于当前值时,不更新,直接返回true
// 当新的引用值和新的版本号不同时等于当前值时,同时设置新的引用值和新的版本号,返回true
public boolean weakCompareAndSet(V  expectedReference,
                                 V  newReference,
                                 int expectedStamp,
                                 int newStamp)
 
// 以原子的方式同时更新引用值和版本号
// 当期望引用值不等于当前引用值时,操作失败,返回false
// 当期望版本号不等于当前版本号时,操作失败,返回false
// 在期望引用值和期望版本号同时等于当前值的前提下
// 当新的引用值和新的版本号同时等于当前值时,不更新,直接返回true
// 当新的引用值和新的版本号不同时等于当前值时,同时设置新的引用值和新的版本号,返回true
public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp)
 
// 以原子方式设置引用的当前值为新值newReference
// 同时,以原子方式设置版本号的当前值为新值newStamp
// 新引用值和新版本号只要有一个跟当前值不一样,就进行更新
public void set(V newReference, int newStamp)
 
// 以原子方式设置版本号为新的值
// 前提:引用值保持不变
// 当期望的引用值与当前引用值不相同时,操作失败,返回fasle
// 当期望的引用值与当前引用值相同时,操作成功,返回true
public boolean attemptStamp(V expectedReference, int newStamp)
 
// 使用`sun.misc.Unsafe`类原子地交换两个对象
private boolean casPair(Pair<V> cmp, Pair<V> val)

案例

如果线程安全

    /**
     * 程序入口
     *
     * @param args 参数
     */
    public static void main(String[] args) {

        // 初始引用值是【1】;版本号是【1】
        AtomicStampedReference<Integer> reference = new AtomicStampedReference<>(1, 1);
        Integer reference1 = reference.getReference();
        System.out.println("初始引用值" + reference1); // 初始引用值1

        // 期望的初始引用值是【1】;
        // 更新引用为2;
        // 期望的初始版本号是【1】;
        // 更新版本号为2
        boolean b = reference.weakCompareAndSet(1, 2, 1, 2);

        // 是否swap成功
        System.out.println(b); // true
        // 再次获取引用值
        Integer reference2 = reference.getReference();
        System.out.println("最新引用值" + reference2); // 最新引用值2
    }

如果线程不安全

    /**
     * 程序入口
     *
     * @param args 参数
     */
    public static void main(String[] args) {

        // 初始引用值是【1】;版本号是【1】
        AtomicStampedReference<Integer> reference = new AtomicStampedReference<>(1, 1);
        Integer reference1 = reference.getReference();
        System.out.println("初始引用值" + reference1); // 初始引用值1

        // 此时线程不安全,期望的引用值是【2】;
        // 更新引用为2;
        // 此时线程不安全,期望的版本号是【2】;
        // 更新版本号为2
        boolean b = reference.weakCompareAndSet(2, 2, 2, 2);

        // 是否swap成功
        System.out.println(b); // false
        // 再次获取引用值
        Integer reference2 = reference.getReference();
        System.out.println("最新引用值" + reference2); // 最新引用值1
    }

循环时间长开销大

如果CAS不成功,则会原地自旋,如果长时间自旋会给CPU带来非常大且没必要的开销。

可以破坏掉for死循环,当超过一定时间或者一定次数时,return退出。

JDK8新增的LongAdder和ConcurrentHashMap类似的方法。

当多个线程竞争时,将粒度变小,将一个变量拆分为多个变量,达到多个线程访问多个资源的效果,最后再调用sum把它合起来。

虽然base和cells都是volatile修饰的,但这个sum操作没有加锁,可能sum的结果不是那么精确。

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

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。

比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

AtomicReference的API

// 当使用无参构造函数创建AtomicReference对象的时候,
// 需要再次调用set()方法为AtomicReference内部的value指定初始值。
AtomicReference()
// 创建AtomicReference对象时顺便指定初始值。
AtomicReference(V initialValue);
/**
原子性地更新AtomicReference内部的value值,
其中expect代表当前AtomicReference的value值,update则是需要设置的新引用值。
该方法会返回一个boolean的结果,
当expect和AtomicReference的当前值不相等时,修改会失败,返回值为false,
若修改成功则会返回true。
**/
compareAndSet(V expect, V update)
// 原子性地更新AtomicReference内部的value值,并且返回AtomicReference的旧值。
getAndSet(V newValue)
// 原子性地更新value值,并且返回AtomicReference的旧值,该方法需要传入一个Function接口。
getAndUpdate(UnaryOperator<V> updateFunction)
// 原子性地更新value值,并且返回AtomicReference更新后的新值,该方法需要传入一个Function接口。
updateAndGet(UnaryOperator<V> updateFunction)
// 原子性地更新value值,并且返回AtomicReference更新前的旧值。
// 该方法需要传入两个参数,第一个是更新后的新值,第二个是BinaryOperator接口。
getAndAccumulate(V x, BinaryOperator<V> accumulatorFunction)
// 原子性地更新value值,并且返回AtomicReference更新后的值。
// 该方法需要传入两个参数,第一个是更新的新值,第二个是BinaryOperator接口。
accumulateAndGet(V x, BinaryOperator<V> accumulatorFunction)
// 获取AtomicReference的当前对象引用值。
get()
// 设置AtomicReference最新的对象引用值,该新值的更新对其他线程立即可见。
set(V newValue)
// 设置AtomicReference的对象引用值。
lazySet(V newValue)

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿