volatile——指令重排的规则和内存屏障插入策略

本文深入探讨了Java内存模型(JMM)中volatile关键字的重排序规则,详细分析了volatile读写与其他普通读写操作的重排序限制。通过案例展示了volatile保证可见性和禁止特定重排序的机制,同时解释了内存屏障的插入策略,以确保volatile的正确行为。文章还提供了代码示例,帮助理解这些概念。
摘要由CSDN通过智能技术生成

前言

前面博客中,重点说明了volatile的相关特性。
比如:保证共享变量在其他线程中的及时可见性,不能保证原子性。以及包括计算机指令重排的优点和缺点,以及案例说明了指令重排出现的可能性等。

但是,volatile读写和普通读写之间,指令重排的规则又是如何判断的?

本篇博客将围绕这个技术点做相关说明。

JMM针对编译器制定的volatile重排序规则

第一个操作第二个操作:普通读写第二个操作:volatile读第二个操作:volatile写
普通读写可重排可重排不可重排
volatile读不可重排不可重排不可重排
volatile写可重排不可重排不可重排

以第一规则做案例分析理解

上面的规则初步看来不容易理解,下面通过案例分析上述的规则表:

当第一个操作是普通读写

第一操作为普通读写时,此时的重排规则如下所示:

第一个操作第二个操作:普通读写第二个操作:volatile读第二个操作:volatile写
普通读写可重排可重排不可重排
  • 1、第一个操作:普通读写;第二个操作:普通读写。——可以重排
    案例如下所示:

    public class MemoryBarrier {
        // 初始化变量(普通类型)
        int a = 0;
        int x = 1,y =2;
    
        void readAndWrite(){
            int c = 2; // 第一个操作:普通写
            a = x + y; // 第二个操作:普通读/写
        }
    }
    

    c = 2表示一个普通写操作a = x + y则表示普通读+普通写操作,那么这两个执行顺序可以重排

    a = x + y,因为涉及到计算操作,首先需要获取xy对应的值,其中x和y只是一个普通的变量,所以是普通读
    计算之后,将结果赋值给a,这个赋值操作就是写操作,由于变量a无volatile修饰,则表示普通写

  • 第一个操作:普通读写;第二个操作:volatile读。——可重排
    看下列案例:

    public class MemoryBarrier {
        // 初始化变量(普通类型)
        int a = 0;
        int x = 1,y =2;
        int c = 0;
    
        // volatile 修饰变量
        volatile int m1 = 1;
        volatile int m2 = 2;
    
        void readAndWrite(){
            // 第一操作:普通读写;第二操作:普通读写。则可重排
            //c = 2; // 第一个操作:普通写
            //a = x + y; // 第二个操作:普通读/写
    
            //第一操作:普通读写;第二操作:volatile读。则可重排
            c = 2; // 普通写
            int i = m1; // volatile读+普通写(先读m1的值,再赋值给i)
        }
    }
    

    int i = m1;进行赋值操作,其实是两个操作的综合,m1变量被volatile修饰,将值进行赋值操作前,需要先读取m1的值,这里是volatile读。再将读取的数据赋值i,此时的变量i只是一个普通变量

    所以,int i = m1;的操作为volatile读和普通写

    第一个操作c = 2普通写,第二个操作int i = m1volatile读(加上普通写),所以可以指令重排

  • 第一操作:普通读写;第二操作:volatile写。不可重排
    案例代码如下所示:

    public class MemoryBarrier {
        // 初始化变量(普通类型)
        int a = 0;
        int x = 1,y =2;
        int c = 0;
    
        // volatile 修饰变量
        volatile int m1 = 1;
        volatile int m2 = 2;
    
        void readAndWrite(){
            // 第一操作:普通读写;第二操作:普通读写。则可重排
            //c = 2; // 第一个操作:普通写
            //a = x + y; // 第二个操作:普通读/写
    
            //第一操作:普通读写;第二操作:volatile读。则可重排
            //c = 2; // 普通读写
            //int i = m1; // volatile读+普通写(先读m1的值,再赋值给i)
    
            // 第一操作:普通读写;第二操作:volatile写。不可重排
            c = 2; // 普通写
            m1 = 2; // volatile 写
        }
    }
    

    由于第一个操作c = 2,这是一个普通写操作
    第二个操作m1 = 2,由于m1volatile修饰,表示该变量是volatile 变量,重新赋值则表示volatile写

    这两个操作指令之间,不可重排

什么是读?什么是写?

上述的案例中,频繁的出现读操作写操作字眼,相信很多人看了会很困惑。下面解释什么是读什么是写。代码如下:

public class Te {
    public static void main(String[] args) {
        int b = 1;
        int c = b;
    }
}

赋值符号 =为界,
int b = 1 在计算机中,= 符号右中,并不需要从内存中读具体的信息,所以int b = 1只是一个写操作

int c = b 在计算机中,= 符号右中,由于需要进行赋值操作,将变量b赋值给变量c,但计算机并不知道 变量b的值,所以需要去进行读操作,将变量b的具体值从内存中获取。当获取到了变量b的值后,再将该数据值赋值给变量c,此时的赋值操作就是一个写操作

int c = b;的总体操作为:读操作+写操作。

关于第一步第二步

第一步和第二步,并不是指代码中的第一行代码、第二行代码。

而是两行代码执行顺序比较时,相对而言

在这里插入图片描述

在这里插入图片描述

JMM内存屏障插入策略

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

之前博客中有说到volatile采取内存屏障来保证禁止计算机指令重排优化
volatile禁止重排优化

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略

基于保守策略,JMM内存屏障插入规则有如下几种规则:

  • 在每个volatile写操作 前,插入StoreStore 内存屏障
  • 在每个volatile写操作 后,插入StoreLoad 内存屏障
  • 在每个volatile读操作 后,插入LoadLoad 内存屏障
  • 在每个volatile读操作 后,插入LoadStore 内存屏障

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

下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

这个是 volatile 写

  • 在每个volatile写操作 前,插入StoreStore 内存屏障
  • 在每个volatile写操作 后,插入StoreLoad 内存屏障

在这里插入图片描述
上图中StoreStore屏障可以保证在volatile写之前其前面所有普通写操作已经对任意处理器可见了。

因为StoreStore屏障将保障上面所有的普通写volatile写之前 刷新到主内存

【疑问:】为什么 volatile写 后面加的是 storeload 屏障?而不是其他的?
storeload屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。

因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)

为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略:

在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。

整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障

volatile写-读内存语义的常见使用模式是:
一个写线程写volatile变量,
多个读线程读同一个volatile变量。

当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。

首先确保正确性,然后再去追求执行效率。


下面是保守策略下,volatile读 插入内存屏障后生成的指令序列示意图:

这个是 volatile 读

  • 在每个volatile读操作 后,插入LoadLoad 内存屏障
  • 在每个volatile读操作 后,插入LoadStore 内存屏障

在这里插入图片描述
上图中LoadLoad屏障用来禁止处理器把上面的volatile读下面的普通读 重排序

上图中LoadStore屏障用来禁止处理器把上面的volatile读下面的普通写 重排序

代码案例分析

上面的概念过于抽象,下面以java代码案例的形式,将其进行插入内存屏障进行拆解分析:
案例如下所示:

class Test {
       int a; // 普通变量
       
       volatile int v1 = 1; // volatile 修饰的变量
       volatile int v2 = 2; // volatile 修饰的变量
       void readAndWrite() {
           int i = v1;      // 第一个volatile读
           int j = v2;       // 第二个volatile读
           
           a = i + j;         // 普通写
           
           v1 = i + 1;       // 第一个volatile写
           v2 = j * 2;       // 第二个 volatile写
       }
}

上述代码中的readAndWrite(),编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。其中插入内存屏障大致如下所示:
在这里插入图片描述
【注意:】最后的StoreLoad屏障不能省略!

因为第二个volatile写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。

参考资料

《并发编程的艺术》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值