• (转)volatile如何保证可见性


    1、前言

      volatile能够保证可见性和有序性,是怎么保证可见性和有序性的?为什么不能保证原子性?

    2、问题的出现

      先看一个例子,可见性导致的线程安全问题:

      

     1 public class Main {
     2 
     3     static int a = 0;
     4 
     5     public static void main(String[] args)  throws Exception {
     6         Thread t1 = new Thread(new Runnable() {
     7             @Override
     8             public void run() {
     9                 while (a == 0) {
    10 
    11                 }
    12                 System.out.println("T1得知a = 1");
    13             }
    14         });
    15 
    16         Thread t2 = new Thread(new Runnable() {
    17             @Override
    18             public void run() {
    19                 try {
    20                     Thread.sleep(1000);
    21                     a = 1;
    22                     System.out.println("T2修改a = 1");
    23                 } catch (InterruptedException e) {
    24                     e.printStackTrace();
    25                 }
    26             }
    27         });
    28         t1.start();
    29         t2.start();
    30     }
    31 }

      线程T2在休眠1秒之后,修改了a的值为1,此时T1应该退出while循环并打印结果,但是结果并非如此

       T1没有退出循环,程序也就不会结束。但是如果对变量a使用volatile关键字修饰就会解决该问题。这个问题的源头就在于可见性问题。为什么会出现这个问题?这需要从CPU多级缓存架构讲起。

    3、CPU多级缓存架构

       一个双核CPU架构可以如下图所示:

      

       首先需要明确的一点是,计算机实际是分为多级缓存的,因为读取缓存的数据性能十分快

    •  当CPU1需要读取共享变量的值a时,首先会找到缓存(即L1、L2、L3三级高速缓存),看看这个值是不是 在L1、L2、L3中。
    •     很明显,缓存没办法给CPU1它想要的数据,于是只能取主内存读取共享变量的值。
    •     缓存得到的共享变量的值之后,把数据交给寄存器,但是缓存留了个心眼,他把a的值缓存了起来,这样下次别的线程在需要a的时候,就不用再去主内存问了。

      至此每一次完成的数据访问流程走完了。L1和L2、L3都是告诉高速缓存,从高速缓存和主内存读取数据的速度完全时两个概念。所以才会由主内存和缓存的设计。

    4、写数据缓存时刷新新内存

      针对上诉模型,当CPU1读取完数据后,加入对数据进行了修改,那么它会将缓存->主内存的顺序将修改后的数据刷新一遍,完成对数据的更新。

      从读到写这一整个流程看起来似乎完美的,而且每次修改都把数据重新写回到主内存,讲道理不会有问题啊?

      实际上问题正式处在这个看似完美的读写操作中:对于CPU1来说的取值是完美的,但是如果这个时候CPU2类插一脚那?我们撕开下面这个流程:

    •  CPU1读取数据a=1,CPU1的缓存中都由数据a的副本。
    •     CPU2也执行读取操作,同样CPU2也有数据a=1的副本。
    •     CPU1修改数据a=2,同时CPU1的缓存一级主内存a=2
    •     CPU2再次读取a,但是CPU2在缓存中命中数据,此时a=1

           问题到这里已经很明显了,CPU2并不知道CPU1改变的共享变量的值,因此造成了不可见问题。

    5、缓存一致性协议

      为了解决这个问题,在早期的CPU当中,是通过在总线上直接加锁的形式来解决缓存不一致的问题。

      但是正如Java中Synchronized一样,直接枷锁太粗暴了,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。很明显这样做时不可取的。

      所以就出现了缓存一致性协议。缓存一致性协议由MSI,MESI,MOSI,Synapse,Firefly一级DragonProtocol等等。

     6、MESI协议

      最出名的就是Intel的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本时一致的 。

    •  Modify(修改):当缓存行中的数据被修改时,该缓存行设置为M状态
    •     Exclusive(独占):当只有一个缓存行使用某个数据时,置为E状态
    •     Shared(共享):当其他CPU中也读取到某数据到缓存行时,所有持有该数据的缓存行置为S状态
    •     Invalid(无效):当某个缓存行数据修改时,其他持有该数据的缓存行置为I状态

       他的核心思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行时无效的,那么他就会重新从主内存中读取。在这其中,监听和通知由基于总线嗅探机制来完成的。

    7、总线嗅探机制

      嗅探机制其实就是一个监听器,回到我们刚才的流程,如果是加入MESI缓存一致性协议和总线嗅探机制之后:

    •  CPU1读取数据a=1,CPU1的缓存中都有数据a的副本,该缓存行置为(E)状态。
    •     CPU2也执行读取操作,同样CPU2也有数据a=1的副本,此时总线嗅探到CPU1也有该数据,则CPU1、CPU2两个缓存行都置为(S)状态。
    •     CPU1修改数据a=2,CPU1的缓存以及主内存a=2,同时CPU1的缓存行置为(S)状态,总线发出通知,CPU2的缓存行置为(I)状态。
    •     CPU2再次读取a,虽然CPU2在缓存中命中数据a=1,但是发现状态为(I),因此直接丢弃该数据,去主内存获取最新数据。

      当我们使用volatile关键字修饰某个变量之后,就相当于告诉CPU:我这个变量需要使用MESI和总线嗅探机制处理。从而也就保证了可见性。

    8、指令重排

      在加入MESI和总线嗅探机制后,当CPU2发现当前缓存行数据无效时,会丢弃该数据,并前往主内存获取最新数据。

      但是这里又会产生一个问题:CPU1把数据刷回主内存是需要时间的,假如CPU2在主内存拿数据时,CPU1还没有把数据刷回来呢?

      很明显,CPU2不会把资源浪费在这里傻等。它会先跳过和该数据有关的语句,继续处理后面的逻辑。

      比如说如下代码:

      

    1 a = 1;
    2 b = 2;
    3 b++;

      假如第一条语句需要等待CPU1数据刷新,那么CPU2可能就会先回来执行后面两条语句。因为对于CPU2来说,先执行后面两条语句不会对最终结果造成任何影响。

       但是多线程环境下就会出现问题。关于指令重排序,我们放到内存屏障来讲。

    9、一些可能让你困惑的问题

      依旧是一开始的代码,假如我们把TI线程循环的内容改成如下:

      

    1 Thread t1 = new Thread(new Runnable() {
    2     @Override
    3     public void run() {
    4         while (a == 0) {
    5             System.out.println(a);
    6         }
    7         System.out.println("T1得知a = 1");
    8     }
    9 });

      或者如下:

      

     1 Thread t1 = new Thread(new Runnable() {
     2     @Override
     3     public void run() {
     4         while (a == 0) {
     5             try {
     6                 Thread.sleep(1);
     7             } catch (InterruptedException e) {
     8                 e.printStackTrace();
     9             }
    10         }
    11         System.out.println("T1得知a = 1");
    12     }
    13 });

      此时变量a没有使用volatile修饰。

      但是运行结果会让你匪夷所思:程序正常结束,a变量对T1居然可见了!

      这是为什么呢?难道是因为在while循环中加了代码导致的?

      那我们加个变量b再来试试:

      

    1 Thread t1 = new Thread(new Runnable() {
    2     @Override
    3     public void run() {
    4         while (a == 0) {
    5             b++;
    6         }
    7         System.out.println("T1得知a = 1");
    8     }
    9 });

      这次运行结果T1又没办法感知a的变化了,也就是说,并不是while中有代码就会发生可见的现象。

      那么真正的原因究竟是什么呢?

    10、勤奋的CPU 

      这是一个很有趣的现象,有些人认为是因为println方法加了synchronized的原因。的确,锁机制保证了每次执行都会把共享内存中的数据同步到工作内存中。

      但Thread.sleep方法并没有加呀?

      真正的原因在于,CPU是很勤奋的,如果它发现自己有空闲的时间,就会主动去主内存里更新自己缓存中的数据。

      而Thread.sleep方法对于CPU来说,会给它“喘息”的时间,让它有空去把缓存里的数据去主内存刷新一下。

      而后面的b++操作几乎没有给CPU任何机会休息,也就没办法去刷新缓存中的数据信息。

    总结

      事实上,我们的JMM模型就是类比CPU多核缓存架构的,它的作用是屏蔽掉了底层不同计算机的区别
      JMM不是真实存在的,只是一个抽象的概念。volatile也是借助MESI缓存一致性协议和总线嗅探机制才得以完成
      此外,当CPU不支持缓存一致性协议时,还是需要依靠总线加锁的形式来保证线程安全

     转自:https://zhuanlan.zhihu.com/p/250657181

  • 相关阅读:
    数据结构化与保存
    爬取基础2
    爬取校园新闻首页的新闻的详情,使用正则表达式,函数抽离
    爬虫基础
    中文词频
    使用docker搭建rabbitmq集群
    centos安装rabbitmq
    git查看仓库地址以及修改远程仓库
    网易云邮箱账号
    jmeter提取登录cookie实现跨线程组保持登录
  • 原文地址:https://www.cnblogs.com/rana4504/p/14631986.html
Copyright © 2020-2023  润新知