纯函数和CAS

本节课是小密圈《进击的Java新人》的第十四周第四课。这一节课,我们继续讲解解决并发问题的手段。

在上一节课的例子中,我们使用synchronized关键字解决了并发的问题。这一节课我们再介绍两种方法。

纯函数

纯函数这个词是一个来自于函数式编程的名词。它的定义是这样的:

  1. 给出同样的参数值,该函数总是求出同样的结果。该函数结果值不依赖任何隐藏信息或程序执行处理可能改变的状态或在程序的两个不同的执行,也不能依赖来自I/O设备的任何外部的输入。
  2. 结果的求值不会促使任何可语义上可观察的副作用或输出,例如易变对象的变化或输出到I/O设备。

函数的返回值必须不依赖参数值以外的东西,并且不会对任何的参数做出修改。定义很拗口,但其实很简单。例如sin(x)求 x 的正弦值。对于一个确定的参数,就会返回一个确定的输出。这种函数就是纯函数。再比如这个函数:

int inc(int x) {
    return x + 1;
}

这个函数对于确定的输入参数,也会返回确定的输出,并且不会对输入参数做任何的修改,这个函数也是纯函数。

再举几个反例,有IO操作的都不是纯函数:

void sayHello() {
    System.out.println("hello world!");
}

因为要访问一个全局变量 System.out,所以这就不是一个纯函数。

每次运行结果是不固定的,也不是纯函数:

long getTime() {
    return System.nanoTime();
}

还有,会对参数进行修改的,也不是纯函数:

void buildObject(UserObject o) {
    o.refA = new A();
}

为什么要强调纯函数呢?这是因为所有的纯函数都是线程安全的,所谓线程安全,就是无论有多少个线程以怎么样的顺序同时执行一个函数,不用做任何的额外处理,运行的结果都是确定而正确的。纯函数显然就满足线程安全的要求。

当然,线程安全的要求比纯函数的约束要弱,也就是说,一个线程安全的函数未必是纯的,但是纯函数一定是线程安全的。对于未来的并发编程,函数式编程可能才是唯一正确的道路。这是一个比较大的话题,我们的课程不会涉及(也许会涉及,我不能确定,将来看吧,如果有机会,我还是希望能多介绍一些python和scala的函数式编程的知识的)。写在这里,只是让大家学会去判断什么样的函数是线程安全的,什么样不是线程安全的,而非线程安全的函数要加synchronized或者通过其他的锁机制来保证并发的正确性。

CAS

cas 是 Compare And Swap 的缩写。它的意义是这样的,CAS操作包含三个操作数,内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相等,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可”。

在 x86 平台上,有一条指令:cmpxchgl。这条指令就是一条原子指令,在执行这条指令的中间,线程是不会被切换的。也就是说,我们完成上面所说的复杂语义,却不用自己加锁来进行特别的处理。

Java中的CAS就是使用这条指令来实现CAS操作的。如果使用伪代码来表示,一个CAS指令与下面的代码片段的功能是一致的:

public synchronized void compareAndSwap(UserObject o, Value a, Value b) {
    Value old = o.value;
    if (o.value == a)
        o.value = b;
    return old;
}

毫无疑问,上面的代码在执行效率上比起一条CPU指令差了非常非常多。

CAS的实现

我们来考察一下CAS的具体实现吧,先看compareAndSwap方法定义的位置:

share/classes/sun/misc/Unsafe.java

    /**  
     * 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);

Unsafe里提供的 compareAndSwapInt 是一个 native 方法,这个方法要提供的是目标对象 o,这个参数是我们要修改的对象。offset 代表要修改的对象在这个对象中的偏移。expected 是就是上文中提到的期望值 A,x 代表了上文中指定的目标值B。

好,我们继续往下找,看看这个native的具体实现。我们要找的这个 native 的实现在 hotspot 内部,是用C++写的,我们深入到虚拟机内部来看一下:

hotspot/src/share/vm/prims/unsafe.cpp

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);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

这段代码里的 p 就是 Java 对象 o 在 hotspot 中的内部表示,addr 是要修改变量的目标地址。 我们继续去看Atomic::cmpxchg的实现:

hotspot/src/os_cpu/linux_x86/vm/atomic_linux_x86.inline.hpp

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}

这是一段GCC的嵌入式汇编,我们先不去管这些。我们所要知道是:compare_value 这个值被送到了 rax 寄存器,然后使用 x86 cpu 的指令 cmpxchgl 来查看 dest 位置是否是compare_value,如果是,就把 dest 位置的值更改为 exchange_value,如果不是就什么都不做。当然,也不是什么都不做,其实,还是会把dest位置的值传回给exchange_value,并返回。

再回过头来,看一下,如何使用CAS操作实现加法的原子操作呢?

share/classes/sun/misc/Unsafe.java

    /**
     * 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 long getAndAddLong(Object o, long offset, long delta) {
        long v;
        do { 
            v = getLongVolatile(o, offset);
        } while (!compareAndSwapLong(o, offset, v, v + delta));
        return v;
    } 

上面的方法就是用于CAS加法的。可以看到,这个加法里,并不是简单地把delta 加到 v 上。而是使用一个循环,不断去取 o 的 offset 那个位置上的值。假如A线程取出来的是 1, B线程取出来的也是1,如果要加 delta,假如B线程执行完加法以后,把新的值 1 + delta 设置回 offset 处,那么A线程计算完了,也是1 + delta,再想把它设回去的时候,就发现 expect 已经不是1了。而是另外一值,这就说明,offset 处的值已经被另一个线程更改过了。那么就会进入下一次循环,重新取offset处的值,这次取到的就是1+delta了,其后再执行加法,就变为1+2*delta,然后这时,再通过CAS设置回去,这样得到的值就是我们期望的值了。

好了。今天就讲这么多吧。JDK中对这个函数专门做了一个封装,明天我们再去从锁的角度重新学习CAS操作。更多的相关知识,下节课再讲。今天没有作业。

上一节课:线程的共享互斥

下一节课:Atomic变量

目录:课程目录

编辑于 2017-06-01 00:14