前言
研究java并发编程有一段时间了, 在并发编程中cas出现的次数极为频繁。cas的英文全名叫做compare and swap,意思很简单就是比较并交换。在jdk的conurrent包中,Doug Lea大神大量使用此技术,实现了多线程的安全性。
cas的核心思想就是获取当前的内存偏移值、期望值和更新值,如果根据内存偏移值得到的变量等于期望值,则进行更新。
问题
总有面试官喜欢问你i++和++i,以及经典的字符串问题,其实这些问题只要你试用javap -c这个命令反编译一下,就一目了然。当然今天的主题是cas,我首先来研究下a++:
//@RunWith(SpringRunner.class)
//@SpringBootTest
public class SblearnApplicationTests {
public static volatile int a;
public static void main(String[] args) {
a++;
}
}
通过javac SblearnApplicationTests.java,javap -c SblearnApplicationTests.class可以得到:
Compiled from "SblearnApplicationTests.java"
public class com.example.sblearn.SblearnApplicationTests {
public static volatile int a;
public com.example.sblearn.SblearnApplicationTests();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field a:I
3: iconst_1 //当int取值-1~5采用iconst指令,取值-128~127采用bipush指令,取值-32768~32767采用sipush指令,取值-2147483648~2147483647采用 ldc 指令
4: iadd
5: putstatic #2 // Field a:I
8: return
}
通过反编译得出如上的结果,都是一些jvm的指令,百度一下就能知道意思。我们将变量a用violate修饰,保证线程间的可见性。通过jvm指令可知a++不是一个原子动作,如果多个线程同事对a进行操作,无法保证线程安全,那怎么解决呢?
解决方案
java给我们提供了一个关键字synchronized,可以对成员方法、静态方法、代码块进行加锁,从而保证操作的原子性。但效率不高,还有其他办法吗?当然有了,就是我们今天的主角cas。接下来我们再来看看concurrent包下的AtomicInteger:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
//效果等同于a++,但保证了原子性
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
}
public final class Unsafe {
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public native int getIntVolatile(Object var1, long var2);
//object var1:当前AtomicInteger对象,long var2Integer对象的内存偏移值,int var4 增加的值
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
//从方法名字就可以看出,获取线程可见的值
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
}
cas机制的核心类就是Unsafe,valueOffset 是其内存偏移值。由于java语言无法直接操作底层,需要本地方法(native method)来访问,unsafe这个类中存在大量本地方法,就是在调用c去操作特定内存的数据。我们先假设unsafe帮我们保证了原子性,先来分析下AtomicInteger.getAndIncrement(),在jdk1.8中,其实现就是Unsafe.getAndAddInt()
- 现在我们假设有A、B线程同时来操作AtomicInteger,其初始值为1,根据java内存模型,当前主内存AtomicInteger值为1,线程A、线程B各自的工作内存也为1.
- 线程A获得通过getIntVolatile获取当前值,被挂起。线程B也通过此方法获取当前值,进行操作,比较内存值相等进行修改。
- 这时线程A恢复,执行compareAndSwapInt发现与内存期望值不相等,重新获取var5变量(因为被violate修饰,所以工作内存和主内存变量一致),再次比较与内存期望值相等,进行更新。
我们通过cas保证了对value的并发线程安全,其安全的保证是CAS通过调用JNI的代码实现的。JNI:Java Native Interface为JAVA本地调用,允许java调用其他语言。而compareAndSwapInt就是借助C来调用CPU底层指令实现的。下面从分析比较常用的CPU(intel x86)来解释CAS的实现原理。compareAndSwapInt方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。这个本地方法的最终实现在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011openjdkhotspotsrcoscpuwindowsx86vm atomicwindowsx86.inline.hpp(对应于windows操作系统,X86处理器)。下面是对应于intel x86处理器的源代码的片段:
// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。
intel的手册对lock前缀的说明如下:
1.确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
2.禁止该指令与之前和之后的读和写指令重排序。
3.把写缓冲区中的所有数据刷新到内存中。
cas的缺点
cas的缺点就是会出现aba问题,假如一个字母为a,它经历a->b->a的过程,实际已经改变两次,但值相同。部分业务场景是不允许出现这种情况的(比如银行转账..).解决办法就是添加版本号,他就变成了1a->2b>3a。jdk1.5之后也提供了AtomicStampedReference来解决aba问题。
总结
自旋cas如果长时间不成功,将会对cpu带来非常大的开销。cas只能保证一个共享变量的原子操作。所以非常简单的操作又不想引入锁,cas是一个非常好的选择。