volatile 和锁的内存语义

一、volatile 的内存语义

1. volatile 的特性

volatile变量自身具有以下特性:

可见性 :对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

原子性 :对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

2. volatile 写-读建立的happens-before关系

从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

3. volatile 写-读的内存语义

volatile写的内存语义如下:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

volatile读的内存语义如下:

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

下面对volatile写和volatile读的内存语义做个总结:

线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。

线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。

线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

4. volatile 内存语义的实现

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。

  • 在每个volatile写操作的后面插入一个StoreLoad屏障。

  • 在每个volatile读操作的后面插入一个LoadLoad屏障。

  • 在每个volatile读操作的后面插入一个LoadStore屏障。

前文提到过,X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。


二、锁的内存语义

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

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

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关系可以分为3类:

  • 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。

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

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

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

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

下面对锁释放和锁获取的内存语义做个总结:

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

3、锁内存语义的实现

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 的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,这个volatile变量是ReentrantLock内存语义实现的关键。

ReentrantLock分为公平锁和非公平锁,我们首先分析公平锁。

加锁调用轨迹如下:

ReentrantLock#lock() -> FairSync#lock() -> 
AbstractQueuedSynchronizer#acquire(int args) -> FairSync#tryAcquire()
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    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()调用轨迹如下:

ReentrantLock#unlock() -> AbstractQueuedSynchronizer#release(int args) -> 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);
    return free;
}

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

对于非公平锁,释放和公平锁完全一样,加锁则使用CAS来更新state变量:

protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

sun.misc.Unsafe类的compareAndSwapInt()方法是一个本地方法调用,具体的C++实现就是:

程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

4、concurrent 包的实现

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

  • 1)A线程写volatile变量,随后B线程读这个volatile变量。
  • 2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  • 3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
  • 4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

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

首先,声明共享变量为volatile。

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

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

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

从整体来看,concurrent包的实现示意图如3-28所示:

image

本文来自对《Java并发编程的艺术》一书总结。

posted @ 2018-06-30 17:37  Lucare  阅读(735)  评论(0编辑  收藏  举报