分享

volatile关键字的作用

 印度阿三17 2021-02-10

volatile关键字的作用

1.java内存模型
在这里插入图片描述
如上图所示,所有线程的共享变量都存储在主内存中,每个线程都有一个独立的工作内存,每个线程不直接操作在主内存中的变量,而是将主内存上变量的副本放进自己的工作内存中,只操作工作内存中的数据。当修改完毕后,再把修改后的结果放回到主内存中。每个线程都只操作自己工作内存中的变量,无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

2.内存中的交互操作有很多,和volatile有关的操作为
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,他把read操作从主内存得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,他把一个从执行引擎接受到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节指令码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
write(写入):作用于主内存的变量,他把store操作从工作内存中一个变量的值传送到主内存的变量中。

对被volatile修饰的变量进行操作时,需要满足以下规则:
1)线程对变量执行前的一个动作是load时才能执行use,反之只有后一个动作是use时才能执行load。线程对变量的read,load,use动作关联,必须连续一起出现。这保证了线程每次使用变量时都需要从主内存拿到最新的值,保证了其他线程修改的变量本线程能看到。
2)线程对变量执行的前一个动作是assign时才能执行store,反之只有后一个动作是store时才能执行assign。线程对变量的assign,store,write动作关联,必须连续一起出现。这保证了线程每次修改变量后都会立即同步回主内存,保证了本线程修改的变量其他线程能看到。
3)假设有线程T,变量X,变量Y。假设动作A是T对X的use和assign动作,B是与A关联的read或write动作,动作C是T对Y的use和assign动作,动作D是与C关联的read或write动作。加入A先于C,那么B先于D
。这保证了volatile修饰的变量不会被指令重排序优化,代码的执行顺序与程序的顺序相同。

3.MESL缓存一致性协议
volatile可见性是通过汇编加上Lock前缀指令,触发底层的MESL缓存一致性协议来实现的。当然这个协议有很多种,不过最常用的就是MESL。MESL表示四种状态:
M 修改(Modifled):此时缓存行中的数据与主内存中的数据不一致,数据只存在于本工作内存中。其他线程从主内存中读取共享变量值的操作会被延迟执行,直到该缓存行将数据写回到主内存中。
E 独享(Exclusive):此时缓存行中的数据与主内存中的数据一致,数据只存在于本工作内存中。此时会监听其他线程读主内存中共享变量的操作。如果发生,该缓存行需要变成共享状态。
S 共享(Shared):此时缓存行中的数据与主内存中的数据一致,数据存在于很多工作内存中。此时会监听其他线程使缓存行无效的请求,如果发生,该缓存行需要变成无效状态。
I 无效(invalid):此时该缓存行无效。

假如说当前有一个cpu去主内存拿到一个变量x的值初始为1,放到自己的工作内存中。此时他的状态就是独享状态E,然后此时另一个cpu也拿到了这个x的值,放到自己的工作内存中。此时之前的那个cpu会不断地监听内存总线,发现这个x有多个cpu在获取,那么这个时候这两个cpu所获得的x的值的状态就都是共享状态S。然后第一个cpu将自己工作内存中的x的值带入到自己的ALU计算单元去进行计算,返回时x的值变为2,接着会告诉内存总线,此时自己的x的状态置为修修改状态M。而另一个cpu此时会不断地监听内存总线,发现这个x已经有别的cpu将其置为了修改状态,所以自己内部的x的状态会被置为无效状态I,等待第一个cpu将修改后的值刷回主内存后,重新去获取新的值。这个谁先改变的x的值可能是同一时刻进行修改的,此时cpu会通过底层硬件在同一个指令周期内进行裁决,裁决是谁进行修改的,就置为修改状态,而另一个就置为无效状态,被丢弃或者被覆盖。

当然MESL也会有失效的时候,缓存的最小单元是缓存行,如果当前的共享数据的长度超过一个缓存行的长度的时候,就会使MESL协议失败,此时的话就会触发总线加锁的机制,第一个线程cpu拿到这个x的时候,其他线程都不允许去获取这个x的值。

4.禁止指令重排序优化
1)指令重排序基本概念:
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能的提高并行度。指令重排序包括编译器重排序和运行时重排序。

2)如果一个操作不是原子的,就会给JVM留下重排序的机会。下面我们举一个非常经典的例子。

public class SingletonDemo {

    private static SingletonDemo instance=null;

    private SingletonDemo() {
    }

    public static SingletonDemo getInstance(){
        if(null==instance){
            synchronized (SingletonDemo.class){
                if(null==instance){
                    //非原子操作
                    instance=new SingletonDemo();
                }
            }
        }
        return instance;
    }

}

这是单例模式中的"双重检查加锁模式",由于instance=new SingletonDemo();并不是一个原子操作,其实际可以抽象为下面几条JVM指令。

//分配对象的内存空间
1.memory=allocate();
//初始化对象
2.ctorInstance(memory);
//设置instance指向刚分配的内存地址
3.instance=memory;

上面的操作2依赖于操作1,但是操作3并不依赖于操作2.所以JVM可以针对他们进行指令的优化重排序,经过重排序后如下。

//分配对象的内存空间
1.memory=allocate();
//设置instance指向刚分配的内存地址
2.instance=memory;
//初始化对象
3.ctorInstance(memory);

指令重排序之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,将其返回使用,导致出错。

3)对此我们可以用volatile关键字修饰instance变量,使得instance在读,写操作前后都会插入内存屏障,避免重排序。

    private volatile static SingletonDemo instance=null;

4)内存屏障。
volatile有序性是通过内存屏障实现的。JVM和cpu都会对指令做重排优化,所以在指令间插入一个屏障点,就相当于告诉JVM和cpu不能进行重排优化。具体分为读读,读写,写读,写写这四种屏障,同时他也会有一些插入屏障点的策略:
i)每个volatile写的前面插入一个store-store屏障,禁止上面的普通写和下面的volatile写重新排序。
ii)每个volatile写的后面插入一个store-load屏障,禁止上面的volatile写和下面的volatile读/写重新排序。
iii)每个volatile读的后面插入一个load -load屏障,禁止下面的普通读和上面的volatile读重新排序。
iv)每个volatile读的后面插入一个load -store屏障,禁止下面的普通写和上面的volatile读重新排序。
上面的插入策略非常保守,但是他可以保证在任意处理器平台上的正确性,在实际执行时,编译器可以省略没必要的屏障点,同时在某些处理器上会做进一步的优化。

5.不保证原子性
尽管volatile关键字可以保证内存可见性和有序性,但不能保证原子性。也就是说,对volatile修饰的变量进行操作,不保证多线程安全。下面我们举个例子:

public class AtomicityDemo extends Thread {

    private static volatile int increase = 0;
    //原子类(实现了原子性)作为对照组
    private static AtomicInteger aInteger = new AtomicInteger();

    private static void increaseFun() {
        increase  ;
        //计算多线程调用次数,每次调用会 1,返回int类型数据。
        aInteger.incrementAndGet();
    }

    @Override
    public void run() {
        int i = 0;
        while (i < 10000) {
            increaseFun();
            i  ;
        }
    }

    public static void main(String[] args) {
        AtomicityDemo ad = new AtomicityDemo();
        int thread = 10;
        Thread[] threads = new Thread[thread];
        for (int i = 0; i < thread; i  ) {
            threads[i] = new Thread(ad, "线程"   i);
            threads[i].start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("volatile的值为:"   increase);
        System.out.println("AtomicInteger的值为:"   aInteger);
    }

}

输出:
volatile的值为:98490
AtomicInteger的值为:100000

上面的代码我们采用了10个线程同时对volatile修饰的变量进行自增1000的操作。如果volatile变量是并发安全的话,运行结果应该为10000,可结果很显然不是,由此可见volatile修饰的变量并不保证原子性。
increase 这行代码不是原子操作, 操作的执行过程如下所示:
1.首先获取变量increase的值。
2.将该变量的值 1。
3.将该变量的值写回到对应的主内存中。
虽然每次获取increase值的时候,都拿到的是主内存中的最新变量值,但是在进行第二部 1的操作的时候,可能其他线程在此期间已经对increase进行了 1,这时候就会触发MESL协议的失效动作,将该线程内的值无效,那么 1的操作就失效了。所有会产生多个线程同时做了 1的操作,但是实际结果只加了1次,这就造成了返回结果小。对此我们可以用以下方式解决这个问题:

 private synchronized static void increaseFun() {
        increase  ;
        //计算多线程调用次数,每次调用会 1,返回int类型数据。
        aInteger.incrementAndGet();
    }

整理借鉴了很多大佬写的,在此无法一一说明,这只是个人用来查漏补缺的文章,如果对你有帮助我很高兴。

来源:https://www./content-4-853251.html

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多