• Java中的volatile


    1、volatile的内存语义

    内存可见性

    ​ volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销,倘若能恰当的合理的使用volatile,自然是美事一桩。

    为了能比较清晰彻底的理解volatile,我们一步一步来分析。首先来看看如下代码

    public class volatileDemo1 {
        static boolean flag = true;
    
        public static void main(String[] args) {
            new Thread(()->{
                System.out.println(Thread.currentThread().getName()+"\t -----come in");
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                flag = false;
            },"t1").start();
    
            new Thread(()->{
                System.out.println(Thread.currentThread().getName()+"\t -----come in");
                while (flag) {
    
                }
                System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
            },"t2").start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    上面这个例子,模拟在多线程环境里,t1线程对flag共享变量修改的值能否被t2可见,即是否输出 “-----flag被设置为false,程序停止” 这句话?

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vzNZ5vt5-1656404046754)(JUC并发编程.assets/image-20220628160812458.png)]

    答案是:NO! 输出结果如下~
    在这里插入图片描述

    ​ 这个结论会让人有些疑惑,可以理解。因为倘若在单线程模型里,因为先行发生原则之happens-before,自然是可以正确保证输出的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量flag来说,线程t1的修改,对于线程t2来讲,是"不可见"的。也就是说,线程t2此时可能无法观测到flage已被修改为false。那么什么是可见性呢?

    所谓可见性,是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更,JMM规定了所有的变量都存储在主内存。很显然,上述的例子中是没有办法做到内存可见性的。

    volatile的内存语义

    • 一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
    • 一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量

    所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取,从而保证了可见性。

    volatile变量有2大特点,分别是:

    • 可见性

    • 有序性:禁重排!

      重排序是指编译器和处理器为了优化程序性能面对指令序列进行重新排序的一种手段,有时候会改变程序予以的先后顺序。

      • 不存在数据以来关系,可以重排序;
      • 存在数据依赖关系,禁止重排序。

      但重排后的指令绝对不能改变原有串行语义!

    那么volatile凭什么可以保证可见性和有序性呢??

    • 内存屏障Memory Barrier~

    2、内存屏障

    ​ 内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

    ​ Java中的内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性。

    • 内存屏障之前 的所有 操作 都要 回写到主内存,
    • 内存屏障之后 的所有 操作 都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。

    粗分主要是以下两种屏障:

    • 读屏障(Load Memory Barrier) :在读指令之前插入读屏障,让工作内存或CPU高速缓存当中的缓存数据失效,重新回到主内存中获取最新数据。
    • 写屏障(Store Memory Barrier) :在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中。

    因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话:对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读。

    让我们来看看源码:sun.misc.Unsafe.java
    在这里插入图片描述
    主要包括以上三个方法,接着在对应的Unsafe.cpp 源码中查看:
    在这里插入图片描述
    在底层C++代码中发现其底层调用的是OrderAccess类中的方法~
    在这里插入图片描述
    我们发现其又细分了四种屏障,四大屏障分别是什么意思呢?

    屏障类型指令示例说明
    LoadLoadLoad1;LoadLoad;Load2保证Load的读取操作在load2及后续读取操作之前执行
    StoreStoreStore1;StoreStore;Store2在Store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
    LoadStoreLoad1;LoadLoad;Store2在Store2及其后的写操作执行前,保证Load1的读操作已读取结束
    StoreLoadStore1;StoreStore;Load2保证Store1的写操作已刷新到主内存之后,Load2及其后的读操作才能执行

    接下来结合底层linux_86代码来分析~
    在这里插入图片描述

    2、happens-before 之 volatile 变量规则

    在这里插入图片描述

    给大家讲解一下上表,主要有以下三种情况不允许重拍~

    1. 蓝色:当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。

    2. 红色:当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会重排序到volatile写之后。

    3. 绿色:当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。

    其他情况都允许被重排。

    4、Demo

    可见性案例

    public class volatileDemo1 {
        static volatile boolean flag = true;
    
        public static void main(String[] args) {
            new Thread(()->{
                System.out.println(Thread.currentThread().getName()+"\t -----come in");
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                flag = false;
            },"t1").start();
    
            new Thread(()->{
                System.out.println(Thread.currentThread().getName()+"\t -----come in");
                while (flag) {
    
                }
                System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
            },"t2").start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DVv5C5mZ-1656423701210)(JUC并发编程.assets/image-20220628161643842.png)]

    若不加volatile修饰为何t2 看不到被 t1线程修改为 false的flag的值?

    1. t1线程修改了flag之后没有将其刷新回住内存,所以t2线程获取不到。
    2. 主线程将flag刷新到了主内存,但是t2一直读取的是自己工作内存中flag的值,没有去主内存中更新获取flag最新的值。

    使用volatile修饰共享变量后,被volatile修饰的变量有以下特点:

    1. 线程中读取时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存;
    2. 线程中修改了工作内存中变量的副本,修改之后回立即刷新到主内存。

    无原子性案例

    首先我们先编写一个用 synchronized 修饰的案例:

    class MyNumber {
        int number;
        public synchronized void addPlusPlus() {
            number++;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在我们的main方法中开启一个线程执行 number++的方法,然后等待2秒,大家的预期值是不是1000呢?

    public class volatileDemo2 {
        public static void main(String[] args) {
            MyNumber myNumber = new MyNumber();
            for (int i = 0; i < 10; i++) {
                new Thread(()->{
                    for (int i1 = 0; i1 < 1000; i1++) {
                        myNumber.addPlusPlus();
                    }
                }).start();
            }
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(myNumber.number);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    结果是一致的,因为使用了 synchronized 修饰了number++方法,从而保证了原子性

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RhetVkfU-1656423701211)(JUC并发编程.assets/image-20220628163421068.png)]

    接下来 使用 volatile修饰number~

    class MyNumber {
        volatile int number;
        public void addPlusPlus() {
            number++;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在这里插入图片描述

    那为什么会出现不预期的结果呢?

    ​ 对于volatile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅是数据加载时是最新的。但是在多线程环境下,“数据计算” 和 “数据赋值” 操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读主内存的最新值,操作出现丢失问题。即 各线程工作内存和主内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步。

    禁重排

    public class volatileDemo3 {
        int i = 0;
        volatile boolean flag = false;
        public void write() {
            if (flag) {
                System.out.println("---i=" + i);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在本案例中 变量i 和 flag 语句的执行顺序如果被重排的话就会影响结果,存在数据依赖关系,禁止重排序。

    说了这么多,那么在什么时候使用 volatile 呢?

    1. 单一赋值可以,但含复合运算赋值不可以
    2. 状态标志,判断业务是否结束
    3. 开销较低的读,写锁策略
    4. DCL双端锁的发布

    这里要提提 DCL双端锁,小编最近面试有被问到~在接下来的博客里给大家谈谈单例模式

  • 相关阅读:
    练习nfs-rsyslog-httpd-mysql
    公有云容灾,中小企业最具性价比的选择
    一图看懂CodeArts Governance 三大特性,带你玩转开源治理服务
    无代码开发V-Robot入门教程
    金仓数据库KingbaseES数据库管理员指南--14索引的管理
    【Linux】多进程——fork()创建进程及相关内容
    「认识AI:人工智能如何赋能商业」【29】主流的机器学习工具
    Python自学笔记——高级篇(面向对象)
    项目风险管理
    看完这篇文章,你就入门了所有有关API的热门概念!
  • 原文地址:https://blog.csdn.net/m0_49183244/article/details/125493673