volatile是什么?
volatile关键字是java并发编程中一个非常重要的关键字,使用volatile修饰共享变量可以保证多线程情况下的可见性和有序性。
多线程并发下的问题
先来看一段代码:
/**
* @author Yang Jiarun
* @version 1.0
* @date 2019/12/20 19:30
*/
public class VolatileDemo {
public static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
//线程一
new Thread(()->{
while (!flag)
{
}
System.out.println("我现在是:"+flag);
}).start();
//线程二
new Thread(()->
{
flag=true;
System.out.println("我把flag修改成"+flag);
}).start();
}
}
输出:
我把flag修改成true
上面这段程序中,共享变量是flag,初始值是false。线程二的作用是将其修改为true。线程一如果感知到falg==true,那么会打印现在"我现在是:true"。但是并没有。这是咋回事呢?肯定是因为线程一看不到flag被修改了。
随后我们对flag变量前面加一个volatile关键字,线程一就可以感知到了。
public volatile static boolean flag=false;
即共享变量的值对volatile瞬间可见了。那么volatile是怎么做到的呢?
volatile实现可见性探究
想要探究volatile可见性原理,必须先了解一下JMM(java memory model),即java内存模型。实际上应该是java线程的内存模型。我们知道每一个java线程都对应着一个操作系统中的线程。那么操作系统底层是如何读取数据处理数据的呢?看下面这张图。
CPU想要处理内存中的数据,首先要从寄存器和缓存中找,找不到就从主内存中取出,修改完之后再放回去。其实我们的java线程处理数据的方式也跟这类似。
一个线程对应一个CPU。每个线程都有自己的工作内存。工作内存中存储的是共享变量的副本。所以如果不加限制,线程修改的是自己工作内存中副本的值。
根据上面的示例可以想到,flag是以副本的形式被读到线程一中的,即便线程二修改了flag=true,线程一仍然读不到真正的值,他一直在循环读取自己工作内存中那个flag的副本值。
JMM规定了几个原子操作来完成线程从主内存中读写数据的过程。
- read (读取) :从主内存读取数据
- load (载入) :将主内存读取到的数据写入工作内存
- use (使用) :从工作内存读取数据来计算
- assign (赋值) :将计算好的值重新赋值到工作内存中
- store(存储) :将工作内存数据写入主内存
- write(写入) :将store过去的变量值赋值给主内存中的变量
- lock (锁定) :将主内存变量加锁,标识为线程独占状态
- unlock (解锁) :将主内存变量解锁,解锁后其他线程可以锁定该变量
结合这些原子操作,示例中的流程如下:
可以看到,即便主存中flag已经是true了,线程一还是读不到。如果不加任何限制措施,那么两个线程读取到的共享数据是不一致的。那么解决方法有哪些呢?
-
早期:总线加锁(性能太低)。
总线是CPU与其他硬件进行数据间交互的桥梁。
cpu从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其它cpu没法去读或写这个数据,直到这个cpu使用完数据释放锁之后其它cpu才能读取该数据。使用的是lock和unlock这两个原子操作。可想而知,这种加锁方式粒度太大,如果线程2执行时间很长,其他线程要等很久才能对加锁的数据进行访问和修改,所以效率低下。
-
目前:MESI缓存一致性协议
多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其它cpu通过总线嗅探机制可以感知到数据的变化从而让自己缓存里的数据失效。然后重新执行read操作,然后再做后续的处理。并且这种方式锁的粒度很小,只在store之前才lock,而不是从read就开始lock,效率很高。嗅探机制就相当于一个探子,时刻监视着总线上面流转的数据。
底层原理:
底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)。**在store之前执行lock原子指令。并回写到主内存。
会将当前处理器缓存行的数据立即写回到系统内存。**如果没有lock,这个修改的数据不一定什么时候会被写回主内存。
这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI协议)。需要重新读取。
但是这种方式虽然保证了可见性,但是无法保证原子性。原因很简单,虽然其他线程可以时刻感知到数据的变化,但也仅仅是感知而已,感知到了之后该干嘛了?让自己工作内存中的数据副本失效啊!然后重新读。那么如果在失效之前,线程一对数据做了修改,那就白修改了!
也可以这么理解,虽然use、assign、store等操作虽然都是原子操作,但是放在一起就不是原子操作了!下面的例子中,我们期望的num值是2,但很可能输出的值小于2。