Java并发关键字Volatile 详解


  • 问题引出:

    1.Volatile是什么?

    2.Volatile有哪些特性?

    3.Volatile每个特性的底层实现原理是什么?

  • 相关内容补充:

  1. 缓存一致性协议:MESI

    ​ 由于计算机储存设备(硬盘等)的读写速度和CPU的计算速度有着几个数量级别的差距,为了不让CPU停下来等待读写,在CPU和存储设备之间加了高速缓存,每个CPU都有自己的高速缓存,而且他们共享同一个主内存区域,当他们都要同步到主内存时,如果每个CPU缓存里的数据都不一样,这时应该以哪个数据为准呢?为了解决这一同步问题,需要各个处理器都遵循一定的协议,比如MSI,MOSI,MESI等,目前用的比较多的就是MESI协议。

​ 注 :缓存一致性协议是在总线上实现的。

MESI是代表了缓存数据的四种状态,分别是Modified、Exclusive、Shared、Invalid:

​ ①M(Modified):被修改的,处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没 有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。

​ ②E(Exclusive):独占的,处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改, 即与内存中一致。

​ ③S(Shared):共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。

​ ④I(Invalid):要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态 的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。在 缓存行中有这四种状态的基础上,

<font color=\'red\'>总结:</font>每个处理器通过嗅探在总线上传递的数据来检查自己缓存的数据是否过期,当处理器发现自己缓存行数据对应的内存地址被修改,就会将当前缓存行里的数据设置为无效。当再次需要使用该数据的时候就会去主内存中重新读取数据。
  1. Java内存模型:JMM 

    ​ Java内存模型规定了Java变量(实例字段,静态字段,构成数组对象的元素等,但不包括局部变量和方法参数)存储到内存和从内存中取出的的底层实现细节,这些变量都存储在主(Main Memory)中,(主内存只是虚拟机内存的一部分) 每个线程都有自己的工作内存(Working Memory),工作内存(实际上工作内存并不存在,他只是JMM抽象出来的一个概念)中保存着从主内存读取来的变量副本拷贝,线程对副本的操作(读取,修改,赋值)都要在工作内存中进行,而不能直接在主内存中进行,不同线程之间也不能访问彼此的工作内存。且线程之间变量值的传递需要经过主内存作为第三方中介。

  1. 内存间原子性交互操作:

    ①lock(锁定):作用于主内存上的变量,当一个变量被标识为Lock的时候,表示该变量是线程 独占状态,此时其他线程不可以对该变量进行操作。早前的缓存一致性协议就是这样,但是这 样会导致某个变量被一个线程占用,其他线程不可以对其进行访问,并发就变成了串行,效率 降低,在后来的缓存一致性协议中就抛弃了这种做法。

    ②unlock(解锁):同样是作用于主内存中的变量,使变量从锁定状态释放出来,其他线程才可 以对其操作。

    ③read(读取):读取主内存中的变量,传输到线程的工作内存,等待后续的load操作。

    ④load(加载):加载工作内存中的变量,把其放入工作内存的副本变量中。

    ⑤use(使用):把工作内存中的变量副本值传递给执行引擎,每当虚拟机遇到使用变量值字节码 的时候就会进行此操作。

    ⑥assign(赋值):把一个从执行引擎接收到的数据赋给工作内存的变量,即执行赋值操作。

    ⑦store(存储):把工作内存中经过赋值更新后的值传递到主内存中,为后续write做准备。

    ⑧write(写入):把store操作传递来的值写入主内存,替换之前的值,完成同步更新。

​ 4.JMM并发的特性要求:

​ ①可见性(Visibility):可见性要求是指当一个线程修改了共享变量的值以后,其他线程能够马 上得知这个修改。

​ ②原子性(Atomicity):原子性是指对变量的操作(read,load,assign等上述交互操作)不 可分割不可被打断,每个操作都要完整的执行完成才可以有其他操作进来。且默认对基本数据类 型的访问和读写都是原子性的(64位的long型和double型会有可能被拆分成两个32位进行读写 操作,但是这种概率极低,可以忽略不计。)

​ ③有序性:为了提升效率,编译器会对代码进行乱序优化,而CPU会乱序执行,但是这样的操作 会导致很严重的问题。为了解决这一问题,使用了内存屏障来防止乱序的发生。 这样按照顺序执 行就是有序性。


进入主题


  • Volatile是什么?

    Volatile是轻量级的synchronized锁,所谓轻量级,是因为synchronized使用时会引起线程的上下文切换,使得执行成本更高,效率更低,而Volatile不会有这些问题,效率更高。

  • Volatile特性:

    1.可见性 :Visibility

    ​ ①定义:当一个线程修改了共享变量的值以后,其他线程能够马上得知这个修改。

    ​ ②先看一个例子:

    package Test;
    
    public class VolatileTest {
        public static boolean flag = false;
        public static void main(String[] args) throws InterruptedException {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println("主线程等待线程A修改数据~~~");
                    while (!flag){}
                    System.out.println("主线程发现数据被线程A修改~~~~");
                }
            }).start();
            Thread.sleep(300);
    
            new Thread(new Runnable() {
                @Override
                public void run() {
                    changeData();
                }
            }).start();
    
        }
    
        public static void changeData(){
             flag = true;
            System.out.println("线程A修改完成数据~~~~");
        }
    
    }
    // 执行结果:
    等待线程线程A修改数据~~~
    线程A修改完成数据~~~~
    
    

    ​ 可以看出,当线程A修改完成数据后,另外一个线程应该要输出主线程发现数据被线程A修改~~~~,但是实际的运行情况是主线程一直处于等待状态。而如果把 public static boolean flag = false;修改为 public static volatile boolean flag = false;,也就是把变量用volatile修饰,此时的执行结果:

    等待线程线程A修改数据~~~
    线程A修改完成数据~~~~
    线程A修改数据完成~~~~
    

    很明显,线程A修改变量后,主线程也能感知到,使得数据具有可见性,这就是volatile的作用。

    ​ ③volatile可见性底层实现原理:

    ​ 对未加volatile修饰的变量修改时的底层汇编码:

​ 对volatile修饰的变量修改时的底层汇编码:

由底层汇编可知,对volatile修饰的变量修改时,汇编指令前面会多一个lock前缀,这个lock 前缀将会导致下面两件事发生:

​ (1)立即将修改过的数据回写到主内存中,刷新原来的数据。

在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
ps:摘自https://blog.csdn.net/yu280265067/article/details/50986947

​ (2)如果其他处理器缓存了这个被修改过的数据,那回写操作会使他们失效。

IA-32处理器和Intel64位处理器使用MESI(缓存一致性)维护内部缓存和其他处理器缓存的一致性,在多核处理器(多线程)中,处理器和线程使用嗅探技术检测各自缓存中的数据和总线上传递的数据是否一致,如果检测到有其他处理器或线程回写数据,且该数据是共享数据,那么就会强制使其他缓存了该数据的缓存中的数据失效。

2.有序性(禁止指令重排序):Odering

​ (1)指令重排序:为了优化和性能,编译器和处理器经常会对指令做重排序,且分为三种。

​ ①编译器重排序:在不改变单线程程序语义的前提下,重新安排代码执行顺序。

​ ②指令级并行重排序:处理器采用指令级并行技术将多条指令重叠执行,如果数据不存在 依赖,可以改变机器指令执行。

​ ③内存系统重排序:处理器使用缓存和读/写缓冲区,使得加载和存储看上去是乱序执行。

重排序顺序示意图

​ 先看一个例子:

package Test;

public class NoReoder {
    private static int a = 0;
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                write();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                read();
            }
        }).start();
    }

    public static void write(){
        a = 30;
        flag = true;
        System.out.println("方法一结束~~");
    }

    public static void read(){
        if (flag ) {
            a = a + 10;
            System.out.println(a);
            System.out.println("方法二结束~~");
        }
    }
}

​ 当线程A启动调用了write方法,线程B启动调用read方法时能不能知道a被write方法修改了呢?答案是:不一定!!!

由于write方法中:

a = 30;
flag = true;

这两个操作的数据没有依赖性,所以可能会被重排序为:

flag = true;
a = 30;

这样就会使得read方法先读到flag = true ,而 a 还没修改完,从而使计算结果出错。

为了解决这种问题,在JMM中设计了内存屏障技术:

简单来说,Volatile的有序性就是靠内存屏障来实现,就是把一些操作限制在某些操作之前或者之后,比如将Store操作限制在Load之前,这样就能让其他线程得到的数据是最新的或者需要先写入数据再让其他线程加载数据。


说在最后:

            相关参考,详见《Java并发编程的艺术》一书

​ 本文仅是对个人学习中一些理解的记录,鉴于水平有限或多或少存在错漏或不严谨之处,欢迎各位大神批评指正。码字不易,欢迎转载转发但请标注出处。

​ 希望病毒早点结束,再难的日子里也要坚持学习,新年快乐,最后愿工作在与病毒抗争最前线的医护人员平安打完这场仗,加油!!!!

版权声明:本文为coding-996原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/coding-996/p/12233175.html