java

java并发-volatile的内存语义

3.4volatile的内存语义

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

3.4.1 volatile的特性

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这 些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下。

class VolatileFeaturesExample {
 volatile long vl = 0L; //使用volatile声明64位的long型变量
    public void set(long l) {
        vl = l;         //单个volatile变量的写
    }
    public void getAndIncrement () {
        vl++;           // 复合(多个)volatile变量的读/写
    }
    public long get() {
        return vl;     // 单个volatile变量的读
    }
}

假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价。

class VolatileFeaturesExample {
    long vl = 0L; // 64位的long型普通变量
    public synchronized void set(long l) { // 对单个的普通变量的写用同一个锁同步
        vl = l;
    }
    public void getAndIncrement () { //普通方法调用
        long temp = get();           // 调用已同步的读方法
        temp += 1L;                  //普通写操作
        set(temp);                   //调用已同步的写方法
    }
    public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
        return vl;
    }
}

锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double 型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类 似于volatile++这种复合操作,这些操作整体上不具有原子性。

简而言之,volatile变量自身具有下列特性。

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

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

    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 happens-before 2;3 happens-before 4。
  • 2)根据volatile规则,2 happens-before 3。
  • 3)根据happens-before的传递性规则,1 happens-before 4。

3.4.3 volatile写-读的内存语义

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

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

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

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

3.4.4 volatile内存语义的实现

能否重排序:

第一个操作第二个操作 普通读/写第二个操作 volatile读第二个操作 volatile写
普通读/写
volatile读
volatile写

举例来说,第2行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或 写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

从表中可以看出:

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

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

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

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来 禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总 数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能 得到正确的volatile内存语义。

关于作者

程序员,软件工程师,java, golang, rust, c, python,vue, Springboot, mybatis, mysql,elasticsearch, docker, maven, gcc, linux, ubuntu, centos, axum,llm, paddlepaddle, onlyoffice,minio,银河麒麟,中科方德,rpm