学习笔记-volatile关键字

本文内容源于视频教程,若有侵权,请联系作者删除

一、概念

先看一个例子

 1 public class VolatileDemo {
 2 
 3     public static boolean stop = false;
 4 
 5     public static void main(String[] args) throws InterruptedException {
 6         Thread thread = new Thread(() -> {
 7             int i = 0;
 8             while (!stop) {
 9                 i++;
10             }
11         });
12         thread.start();
13         Thread.sleep(10);
14         stop = true;
15     }
16 }

上面的程序会一直运行,并不会因为主线程中修改了stop的值而跳出循环。而在stop变量加上volatile关键字之后程序会立马结束,这就是volatile的第一个作用:保证可见性,它还有另一个功能——防止指令重排序。下面一一道来。

二、JMM

JMM(Java Memory Model)是一种抽象内存模型,它定义了共享内存中多线程程序读写共享变量规范,即把变量存储到内存以及从内存中取出的底层实现细节。

JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成 。

讲到JMM,这里得提一下硬件模型,可移步至3

 

而JMM存在两个问题

1.可见性问题:多个工作内存同时存储同一变量,当某个线程修改变量值时,会先修改当先当前线程工作内存中变量值,然后写回到主内存中。当其他线程访问该变量时会出现变量不一致问题。

2.有序性问题:为了提高程序的执行性能,编译器和处理器都会对指令做重排序,在单线程下不会出现问题,但是在多线程情况下可能导致计算结果与预期不一致。

 volatile解决可见性和有序性的本质是通过内存屏障来实现,简单来说,内存屏障不仅能限制编译器做指令重排序优化,还具备刷新缓存的功能。

volatile内存语义

当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存

当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

2.1 内存屏障

八种原子操作指令:

  1. read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
  2. load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
  3. use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
  4. assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
  5. store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
  6. write(写入):作用于主内存,它把store传送值放到主内存中的变量中;
  7. lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
  8. unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;

 

屏障类型 指令示例 备注
LoadLoadBarriers load1,loadload,load2 确保load1数据的装载优先于load2及所有后续装载指令的装载
StoreStoreBarriers store1,storestore,store2 确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储
LoadStoreBarriers load1,loadstore,store2 确保load1数据装载优先于store2及后续存储指令的存储
StoreLoadBarriers store1,storeload,load2

确保store1数据对其他处理器可见优先于load2及所有后续指令的装载

volatile实现原理

LoadLoadBarrier
volatile 读操作
LoadStoreBarrier

StoreStoreBarrier
volatile 写操作
StoreLoadBarrier

2.2 指令重排序

简单来说,指令重排序是为了提高程序执行性能,在不影响单线程执行结果的条件下,优化指令的执行顺序。

从源代码到最终执行的指令,可能会经过三种重排序

2 和 3 属于处理器重排序。这些重排序可能会导致可见性问题。

 2.2.1 as-if-serial

虽然编译器和cpu优化指令执行顺序,但必须保证在单线程下执行结果一致,这就是as-if-serial规则。

如:a=1;a=2; 这种情况下如果交换第二句和第三句会导致执行结果不一致,因此这种情况下不会重排序。

 2.2.1 happens-before

happens-before表示在多线程中,只要满足一定规则,那么前一个操作的结果对于后一个操作是可见的。

happens-before规则如下:

1.volatile 变量规则,对一个volatile域的写,happens-before于任意后续对这个volatile域的读

2.传递性规则,如果 1 happens-before 2,2happens-before 3 ,那么1happens-before3

3.Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

4.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁

5.对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

6.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

 

三、计算机内存模型

计算机的核心组件包括CPU,内存,和IO设备,随着时代发展,设备也在不断更新换代,如CPU从单核到多核,内存从1G到8G再到16G不等,硬盘由机械转为固态。然而不管怎么变化,一直都存在这一个问题:就是这三者之间计算速度的差异,CPU速度最快,内存次之,IO设备最慢。为了解决CPU和内存之间速度不匹配的问题引入了高速缓存,当CPU需要从内存中读取数据时,不必从内存中直接读取,只需要从高速缓存中读取已备份好的数据,当CPU写数据到内存时,也是现将数据同步到高速缓存,然后同步到内存,大大增加了CPU的利用率。

然而,高速缓存的引入带了新的问题:缓存一致性,每个CPU都有各自的缓存,这意味着当一个线程修改CPU0缓存中的数据并且此时并未同步到主内存中,另外一个线程读取了CPU1中当前变量的值时,拿到的是脏数据。为解决此问题,引入了两种解决方案,总线索和缓存锁。

总线锁:在CPU情况下,当其中一个线程操作共享变量时,在总线上发出一个 LOCK#信号,其他线程都无法访问内存中的数据,这种方法开销较大

缓存锁:缓存锁把锁的粒度控制到了变量级别,基于缓存一致性协议实现的。

3.1 缓存锁

3.1.1 MESI

MESI是一种缓存一致性协议,常见的还有MSI,MOSI等。

缓存一致性协议通过对读和写缓存的操作,以此解决缓存不一致问题。

在MESI协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。

状态 描述 监听任务 当前CPU读请求 当前CPU写请求
M(Modify)修改

数据只缓存在当前 CPU 缓存中,并且是被修改状态,

也就是缓存的数据和主内存中的数据不一致

缓存行必须时刻监听所有试图读该缓存行相对应的内存的操作,其他缓存须在本缓存行

写回内存并将状态置为E之后才能操作该缓存行对应的内存数据

可读

可写
E(Exclusive)独占 数据只缓存在当前CPU 缓存中,并且没有被修改

缓存行必须监听其他缓存读主内存中该缓存行相对应的内存的操作,一旦有这种操作,

该缓存行需要变成S状态

可读

可写
S(Share)共享 数据可能被多个 CPU 缓存,且各个缓存中的数据和主内存数据一致 缓存行必须监听其他缓存是该缓存行无效或者独享该缓存行的请求,并将该缓存行置为I状态 可读 需要先将其他CPU中缓存置为无效才可写
I(Invalid)无效 数据已失效 需要从主内存读取 不可写(实际上可写,但不生效)

通过MESI协议既能保证在读取缓存数据时不会读到脏数据,又能保证写缓存数据时数据的有效性及对其他CPU的可见性。但仍存在效率问题:

当多个CPU缓存相同数据,且与主内存保持一致(及此时各CPU中缓存行状态都为S),当其中一个CPU修改共享数据时,需要将其他所有CPU中该缓存置为无效,等到收到所有CPU置为无效的结果后才能执行写操作。而在此期间该CPU一直处于阻塞状态,导致APU资源浪费。

为解决上述问题,在CPU中引入了Store Buffers。具体的实现为:在写数据时先把数据写到Store Buffers中,继续处理其他事情,当收到其他所有CPU的返回结果后写到缓存中,然后同步到主内存(其实就是做了异步操作)。

由于引入Store Buffers之后其实是做了异步操作,那么又带来的新的问题:

 1 value=3 2 void exeCpu0(){
 3   value = 10 4   isFinish = true 5 }
 6 void exeCpu1(){
 7   if(isFinish){
 8     assert value == 10
 9   }
10 }

假设value在CPU0和1中是S状态,isFinish在CPU0是E状态。预期的assert value == 10的结果为true,但实际上可能为false,理由如下:

CPU0在执行value = 10;时,由于value是S状态,此时CPU0会把value=10存入Store Buffers并且通知CPU1将value置为E状态,然后执行isFinish = true,由于isFinish是E状态,所以可以立刻修改该值。此时如果CPU1还没有返回执行结果且执行了exeCpu1方法就会导致返回结果与预期不一致问题。因此引入了一个新的概念:CPU层面内存屏障,它包含以下内容

屏障类型 描述
Store Memory Barrier(写屏障) 写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存
Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的 
Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

此时上面问题可以通过如下方式解决。

 1 {
 2   value=3 3   void exeCpu0(){
 4     value = 10 5     StoreMemoryBarrier();
 6     isFinish = true 7   }
 8   void exeCpu1(){
 9     if(isFinish){
10       LoadMemoryBarrier();
11       assert value == 10
12     }
13  }

 

posted @ 2020-09-20 00:08  落雨有清·风  阅读(196)  评论(0编辑  收藏  举报