• 内存可见性问题


    目录

    1.什么是内存可见性问题

    2.内存可见性问题是怎么发生的

    3.解决方法:volatile

    4.volatile使用的注意事项

    5.内存可见性问题的延伸

    缓存(cache)


    1.什么是内存可见性问题

    首先来看一段代码

    1. class Counter{
    2. public int flag = 0;
    3. }
    4. public class VolatileDemo1 {
    5. public static void main(String[] args) {
    6. Counter counter = new Counter();
    7. Thread t1 = new Thread(() -> {
    8. while(counter.flag == 0) {
    9. //循环里面不进行任何操作
    10. }
    11. System.out.println("t1 循环结束");
    12. });
    13. Thread t2 = new Thread(() -> {
    14. Scanner scanner = new Scanner(System.in);
    15. System.out.print("请输入flag: ");
    16. counter.flag = scanner.nextInt();
    17. });
    18. t1.start();
    19. t2.start();
    20. try {
    21. t1.join();
    22. t2.join();
    23. } catch (InterruptedException e) {
    24. e.printStackTrace();
    25. }
    26. }
    27. }

    这段代码一共创建了两个线程,其中t1线程去判断flag的值(默认为0),如果不为0则跳出循环(循环里面不执行任何操作)当flag不为0时,提示t1线程结束,t2线程则是输入一个值,赋给flag。

    按照我们的逻辑,当t2线程输入一个不为0的数字时,t1线程会打印“t1 循环结束”,那么我们来看一下结果,如下:

     可以看到,我们输入1,赋值给flag,但是t1循环却没有对此做出相应的操作,这就是出现了内存可见性问题。

    2.内存可见性问题是怎么发生的

    首先针对上面的例子,我们做一些分析。

    t1线程中有一个循环,循环条件是判断flag这个变量是否为0,循环体为空

    t2线程是输入一个数字赋值给flag

    按照逻辑当t2输入数字不为0,那么t1循环结束,那么为什么当t2输入了一个不为0的数字时,t1循环仍然没有结束呢?

    可以肯定的是:t2中的输入和赋值操作都是没有问题的,那么问题的所在就一个在t1的身上。

    那么我们对t1中的执行语句做一些分析:

    t1线程中储粮打印操作,唯一可以被执行的计算循环的判断条件 counter.flag == 0 。

    这条语句我们可以把它拆分成两条指令:

    一条是从内存中获取flag的值--load

    一条是将这个值和0进行比较--cmp

    按理来说,如果每次进入循环条件判断的时候,都对flag的值进行获取,那么结果就不会出现死循环的现象,而此时出现了死循环,那么就说明对flag的获取出现了问题。

    t1中的这个循环是空体,这个循环在执行时的速度极快,1秒钟可以执行上百万次,而执行了这么多次load的获取结果都是一样的。另一方面,load的执行速度相比于cmp慢了太多了。此时JVM就做出来一个非常大胆的决定--不再真正的去重复load了,因为判定好像没人去修改flag的值,所以干脆就只获取一次就好了,此时就出现了前面运行的情况了。

    上述的这种情况是编译器优化的一种方式,而内存可见性问题归根结底就是编译器/JVM在多线程环境下优化时产生了误判,此时就需要我们去手动干预,让编译器不要瞎搞,而这个操作结束在变量前面加上 volatile 关键字。

    3.解决方法:volatile

    继续挪用上面的代码,并且给flag这个变量加上volatile

    1. class Counter{
    2. volatile public int flag = 0;
    3. }
    4. public class VolatileDemo1 {
    5. public static void main(String[] args) {
    6. Counter counter = new Counter();
    7. Thread t1 = new Thread(() -> {
    8. while(counter.flag == 0) {
    9. //循环里面不进行任何操作
    10. }
    11. System.out.println("t1 循环结束");
    12. });
    13. Thread t2 = new Thread(() -> {
    14. Scanner scanner = new Scanner(System.in);
    15. System.out.print("请输入flag: ");
    16. counter.flag = scanner.nextInt();
    17. });
    18. t1.start();
    19. t2.start();
    20. try {
    21. t1.join();
    22. t2.join();
    23. } catch (InterruptedException e) {
    24. e.printStackTrace();
    25. }
    26. }
    27. }

    此时再去运行可以看到

    加了volatile之后,代码的运行情况就符合我们的预期了。

    当然,代JVM并不是任何时候都会出现优化误判的情况,比如下面的代码

    1. class Counter{
    2. public int flag = 0;
    3. }
    4. public class VolatileDemo1 {
    5. public static void main(String[] args) {
    6. Counter counter = new Counter();
    7. //编译器不是任何时候都会进行优化或者优化出错 如下,即使没有 volatile 也可以正常运行
    8. Thread t1 = new Thread(() -> {
    9. while(counter.flag == 0) {
    10. //循环里面不进行任何操作
    11. try {
    12. Thread.sleep(100);
    13. } catch (InterruptedException e) {
    14. e.printStackTrace();
    15. }
    16. }
    17. System.out.println("t1 循环结束");
    18. });
    19. Thread t2 = new Thread(() -> {
    20. Scanner scanner = new Scanner(System.in);
    21. System.out.print("请输入flag: ");
    22. counter.flag = scanner.nextInt();
    23. });
    24. t1.start();
    25. t2.start();
    26. try {
    27. t1.join();
    28. t2.join();
    29. } catch (InterruptedException e) {
    30. e.printStackTrace();
    31. }
    32. }
    33. }

    我们在循环体中加入了sleep,此时代码中没有加 volatile 但是代码也可以正常运行,但是这开发中,对于这种不确定的情况,还是加上volatile更加稳妥。

    4.volatile使用的注意事项

    volatile 只可以对变量进行修饰,不可以对方法进行修饰。

    volatile 不可以对方法中的局部变量进行修饰。

    volatile 不保证原子性,若想保证原子性要使用 synchronized 

    5.内存可见性问题的延伸

    关于内存可见性问题,还可以从JMM(Java Memory Modle java内存模型)的角度去重新表述

    Java程序里除了主内存,每个线程还有自己的“工作内存”

    t1线程进行读取的时候只是读取了它工作内存的数据

    t2线程进行修改的时候,先修改工作内存的数据,然后再把工作内存的数据同步到主内存中,但是由于编译器优化,导致t1没有重新从主内存中同步数据到它的工作内存中,所以读到的结果就是错误的结果。(主内存和工作内存这样的表述来自于Java文档)

    上面的主内存既可以理解为前面说的内存;

    而工作内存可以理解为工作存储区,也就是CPU上存储数据的单元(寄存器)以及缓存。

    缓存(cache)

    CPU中的寄存器存储的空间小,读写速度快,成本高;

    内存的存储空间大,读写速度慢,成本低(相对于寄存器来说)

    缓存就是他俩的中间值,缓存存储空间居中,读写速度居中,成本居中

    当cpu在读取一个数据的时候,可能是直接读取内存,也可能是读取缓存,还可能是读取寄存器

    前面说的工作内存,之所以将寄存器和缓存都包含进去,一方面是因为描述简单,另一方面,无论是缓存还是寄存器都不会对我们得到的结论产生影响。

  • 相关阅读:
    聚观早报 |GPT-4周活用户数达1亿;长城汽车10月销量增加
    轻松省钱赚佣金:微信返利机器人的制作教程
    Linux的screen工具库实现多终端
    572. 另一棵树的子树
    微信小程序传参的五种方式
    孩子的努力,你看的见吗?
    PyCharm使用教程(较详细,图+文)
    使用moviepy生成视频时,提示找不到ImageMagick
    花体字母代表什么
    60主从复制,哨兵模式,集群
  • 原文地址:https://blog.csdn.net/m0_64318128/article/details/128206995