• java EE初阶 — volatile关键字保证内存可见性


    1.volatile保证内存可见性

    先来看一段代码

    package thread;
    
    import java.util.Scanner;
    
    class MyCounter {
        public int flag = 0;
    }
    
    public class ThreadDemo17 {
        public static void main(String[] args) {
            MyCounter myCounter = new MyCounter();
    
            Thread t1 = new Thread(() -> {
                while (myCounter.flag == 0) {
                    //什么都不做
                }
                System.out.println("t1循环结束");
            });
    
            Thread t2 = new Thread(() -> {
                Scanner input = new Scanner(System.in);
                System.out.println("请输入一个人非0整数:");
                myCounter.flag = input.nextInt();//输入一个 整数
            });
            t1.start();
            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
    • 24
    • 25
    • 26
    • 27
    • 28

    t1 线程要快速循环读取。t2 线程要修改 flag 以保证可以跳出循环。

    这段代码的预期是在输入一个非0整数后,线程1跳出循环输出 t1循环结束


    Thread-0 就是 t1 ,因为是要循环,所以状态是 RUNNABLE


    Thread-2 就是 t2 ,状态是 RUNNABLE但是其实它是在阻塞等待的。


    输入非0整数发现程序并没有结束。


    同时可以看到 t2 线程已经不在了(执行完了),但是 t1还在,程序会继续执行循环。

    这就是 内存可见性 问题。

    使用汇编来理解可以分两步:

    1. load 把内存中的 flag 的值读取到寄存器里。
    2. cmp 把寄存器的值和 0 进行比较,根据比较的结果,决定下一步往哪个地方执行(条件跳转指令)

    上述的循环会执行很多次,在 t2 真正修改之前。load 得到的结果都是一样的。
    另一方面。load 操作和 cmp 操作相比,速度慢非常非常多!!!

    相对于 cmp 来说, load 的执行速度太慢了,再加上1反复得到的结果都一样,
    JVM 就做出了一个非常大胆的决定。不再真正的重复 load 了,判定好像不会修改 flag 的值了。
    于是干脆就只读取一次就好了。

    这其实是编译器优化的一种方式。

    内存可见性问题:

    一个线程针对一个变量进行读取操作,同时另一个变量针对这个变量进行修改,
    此时读到的值,不一定是修改之后的值。

    这个读线程没有感知到变量的变化。

    内存可进行问题本质上是编译器/jvm 在多线程的环境下优化时产生误判了。

    1.1 如何保证内存可见性

    volatile 关键字能保证内存可见性

    此时需要手动给 flag 变量加上 volatile关键字。
    意思就是告诉编译器,这个变量是 “易变”的,一定要每次重新读取这个变量的的内存内容。
    因为不一定什么时候就会反生改变,不能随便的优化了。

    class MyCounter {
       volatile public int flag = 0;
    }
    
    • 1
    • 2
    • 3


    加上 volatile 后,得到了预期的结果。

    上面的编译器优化的情况,也不是始终会出现的。(编译器也可能会出现误判)

    下面来调整以下代码,使用 sleep 来控制循环速度。

    package thread;
    
    import java.util.Scanner;
    
    class MyCounter {
       public int flag = 0;
    }
    
    public class ThreadDemo17 {
        public static void main(String[] args) {
            MyCounter myCounter = new MyCounter();
    
            Thread t1 = new Thread(() -> {
                while (myCounter.flag == 0) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("t1循环结束");
            });
    
            Thread t2 = new Thread(() -> {
                Scanner input = new Scanner(System.in);
                System.out.println("请输入一个人整数:");
                myCounter.flag = input.nextInt();//输入一个 整数
            });
            t1.start();
            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
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32



    观察结果即可发现,即使没有 volatile 的参与,程序依然达到了预期结果。

    但是为了保证稳妥还是建议加上 volatile。

    1.2 java 内存模型(JMM)

    java 程序里的 主内存 还有自己的 工作内存 (t1 和 t2 的工作内存不是一个东西)。
    t1 线程进行读取的时候,只是读取了工作内存的值。
    t2 线程进行读取的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中。
    但是由于编译器优化,导致 t1 没有重新的从主内存同步数据到工作内存中,读取的结果就是 “修改之前” 的结果。


    把上诉内容里的 “主内存” ,改为 “内存”。
    把 “工作内存” 改为 “CPU寄存器”。或许可以使上面的内容更好的理解。


    这里的工作内存不一定只是 CPU 的寄存器,还可能包括 CPU 的缓存 cache

    CPU 读取寄存器,速度比读取内存快多了。
    因此就会在 CPU 内部引入缓存 cache

    寄存器存储空间小,读写速度块,但是它的价格比较贵。
    中间有一个cache存储空间,读写速、成本居中。
    内存存储空间大,读写速度满,相对于寄存器来说比较便宜。

    当 CPU 读取一个内存数据的时候,可能是直接读取内存、也可能是读取 cache 、还可能是读取寄存器。

    引入 cache 之后,硬件结构就更复杂了。

    工作内存(工作存储区):CPU 寄存器 + CPU 的 cache

    一方面是为了表述简单,另一方面是为了避免涉及到硬件的细节和差异。

    2.volatile 不保证原子性

    package thread;
    
    class Counter {
        volatile public int count = 0;
    
         public void add() {
             count++;
        }
    }
    
    public class ThreadDemo15 {
        public static void main(String[] args) {
            Counter counter = new Counter();
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < 50000; i++) {
                    counter.add();
                }
            });
    
            Thread t2 = new Thread(() -> {
                for (int i = 0; i < 50000; i++) {
                    counter.add();
                }
            });
    
            t1.start();
            t2.start();
    
            try {
                t1.join();
                t2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("count:" + counter.count);
        }
    }
    
    • 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

    volatile 和 synchronized 有着本质的区别。
    synchronized 能够保证原子性,volatile 保证的是内存可见性。


    此时可以看到, 最终 count 的值仍然无法保证是 100000

  • 相关阅读:
    在 Java 中检查空字符串或空白字符串
    Vue packages version mismatch: vue@xxx vue-server-renderer@xxx
    软件项目验收测试范围和流程,这些你都知道吗?
    插值问 题
    echarts实现横向和纵向滚动条(使用dataZoom)
    基于jeecgboot-vue3的Flowable流程-待办任务(三)
    Go 语言 设计模式-适配器模式
    【机器学习算法】决策树-4 CART算法和CHAID算法
    C++ 类模板实现栈和循环队列
    Docker
  • 原文地址:https://blog.csdn.net/m0_63033419/article/details/128156526