volatile关键字详解

volatile关键字详解

volatile的三个特点

  1. 保证线程之间的可见性
  2. 禁止指令重排
  3. 不保证原子性

可见性

概念

可见性是多线程场景中才讨论的,它表示多线程环境中,当一个线程修改了共享变量的值,其他线程能够知道这个修改。

为什么需要可见性

缓存一致性问题:

public class Test {
    public static void main(String[] args) {
        Mythread mythread = new Mythread();

        new Thread(() -> {
            try {
                //延时2s,确保进入while循环
                TimeUnit.SECONDS.sleep(2);
                //num自增
                mythread.increment();
                System.out.println("Thread-" + Thread.currentThread().getName() +
                        " current num value:" + mythread.num);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "test").start();

        while(mythread.num == 0){ 
            //dead
        }

        System.out.println("game over!!!");
    }
}

class Mythread{
    //不加volatile,主线程无法得知num的值发生了改变,从而陷入死循环
    volatile int num = 0;

    public void increment(){
        ++num;
    }
}


如上述代码,如果不加volatile,程序运行结果如下

不加volatile

加上volatile关键字后,程序运行结果如下

加上volatile

解决方向:

  • 总线锁:

    一次只有一个线程能通过总线进行通信。(效率低,已弃用)

  • MESI缓存一致性协议,CPU总线嗅探机制(监听机制)

    有volatile修饰的共享变量在编译器编译后进行读写操作时,指令会多一个lock前缀,Lock前缀的指令在多核处理器下会引发两件事情。

    • 写一个volatile变量时,JMM(java共享内存模型)会把该线程对应的本地内存中的共享变量值刷新到主内存;

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


    • 每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态, 当处理器对这个数据进行修改操作的时候,会重新从系统内存中吧数据读到处理器缓存行里。

    • 处理器使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存在总线上保持一致

    (参考下面两位大佬的博客)

    https://blog.csdn.net/jinjiniao1/article/details/100540277

    https://blog.csdn.net/qq_33522040/article/details/95319946

禁止指令重排

指令重排概念

编译器和CPU在保证最终结果不变的情况下,对指令的执行顺序进行重排序。

指令重排的问题

可以与双重检验实现单例模式联系起来看:

首先,一个对象的创建过程可大致分为以下三步:

  1. 分配内存空间
  2. 执行对象构造方法,初始化对象
  3. 引用指向实例对象在堆中的地址

但是在实际执行过程中,CPU可能会对上述步骤进行优化,进行指令重排

序1->3->2,从而导致引用指向了未初始化的对象,如果这个时候另外一个线

程引用了该未初始化的对象(只执行了1->3两步),就会产生异常。

不保证原子性

为什么无法保证

具体例子

public class Test {
    public static void main(String[] args) {
        Mythread mythread = new Mythread();
        for(int i = 0; i < 6666; ++i){
            new Thread(() -> {
                try {
                    mythread.increment();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }, "test").start();
        }
        System.out.println("Thread-" + Thread.currentThread().getName() +
                " current num value:" + mythread.num);
    }
}

class Mythread{
    volatile int num = 0;

    public void increment(){
        ++num;
    }
}

上述代码的运行结果如下图

可以看到,循环执行了6666次,但最后的结果为6663,说明在程序运行过程中出

现了重复的情况。

解决方案

  1. 使用JUC中的Atomic类(之后会专门写一篇学习笔记进行阐述)
  2. 使用synchronized关键字修饰(不推荐)

volatile保证可见性和解决指令重排的底层原理

内存屏障(内存栅栏)

组成

内存屏障分为两种:Load Barrier 读屏障Store Barrier 写屏障

4种类型屏障

种类 例子 作用
LoadLoad屏障 Load1; LoadLoad; Load2 保证Load1读取操作读取完毕后再去执行Load2后续读取操作
LoadStore屏障 Load1; LoadStore; Store2 保证Load1读取操作读取完毕后再去执行Load2后续写入操作
StoreStore屏障 Store1; StoreStore; Store2 保证Load1的写入对所有处理器可见后再去执行Load2后续写入操作
StoreLoad屏障 Store1; StoreLoad; Load2 保证Load1的写入对所有处理器可见后再去执行Load2后续读取操作

作用

  1. 保证特定操作的执行顺序

    在每个volatile修饰的全局变量读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障

  2. 保证某些变量的内存可见性

    在每个volatile修饰的全局变量写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障

posted @ 2021-01-04 20:28  Zzzkis  阅读(435)  评论(7编辑  收藏  举报