【Java并发编程的艺术3】Java内存模型(下)

文章目录

volatile的内存语义

当声明共享变量为volatile后,对这个变量的读/写将会很特别。

volatile的特性

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性。对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

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

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

看下面使用volatile变量的示例代码

java 复制代码
public class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                  // 1
        flag = true;            // 2
    }

    public void reader() {
        if (flag) {             // 3
            int i = a;          // 4
            // ......
        }
    }
    // ......
}

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

  1. 根据程序次序规则,1 happen-before 2;3 happens-before 4
  2. 根据volatile规则,2 happens-before 3
  3. 根据happens-before的传递性规则,1 happens-before 4

上述happens-before关系的图形化表现形式如下;

在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;绿色箭头表示组合这些规则后提供的happens-before保证。

这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

volatile 写 - 读的内存语义

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

以上面的示例程序VolatileExample为例,假设线程A首先执行writer()方法,随后线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。下图是线程A执行volatile写后,共享变量的状态示意图。

如上图所示,线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。

volatile读的内存语义如下:

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

线程B读同一个volatile变量后,共享变量的状态示意图如下所示:

如上图所示,在读flag变量后,本地内存B包含的值已经被置为无效了。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。

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

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

锁的内存语义

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

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

下面是锁释放 - 获取的示例代码

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

上述happens-before关系的图形化如下图所示

上图中每个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;绿色箭头表示组合这些规则后提供的happens-before保证。

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

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

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

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

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

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

锁内存语义的实现

借助ReentrantLock来分析锁内存语义的具体实现机制。

java 复制代码
public 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()调用轨迹如下:

  1. ReentrantLock:lock()
  2. FairSync:lock()
  3. AbstractQueuedSynchronizer:acquire(int arg)
  4. ReentrantLock:tryAcquire(int acquires)

在第4步真正开始加锁,下面是该方法

java 复制代码
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)
java 复制代码
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:compareAndSetState(int expect, int update)
java 复制代码
protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

该方法以原子操作的方式更新state变量,接下来compareAndSetState简称为CAS。此操作具有volatile读和写的内存语义。

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

final域的内存语义

final域的重排序规则

对于final域,编译器和处理器要遵守两个重排序规则。

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序(先写final域,再把构造对象的引用赋给引用变量)。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序(先读包含final域对象的引用,再读final域)。

下面通过代码示例来说明这两个规则

java 复制代码
public class FinalExample {
    int i;                              // 普通变量
    final int j;                        // final变量
    static FinalExample obj;

    public FinalExample () {            // 构造函数
        i = 1;                          // 写普通域
        j = 2;                          // 写final域
    }

    public static void writer() {       // 写线程A执行
        obj = new FinalExample();
    }

    public static void reader() {       // 读线程B执行
        FinalExample object = obj;      // 读对象引用
        int a = object.i;               // 读普通域
        int b = object.j;               // 读final域
    }
}

这里假设一个线程A执行writer()方法,随后另一个线程B执行reader方法。下面通过两个线程的交互来说明这两个规则。

写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含2个方面;

  1. JMM禁止编译器把final域的写重排序到构造函数之外。
  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

在下图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误地读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则限定在了构造函数之内,读线程B正确地读取了final变量初始化之后的值。

读final域的重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的前面插入一个LoadLoad屏障。

如上图所示,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作限定在读对象引用之后,此时该final域已经被A线程初始化过了,是一个正确的读取操作。

final域为引用类型

如果final域是引用类型,将会有什么效果

java 复制代码
public class FinalReferenceExample {
    final int[] intArray;
    static FinalReferenceExample obj;
    public FinalReferenceExample () {           // 构造函数
        intArray = new int[1];                  // 1
        intArray[0] = 1;                        // 2
    }

    public static void writerOne() {            // 写线程A执行
        obj = new FinalReferenceExample();      // 3
    }

    public static void writerTwo () {           // 写线程B执行
        obj.intArray[0] = 2;                    // 4
    }

    public static void reader() {               // 读线程C执行
        if (obj != null) {                      // 5
            int temp1 = obj.intArray[0];        // 6
        }
    }
}

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

为什么final引用不能从构造函数内"溢出"

在构造函数内部,不能让被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中"逸出"。

java 复制代码
public class FinalReferenceEscapeExample {
    
    final int i;
    static FinalReferenceEscapeExample obj;
    
    public FinalReferenceEscapeExample () {
        i = 1;                                  // 1写final域
        obj = this;                             // 2this引用在此"逸出"
    }
    
    public static void writer() {
        new FinalReferenceEscapeExample();
    }
    
    public static void reader() {
        if (obj != null) {                      // 3
            int temp = obj.i;                   // 4
        }
    }
}

在构造函数返回之前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。

相关推荐
cd小白6 分钟前
Linux第二课:LinuxC高级 学习记录day01
linux·c语言·开发语言·学习
太阳伞下的阿呆1 小时前
CentOS 8 如何安装java与mysql
java·mysql·centos
小希与阿树1 小时前
如何解决Redis中的热点key问题
数据库·redis·缓存
草莓屁屁不酸2 小时前
积分与签到设计
java·redis·微服务·rabbitmq
lsx2024064 小时前
Matplotlib 直方图:数据可视化基础
开发语言
小馋喵知识杂货铺4 小时前
pytest 截图功能
开发语言·python
数维学长9864 小时前
C++ STL 中的 vector 总结
开发语言·c++
kevin_tech6 小时前
Go 项目开发实战-用户Token的刷新、踢人下线和防盗检测
运维·服务器·开发语言·后端·golang
DevOpsDojo6 小时前
PHP语言的函数实现
开发语言·后端·golang
苹果酱05676 小时前
Golang的文件加密技术研究与应用
java·vue.js·spring boot·mysql·课程设计