volatile的性质

[TOC]

说说你对volatile关键字的理解

就我理解的而言,被volatile修饰的共享变量,就具有了以下两点特性:

1 . 保证了不同线程对该变量操作的可见性;

2 . 禁止指令重排序

volatile关键字如何保证可见性的?

要知道volatile是如何保证可见性的需要先了解下有关CPU缓存的概念。我们知道CPU的运算速度要比内存的读写速度快很多,这就造成了内存无法跟上CPU的情况。为了解决这类问题,出现了针对CPU的缓存协议。

1
2
3
4
5
Intel开发了缓存一致性协议,也就是MESI协议

①当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,那么他会发出信号通知其他CPU将该变量的缓存行设置为无效状态。

②当其他CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会从新从内存中读取这个变量。

了解了上面的内容,就可以很容易的理解volatile是如何实现的了。

  1. 被 volatile 修饰的共享变量,在翻译成为机器码的过程中为其赋值操作添加特殊机器码指令前缀Lock xxxx
  2. 当CPU发现这个指令时,立即做两件事:
    • 使本CPU的缓存写入内存
    • 上面的写入动作也会引起别的CPU中的缓存无效,

volatile关键字的变量写操作时,强制缓存和主存同步,其他线程读时候发现缓存失效,就去读主存,由此保证了变量的可见性。

volatile关键字如何保证有序性的?

在JMM的逻辑实现中,当操作一个变量 执行为变量赋值 时,JVM会检查此变量是否是被volatile修饰的,如果是的话,JVM会为该变量添加内存屏障。保证该变量操作之前的操作不会乱序到其后

  1. 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
  2. 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

img

volatile可以保证原子性么?

例如我们常碰到的i++的问题。

1
2
3
4
5
i = 1; //原子性操作,不用使用volatile也不会出现线程安全问题。
复制代码
volatile int i = 0;
i++; //非原子性操作
复制代码

如果我们开启200个线程并发执行i++这行代码,每个线程中只执行一遍。如果volatile可以保证原子性的话,那么i的最终结果应该是200;而实际上我们发现这个值是会小于200的,原因是什么呢?

1
2
3
4
5
// i++ 其可以被拆解为
1、线程读取i
2、temp = i + 1
3、i = temp
复制代码
  1. 例如当 i=5 的时候A,B两个线程同时读入了 i 的值

  2. 然后A线程执行了 temp = i + 1的操作, 要注意,此时的 i 的值还没有变化,然后B线程也执行了temp = i + 1的操作,注意,此时A,B两个线程保存的 i 的值都是5,temp 的值都是6

  3. 然后A线程执行了 i = temp (6)的操作,此时i的值会立即刷新到主存并通知其他线程保存的 i 值失效, 此时B线程需要重新读取 i 的值那么此时B线程保存的 i 就是6

  4. 同时B线程保存的 temp 还仍然是6, 然后B线程执行 i=temp (6),所以导致了计算结果比预期少了1。
    链接:https://juejin.im/post/5e01b9aa518825126f373b58

自增语句由 4 条字节码指令构成的,依次为 getstaticiconst_1iaddputstatic,当 getstatic 把 i 取到操作栈顶时,volatile 保证了 i 值在此刻正确,但在执行 iconst_1iadd 时,其他线程可能已经改变了 i 值,操作栈顶的值就变成了脏数据,所以 putstatic 后就可能把较小的值同步回了主内存。

1
2
3
4
getstatic // 获取静态变量race,并将值压入栈顶
iconst_1 // 将int值1推送至栈顶
iadd // 将栈顶两个int型数值相加并将结果压入栈顶
putstatic // 为静态变量race赋值

可见性的底层实现机制

flush处理器缓存,他的意思就是把自己更新的值刷新到高速缓存里去(或者是主内存),因为必须要刷到高速缓存(或者是主内存)里,才有可能在后续通过一些特殊的机制让其他的处理器从自己的高速缓存(或者是主内存)里读取到更新的值。除了flush以外,他还会发送一个消息到总线(bus),通知其他处理器,某个变量的值被他给修改了。

refresh处理器缓存,他的意思就是说,处理器中的线程在读取一个变量的值的时候,如果发现其他处理器的线程更新了变量的值,必须从其他处理器的高速缓存(或者是主内存)里,读取这个最新的值,更新到自己的高速缓存中。所以说,为了保证可见性,在底层是通过MESI协议、flush处理器缓存和refresh处理器缓存,这一整套机制来保障的。

flush和refresh,这两个操作,flush是强制刷新数据到高速缓存(主内存),不要仅仅停留在写缓冲器里面;refresh,是从总线嗅探发现某个变量被修改,必须强制从其他处理器的高速缓存(或者主内存)加载变量的最新值到自己的高速缓存里去。

volatile底层的实现机制?

如果把加入volatile关键字的代码和未加入volatile关键字的代码都生成汇编代码,会发现加入volatile关键字的代码会多出一个lock前缀指令。

lock前缀指令实际相当于一个内存屏障,内存屏障提供了以下功能:

lock addl把rsp寄存器的值加0,因为数据就是

1 . 重排序时不能把后面的指令重排序到内存屏障之前的位置

2.将当前处理器缓存行的数据写回到系统内存。

3.这个写回内存的操作会使其他在CPU里缓存了该内存地址的数据无效。

有了mesi还要volatile吗?

在Java中,volatile是个很高层面的规范,保证了指令不会被重排序+对volatile变量的写使得当前cpu缓存中的所有变量写回到主存中,从而保证了内存可见性。

还是有用的,就算在实现了mesi的cpu上,volatile一样不可或缺。除了禁止指令重排序的作用外,由于mesi只是保证了L1-3 的cache之间的可见性,但是cpu和L1之间

还有像storebuffer之类的缓存,而volatile规范保证了对它修饰的变量的写指令会使得当前cpu所有缓存写到被mesi保证可见性的L1-3cache中。

因为 MESI只是保证了多核cpu的独占cache(L1,L2,L3)之间的一致性,但是cpu的并不是直接把数据写入L1 cache的,中间还可能有store buffer或者invalid queue等