前序
时隔多年,杰伦终于出了新专辑,《最伟大的作品》让我们穿越到1920年,见到了马格利特的绿苹果、大利的超现实、常玉画的大腿、莫奈的睡莲、徐志摩的诗…
他说“最伟大的作品”并不是自己的歌,而是这个世界上最伟大的艺术作品们。
为什么要写CAS自旋锁呢?最近看了一下Java实现随机数的几种方式,研究研究就研究到量子力学去了,所以还是回归代码上来,看了看底层实现都是用的CAS,正好又赶上周董发歌,就凑个巧吧~
大家给我这几个免费的专栏点点订阅【后期会变成付费专栏】,听我说谢谢你,因为有你,温暖了四季~
《Java系核心技术》《中间件核心技术》
《微服务核心技术》《云原生核心技术》
文章目录
- CAS核心原理
- i++和++i是原子操作么
- ++i 如何实现原子性
- 如何用Java调用C++
- JNI命名规范
- compareAndSwarpInt源码分析
- CAS缺点
CAS核心原理
CAS即Compare and Swap,翻译成比较并交换。
CAS是一种乐观锁,所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
【至于其他的各种锁,我们下一篇再谈论,本篇主要讲解CAS】
CAS 操作包含三个操作数 —— 内存位置V、预期原值A和新值B。
如果内存位置的值与预期原值相匹配,那么处理器会自动将该内存位置值更新为新值 ,否则,处理器不做任何操作。
i++和++i是原子操作么
先说答案:不是。
反编译成字节码文件就很容易看出来此非原子性操作,先是getfield,然后再iadd。
i++大体分为三步:
- 栈中取出i
- i自增1
- 将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);
这个方法到底做了什么事呢?应该做了两件事情。
- 当前内存位置V是否等于预期原值A
- 如果等于就将内存位置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.h
和 jni_md.h
头复制到当前目录【mac的话,首先需要shift
+command
+.
打开隐藏文件】
执行 gcc -shared -fPIC -o jni.so jniUnit.c
进行编译生成动态库
如果此时报如下错误
就将com_ossa_producer_jni_JniUnit.h
文件中#include <jni.h>
修改为#include "jni.h"
编译成功后当前目录会出现jni.so
文件,放置到指定的目录下即可。
最后测试我们的JniUnit类,成功!
到此,我已经亲手带大家完成了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)