JUP JMM (四) Lock Memory Semantics


锁的内存语义

总所周知,锁可以让临界区互斥执行。这里介绍锁的另一个重要的功能:锁的内存语义。

1、锁的释放-获取建立的 happens-before 关系

锁是 Java 并发编程中最重要的同步机制。锁除了让临界区互斥执行以外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

例如:

public class MonitorExample {
    
    int a = 0;
    
    public synchronized void writer() {     // 1
        a++;                                // 2
    }                                       // 3
    
    public synchronized void reader() {     // 4
        int i = a;                          // 5
        // ... 
    }                                       // 6
}

假设线程 A 执行 writer() 方法,随后线程 B 执行 reader() 方法。根据 happens-before 规则,这个过程中包含的 happens-before 关系可以分为三类:

(1)根据程序次序规则:1 happens-before 2,2 happens-before 3,4 happens-before 5,5 happens-before 6

(2)根据监视器锁规则:3 happens-before 4

(3)根据 happens-before 的传递性:2 happens-before 5

图形化表现如下:

线程 A 释放了锁之后,随后线程 B 获得了同一个锁。2 happens-before 5,因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对线程 B 可见。

2、锁的释放和获取的内存语义

当线程释放锁后,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的 MonitorExample 程序为例,A 线程释放锁后,共享数据的状态示意图:

当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

对比锁释放-获取的内存语义与 volatile 写-读的内存语义可以看出:锁释放与 volatile 写有相同的内存语义;锁获取与 volatile 读有相同的内存语义。

总结

  • 线程 A 释放一个锁,实质上是线程 A 向接下来要获取这个锁的某个线程发出了(线程 A 对共享变量所作修改的)消息。
  • 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。

3、锁内存语义的实现

(1)ReentrantLock

看一下使用 ReentrantLock 的例子:

class ReentrantLockExample {
    
    int a = 0;
    
    ReentrantLock lock = new ReentrantLock();
    
    public void writer() {      // 获取锁
        lock.lock();
        try {
            a ++;
        } finally {
            lock.unlock();      // 释放锁
        }
    }
    
    public void reader() {      // 获取锁
        lock.lock();
        try {
            int i = a;
            // ...
        } finally {
            lock.unlock();      // 释放锁
        }
    }
}

ReentrantLock 中,调用 lock() 方法获取锁;调用 unlock() 方法释放锁。

ReentrantLock 的实现依赖于 Java 同步器框架 AbstractQueuedSynchronizer(AQS,抽象同步队列)。

AQS 使用一个整型的 volatile 变量(命名为 state)来维护同步状态,这个 volatile 变量是 ReentrantLock 内存语义实现的关键。

下图为 ReentrantLock 的部分类图:

(2)公平锁和非公平锁

ReentrantLock 分为公平锁和非公平锁,首先分析公平锁:

使用公平锁时,加锁方法 lock() 的调用轨迹如下:

(1)ReentrantLock#lock()

(2)FairSync#lock()

(3)AbstractQueuedSynchronizer#acquire(int arg)

(4)FairSync#tryAcquire(int acquires)

到第四步真正开始加锁,该方法源码:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();		// 获取锁的开始, 首先读 volatile 变量 state
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

加锁方法首先读 volatile 变量 state。

在使用公平锁时,解锁方法 unlock() 的调用轨迹如下:

(1)ReentrantLock#unlock()

(2)AbstractQueuedSynchronizer#release(int arg)

(3)Sync#tryRelease(int releases)

在第三步真正开始释放锁,下面是该方法的源码:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);		// 释放锁的最后,写 volatile 变量 state
    return free;
}

从源码可以看出,在释放锁的最后写 volatile 变量 state。

公平锁在释放锁的最后写 volatile 变量 state,在获取锁时首先读这个 volatile 变量。根据 volatile 的 happens-before 规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变得对获取锁的线程可见。

现在分析一下非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。使用非公平锁时,加锁方法 lock() 的调用轨迹如下:

(1)ReentrantLock#lock()

(2)NonfairSync#lock()

(3)AbstractQueuedSynchronizer#acquire(int arg)

(4)NonfairSync#tryAcquire(int acquires)

(5)Sync#nonfairTryAcquire(int acquires)

到第五步才开始真正加锁:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

这里用到了 compareAndSetState 方法(简称 CAS),该方法以原子操作的方式更新 state 变量。

JDK 文档对该方法说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义。

(3)CAS 的实现

这里我们从编译器和处理器的角度来分析,CAS 如何同时具有 volatile 读和 volatile 写的内存语义。

之前提到过,编译器不会对 volatile 读与 volatile 读后面的任意内存操作作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作作重排序。组合这两个条件,意味着为了同时实现 volatile 读和 volatile 写的内存语义,编译器不能对 CAS 与 CAS 前面和后面的任意内存操作作重排序。

下面分析在常见的 intel X86 处理器中,CAS 是如何同时具有 volatile 读和 volatile 写的内存语义的。

下面是 sum.misc.Unsafe 类的 compareAndSwapInt() 方法的源代码:

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

// 换个参数说明
public final native boolean compareAndSwapInt(Object o, long offest, int expect, int update);

可以看到,这是一个本地方法调用。这个本地方法的最终实现在 openjdk 中源代码为:

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)确保对内存的 读-改-写 操作原子执行。在 PentiumPentium 之前的处理器中,带有 lock 前缀的指令会在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从 Pentium4、Intel Xeon 及 P6 处理器开始,Intel 使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低 lock 前缀指令的执行开销。

(2)禁止改指令与之前和之后的读和写指令重排序。

(3)把写缓冲区中的所有数据刷新到内存中。

上面的 2、3 两点所具有的内存屏障效果,足以同时实现 volatile 读和 volatile 写的内存语义。

经过上的分析,我们终于明白了为什么 JDK 文档说 CAS 同时具有 volatile 读和 volatile 写的内存语义了。

(4)总结

现在对公平锁和非公平锁的内存语义做个总结:

  • 公平锁和非公平锁释放时,最后都要写一个 volatile 变量的 state;
  • 公平锁和非公平锁获取时,首先会去读 volatile 变量,然后用 CAS 更新 volatile 变量,这个操作同时具有 volatile 读和 volatile 写的内存语义。

从前面对 ReentrantLock 的分析,可知锁释放-获取的内存语义的实现至少有下面两种方式:

(1)利用 volatile 变量的写-读所具有的内存语义;

(2)利用 CAS 所附带的 volatile 读和 volatile 写的内存语义。

4、concurrent 包的实现

由于 Java 的 CAS 同时具有 volatile 读和 volatile 写的内存语义,因此 Java 线程之间的通信现在有了四种方式:

(1)A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量;

(2)A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量;

(3)A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量;

(4)A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量。

Java 的 CAS 会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行 读-改-写 操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时 volatile 变量的 读/写 和 CAS 可以实现线程之间的通信。

把这些特性整合起来,就形成了整个 concurrent 包得以实现的基石。

如果我们仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式。

(1)首先,声明共享变量为 volatile;

(2)然后,使用 CAS 的原子条件更新来实现线程之间的同步;

(3)同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些 concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖这些基础类来实现的。

从整体上看,concurrent 包的实现示意图如下所示:


Author: NaiveKyo
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source NaiveKyo !
  TOC