Java CAS原理分析

 

在java.util.concurrent的许多类中, 例如Semaphore, ConcurrentLinkedQueue, 都提供了比Synchronized(内置锁)机制更高的性能和可伸缩性, 而这种性能提升的主要来源是:原子变量和非阻塞的同步机制. - «java并发编程实战» , 而原子变量的更新的主要依赖于CAS, 即CompareAndSwap, 比较并交换, 一种非阻塞的同步机制.

硬件方面

这是针对多处理器操作而设计的处理器中提供的一些特殊指令, 用于管理对共享数据的并发访问, 这些指令常用的是:

  1. 测试并设置(Test-and-Set)
  2. 获取并递增(Fetch-and-Increment)
  3. 交换(Swap)
  4. 比较并交换(Compare-and-Swap)
  5. 加载链接/条件存储(Load-Linked/Store-Conditional)

背景知识

关于CPU的锁有如下3种:

  1. 处理器自动保证基本内存操作的原子性   首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

  2. 使用总线锁保证原子性   第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写(i++就是经典的读改写操作)操作,那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致,举个例子:如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2。如下图
    enter description here

  原因是有可能多个处理器同时从各自的缓存中读取变量i,分别进行加一操作,然后分别写入系统内存当中。那么想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。   处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。

  1. 使用缓存锁保证原子性   第二个机制是通过缓存锁定保证原子性。在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,最近的处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

  频繁使用的内存会缓存在处理器的L1,L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在奔腾6和最近的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”就是如果缓存在处理器缓存行中内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效,在例1中,当CPU1修改缓存行中的i时使用缓存锁定,那么CPU2就不能同时缓存了i的缓存行。

  但是有两种情况下处理器不会使用缓存锁定。第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定。第二种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定.

  以上两个机制我们可以通过Inter处理器提供了很多LOCK前缀的指令来实现。比如位测试和修改指令BTS,BTR,BTC,交换指令XADD,CMPXCHG和其他一些操作数和逻辑指令,比如ADD(加),OR(或)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。

CAS参数

  1. 需要读写的内存地址
  2. 进行比较的值
  3. 写入的新值

    CAS流程

    内存地址区域的值和要比较的值进行比较, 如果相等, 那么就写入新的值. 否则什么都不做.

AtomicInteger使用CAS的部分源码:

    /**
     * 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);
    }
	 /**
     * Atomically increments by one the current value.
     *
     * @return the updated value
     */
    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
	

Unsafe实例的compareAndSwapInt是一个JNI方法.

    /**
	* unsafe.compareAndSwapInt
     */
    public final native boolean compareAndSwapInt(Object o, long offset,
                                                  int expected,
                                                  int x);
 /**
     * 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;
    }
	

这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。这个本地方法的最终实现在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ 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
  }
}

如c++的源码所示, 运行时会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀. 如果是在多核处理器上运行, 就为cmpxchg添加lock指令, 单核处理器则不需要.

intel的手册对lock前缀的说明如下: 确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。 禁止该指令与之前和之后的读和写指令重排序。 把写缓冲区中的所有数据刷新到内存中。 (跟volatile的内存语义很像)

CAS的缺点

CAS跟java中的锁相比, 粒度小了有很多, 而且避免了死锁的问题, 同时也保证线程的活跃度(要知道持有锁的线程会一直阻塞其他线程不能进入, 这些线程的活跃度就很低, 而且会发生”优先级反转”), 并且是基于CPU底层的指令, 所以性能很高. 但是CAS也存在着一些问题.

  1. ABA问题. 如果在CAS过程中, 要比较的值发生了变化, 但是变化之后, 与要比较的exceptedValue仍是相等的, 那么仍会发生交换, 但是实际上这已经不是我们想要的流程. 从Java1.5开始, 提供了AtomicStampedReference来解决ABA问题, 他的思路是不只更新某个引用的值, 而是添加一个版本号, 同时更新两个值, 一个引用和一个版本号.
  2. 循环时间开销长. 自旋CAS如果长时间不成功, 那么会给CPU造成很大的开销.
  3. 只能保证一个共享变量的原子操作. 解决方案:从java1.5开始JDK提供了AtomicReference来保证引用对象之间的原子性, 可以把多个变量放在一个对象里进行CAS操作.

concurrent包大致的结构

参考材料: «Java并发编程实战»
JAVA CAS原理深度分析
并发编程之 CAS 的原理
Unsafe.java源码