要是面试官再问我volatile,我就这么答

回答volatile关键字

volatile关键字的三个特性:可见性、禁止指令重排、不保证原子性



1、可见性

此处的核心是理解MESI协议,基于volatile关键字展开,然后延申到MESI协议,并且完成MESI协议相关的一些计算机组成的概念的补充

①现象:保证了可见性,在进行线程交互中过程中,被该关键字修饰的变量会具有可见性,能够保证变量在线程间的信息是同步的

②字节码:被该关键字修饰的变量在被编译为字节码的时候,会添加了一个ACC_VOLATILE关键字

③理论基础:MESI协议,可见性的本质特性是基于计算机的缓存一致性协议。MESI分别对应已修改、独占、共享、无效。之所以该协议能够保证变量在不同线程间具有可见性,就是得益于该协议。



在理解理解MESI协议之前需要了解的一下计算机组成相关的概念:

  1. 总线事务:从我们的请求开始,到我们的请求结束,这一整个过程称之为总线事务。
  2. 总线仲裁:在我们的总线事务请求过程中,如果出现,多个处理器同时访问同一片内存空间,那么就一定要对我们的多个请求做出决策,到底使用执行哪一个请求。这个选择的过程叫做总线裁决。
  3. 总线锁定:当总线裁决后,只会有一个请求去执行对应的操作,此时就会出现一个LOCK#信号量(类比理解为Java中的synchronized,添加一个锁),出现了该信号量,表示出现了总线锁定
  4. 缓存锁定:与总线锁定类似,只是锁定的粒度不同,此时锁定的是缓存块。(缓存锁定和总线锁定的区别就好比InnoDB存储引擎下的行锁和表锁)
  5. 总线窥探:在缓存锁定的基础上,通过缓存锁定自己独特的方式去实现数据同步问题,这个独特的方式就是MESI协议,这个MESI协议的基础背景理论则是总线窥探机制。当我们将数据写入一个缓存数据行的时候,其他总线是能够感知到数据变化,继而做出相对应的调整。这就是第一点中所说的为什么volatile能够保证可见性,他背后的原理就是MESI协议,自然也就离不开这里的总线窥探机制

总结:总线事务总是存在,所以需要总线裁决来决定到底是哪一个进行,常规情况下我们直接使用总线锁定,这样就能够保证数据的安全。但是由于人们希望能够提高处理效率,所以又基于总线窥探机制,提出了一个缓存锁定的理论。将原来的锁定内存块变成了粒度更小的缓存块。



MESI协议(详细版请参考浅谈volatile与计算机缓存一致性协议(MESI)之间的联系):

情景前提为,两个线程共同访问num=1这个变量,MESI协议在这个过程中的体现

  1. 线程1将主存中的num = 1加载到线程1私有的缓存空间中。由于num被volatile关键字修饰,所以会执行对应的一致性协议(添加对应的LOCK前缀指令)。此时线程1的缓存状态为独占独占(E);
  2. 如果此时线程2也加载了num变量,那么线程1和线程2的各自的缓存空间对应的缓存状态就会变成共享(S);
  3. 此时线程1将缓存中的数据加载到CPU,在ALU(算数逻辑单元)的配合下完成对应的逻辑运算;
  4. 线程1的CPU完成逻辑运算后,将数据返回给缓存。此时总线发现了数据发生变化,就会修改对应缓存空间的缓存状态,将当前线程的缓存状态修改为已修改(M),其他线程中对应的缓存块状态修改为无效(I)。于此同时,还需要将线程1中的缓存变量num,立即刷新回主存;
  5. 那么当线程2想加载自己缓存空间中对应的变量时,就无法命中(原因就是第四步),所以又重新去主存中加载,此时线程2加载到的num就变成了最新的数据;
  6. 最后线程2在将数据加载到CPU完成自己的逻辑运算,最终返回给主存。

总结:简单来说就是,最开始的人拿到了数据就是E,如果第二个人也拿到了数据就变了两个S,如果一个人修改了数据,那么状态就会变成M,然后数据放到主存,与此同时第二个人的就变成I。既然是无效的I,那么第二个人在使用数据的时候就只能自己再去主存中获取,此时获取到的数据,就成个最新的一笔数据。





2、禁止指令重排

程序在执行的时候,内部会自动优化掉一些不影响结果的逻辑,这个时候就会涉及代码顺序的调整。那么添加了volatile关键字后,就能够禁止代码结构的优化。编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器进行重排序。编译器选择了比较保守的JMM内存屏障策略,这样就能保证在任何处理器平台上,任何程序中都能够得到正确的volatie内存语义。这个策略分成:

LoadStore:在读后面再插入

StoreStore:在写前插入

StoreLoad:在写后插入

LoadLoad:在写读后插入



当然,如果我们只是要解决我们的问题,那么我们添加一个volatile关键字就可以了。如果局部变量也需要使用volatile,那么我们可以手动的添加内存屏障。我们可以使用Java自带的Unsafe工具类,调用对应的方法完成该操作。不过该方法只能通过反射调用。





3、不保证原子性

对任意单个volatile变量的读/写具有原子性,但类似于i++这种复合操作不具有原子性

  • 常见的例子就是使用两个线程对一个变量进行i++操作,最后的结果值不等于循环的次数(我个人不是很能理解为什么会把这一个点当作一个特性来描述,因为i++不具有原子性是客观存在的,即便是你不添加volatile也是没有办保证原子性的,既然i++本身就有问题,那为什又要和volatile关键字扯上关系呢?)
  • 还有一个比较常见的例子,就是单例模式的双重检测锁,我们需要对instance变量添加volatile关键字,因为很有可能在赋值的位置,由于创建对象的过程不是原子性的(类比理解i++),加上这个过程可能会出现指令重排,所以添加对应的关键字就能够很好的避免创建单例对象过程出现的线程安全问题。

猜你喜欢

转载自blog.csdn.net/qq_44377709/article/details/121326037