• java中volatile解决可见性和有序性问题


    java内存模型 (JMM


    这种模型和 jvm 中的堆不同, JMM 是抽象概念,不真实存在。它是一种规范,指定了程序中的各变量的访问方式

    主内存

    JMM 规定所有变量都存放在主内存,主内存是所有线程共享的,但是线程的操作在线程的工作内存中进行,先从主内存读取到线程的工作内存中,然后执行操作,再将工作内存中的值写入主内存中。线程不能直接操作主内存中的数据

    工作内存

    工作内存是线程独有的,不同的线程无法访问到对方的工作内存,线程间的通信必须通过主内存传值进行

    JMM 描述的是变量在共享区域和私有区域的访问方式,变量的访问在多线程下会有 可见性,原子性,可见性三大问题

    可见性问题

    因为有工作内存的划分,一个线程操作修改某变量的值,没有同步到主内存前,其他线程是无法读取到该变量最新的值,就导致了变量在另外的线程不可见。
    示例:

    public class CodeVisibility {
    
        private static boolean initFlag = false;
        // private volatile static boolean initFlag = false;
    
        private static int counter = 0;
    
        public static void refresh() {
            System.out.println("refresh data.......");
            initFlag = true;
            System.out.println("refresh data success.......");
        }
    
        public static void main(String[] args) {
            Thread threadA = new Thread(() -> {
                while (!initFlag) {
                    counter++;
                }
                System.out.println("线程:" + Thread.currentThread().getName()
                        + "当前线程嗅探到initFlag的状态的改变, counter: " + counter);
            }, "threadA");
            threadA.start();
    
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            Thread threadB = new Thread(() -> {
                refresh();
            }, "threadB");
            threadB.start();
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    结果:

    可见看到线程A久久不能结束,虽然线程B此时已经修改了 initFlag 的值,但是线程A无法读取到最新值,因为一直没有和主内存同步

    volatile

    这个关键词可以让变量被修改后立刻使其他线程中的副本可见。在上面的示例代码中,把第3行注释,第4行取消注释后再运行:

    可以看到线程A可以立即结束

    volatile 原理

    1. 使用 volatile 关键字会强制将在某个线程中修改的共享变量的值立即写入主内存。
    2. 使用 volatile 关键字, 当线程 2 进行修改时, 会导致线程 1 的工作内存中变量的缓存行无效(反映到硬件层的话, 就是 CPU 的 L1或者 L2 缓存中对应的缓存行无效);
    3. 由于线程 1 的工作内存中变量的缓存行无效,所以线程1再次读取变量的值时会去主存读取。

    特性

    1. 只能控制变量的可见性
    2. 不能解决原子性问题
    3. 还可以禁止CPU指令重排(见下)

    volatile 番外

    如果不对 initFlag 添加 volatile 标识,线程A就永远无法读取到initFlag的最新值吗?

    不一定, 在判断 initFlag 的值时,CPU 先从缓存中取值,只要缓存失效,就会重新在从内存中加载。那么什么时候缓存会失效呢? 对于CPU缓存来说,分为 L1 L2 L3 三级缓存,也就是离CPU最近的那些寄存器,他们的速度依次递减,容量依次递增。而每次CPU缓存的最小单位不是某个变量所占的空间大小,而是固定的字节 ,这样就能减少CPU和内存交互的次数,更好的利用空间局部原理和时间局部性原理。具体细节可以搜索 CPU缓存相关信息

    因为CPU一次会让一批缓存失效,有可能 initFlag 的缓存会随着其他值失效而重新从内存加载最新值。如下例子:

    public class CodeVisibility {
    
        // initFlag 不再用 volatile 修饰
        private static boolean initFlag = false;
    
        // 这里 counter 类型从 int 修改成 Integer
        private static Integer counter = 0;
    
        public static void refresh() {
            System.out.println("refresh data.......");
            initFlag = true;
            System.out.println("refresh data success.......");
        }
    
        public static void main(String[] args) {
            Thread threadA = new Thread(() -> {
                while (!initFlag) {
                    counter++;
                }
                // 线程仍然可以很快的结束,因为 counter 会导致 cpu 缓存失效,重新从主内存加载最新数据
                System.out.println("线程:" + Thread.currentThread().getName()
                        + "当前线程嗅探到initFlag的状态的改变, counter: " + counter);
            }, "threadA");
            threadA.start();
    
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            Thread threadB = new Thread(() -> {
                refresh();
            }, "threadB");
            threadB.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    结果:

    那么问题又来了,为什么用 int 不会 导致 cpu 缓存失效呢?

    个人推测可能使因为 int 比 Integer 所占用的内存更小,CPU缓存放得下,一直没有触发缓存失效。

    有序性问题

    先看一个例子:

    public class VolatileReOrderSample {
        //定义四个静态变量
        private static int x=0,y=0;
        private static int a=0,b=0;
    
        public static void main(String[] args) throws InterruptedException {
            int i=0;
            while (true){
                i++;
                x=0;y=0;a=0;b=0;
                //开两个线程,第一个线程执行a=1;x=b;第二个线程执行b=1;y=a
                Thread thread1=new Thread(new Runnable() {
                    @Override
                    public void run() {
                        //线程1会比线程2先执行,因此用nanoTime让线程1等待线程2 0.01毫秒
                        shortWait(10000);
                        a=1;
                        x=b;
                    }
                });
                Thread thread2=new Thread(new Runnable() {
                    @Override
                    public void run() {
                        b=1;
                        y=a;
                    }
                });
                thread1.start();
                thread2.start();
                thread1.join();
                thread2.join();
                //等两个线程都执行完毕后拼接结果
                String result="第"+i+"次执行x="+x+"y="+y;
                //如果x=0且y=0,则跳出循环
                if (x==0&&y==0){
                    System.out.println(result);
                    break;
                }else{
                    System.out.println(result);
                }
            }
        }
        //等待interval纳秒
        private static void shortWait(long interval) {
            long start=System.nanoTime();
            long end;
            do {
                end=System.nanoTime();
            }while (start+interval>=end);
        }
    }
    复制代码
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52

    按照正常思维,永远不会发生 x=0 y=0的场景,但事实并非如此:
    下面是线程A B 可能的正常执行情况

    1. 线程A执行完 线程B执行
    2. 线程B执行完 线程A执行:
    3. 线程A B 交叉执行

    发生指令重排的情况

    指令重排

    处理器为了程序的性能可以对程序的执行顺序进行重排,但是,必须满足重排后的执行结果在单线程下结果不能发生改变 这就是 as-if-serial 语义

    为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖的操作进行重排,因为会改变执行结果,如果两个操作不存在依赖关系,就有可能会被重排,就入上面的代码,在线程A中a=1;x=b这两各操作没有依赖关系,就有可能会重新排序成x=b;a=1 , 线程B同理。

    这个执行重排的操作在单线程下没有关系,因为没有影响到最终的执行的结果,但是如果是多线程的场景,就像上面的那个例子,就会发生错误

    如何禁止指令重排

    volatile

    volatile 另一个作用是禁止指令重排,避免多线程下出现乱序执行的情况
    重排规则表:

    从上面的规则可以看出:

    • 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能发生重排,
    • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能发生重排
    • 当第一个操作时 volatile 写,第二个操作是 volatile 读时,不能发生重排
    加锁保证有序性

    另外还可以使用 synchronize 和 lock 来保证有序性,因为加锁后,每时每刻只有一个线程执行代码,指令重排对单线程没有影响

    禁止指令重排的经典应用

    看下懒汉模式的单例的问题:

    // 懒汉模式 + synchronized 同步锁 + double-check
    public final class Singleton {
        private static Singleton instance= null;// 不实例化
        private Singleton(){}// 构造函数
        public static Singleton getInstance(){// 加同步锁,通过该函数向整个系统提供实例
            if(null == instance){// 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返回对象
              synchronized (Singleton.class){// 同步锁
                 if(null == instance){// 第二次判断
                    instance = new Singleton();// 实例化对象
                 }
              } 
            }
            return instance;// 返回已存在的对象
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    为了在多线程并发场景下单例仍然有效,加了锁以及双重检测,但是就万无一失了吗?

    在第一个判断 if(null == instance) 中,会出先变量instance有值,但是内存区域是空的(没有初始化 ),从而导致程序出现问题。造成这个问题的原因在于 instance = new Singleton(),事实上初始化对象操作不是原子性的,它包含下面两个动作:

    1. 给对象分配内存空间,
    2. 内存空间的初始化
    3. 将内存地址赋值给变量

    其中2,3没有依赖关系,经过 编译器或者cpu指令重排后,可能会导致 2,3顺序发生变化:

    1. 给对象分配内存空间,
    2. 将内存地址赋值给变量 //此时变量已经不等于 null, 但是变量指向的内存区域还没有初始化
    3. 内存空间的初始化

    假设线程A按照第二种顺序执行,在执行完步骤3时,还没有执行步骤2,线程B执行到第一个if(null == instance)判断,就会直接返回 instance。 这样对于线程B来说 getInstance() 方法返回的是一个没有经过初始化的对象,导致程序出现问题

    解决问题的方法很简单: private volatile static Singleton instance= null; 使用 volatile 关键词禁止 instance 变量被执行指令重排优化即可

    原子性问题

    指的使一个操作是不可中断的,即使在多线程环境下,一旦操作开始就不会被其他线程影响

    java 中可以通过 synchronize 和 lock 保证原子性,它们能保证任意时刻只有一个线程访问代码

  • 相关阅读:
    IDEA插件推荐:Apipost-Helper
    4月02日,每日信息差
    架构重构技巧
    【JavaScript】Promise(二) —— 几个关键问题
    Amazon Linux 2022 来袭,AWS 承诺后续每两年更新一次
    使用腾讯云服务器安装宝塔Linux面板教程_图文全流程
    Okaleido生态核心权益OKA,尽在聚变Mining模式
    Eclipse中打包maven项目-war包方式
    使用 Node.contains 判断元素是否为后代元素对 svg 元素无效解决方案
    龙迅#LT8711UXE1 适用于Type-C/DP1.2/EDP转HDMI2.0方案,支持音频剥离和HDCP功能。
  • 原文地址:https://blog.csdn.net/a141210104/article/details/126901465