volatile的特点
大家在进行并发编程的时候肯定进程遇到多线程操作同一个变量带来的诸多问题。比如一个线程修改了变量另一个线程却读不出正确的值;比如多个线程修改一个变量的值最后值却不正确。这些问题都是JMM(javamemorymodel内存模型)中的变量不具有可见性和有序性和原子性导致的。好在volatile能够解决其中的可见性和有序性的问题。被volatile修饰的变量具有如下特点:
1.保证此变量对所有的线程的可见性,不能保证它具有原子性(可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的)
2.禁止指令重排序优化
3.volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行
JMM内存模型
Java内存模式是一种虚拟机规范。
它用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
JMM内存模型把虚拟机内存分为主内存和工作内存
主内存
主内存是处理器共享的存储空间。主内存用于存储共享变量,是多个线程之间共享的数据存储区域。多个线程可以同时访问主内存中的共享变量,但是线程对共享变量的修改不一定会立即同步到主内存中。Java内存模型规定:线程对共享变量的修改操作必须先在线程自己的工作内存中进行,然后可能会被延迟到主内存中去
工作内存
每个线程都有一个工作内存。工作内存是 JMM 的一个抽象概念,并不真实存在。工作内存是寄存器和高速缓存的抽象。我们可以约等于理解为工作内存即为cpu的寄存器或者高速缓存。线程执行的时候,首先从主内存读值,再保存为工作内存中的副本,然后交给cpu执行,执行完毕后再给副本赋值,随后工作内存再把值传回给主存。
主内存和工作内存之间交互方式
Java内存模型中定义了下面 8 种操作来完成主内存和工作内存的交互
Lock(锁定):
作用于主内存中的变量,表示变量被一个线程独占的状态。
Unlock(解锁):
作用于主内存中的变量,将变量从锁定状态(Lock)释放,释放后的变量可被其他线程锁定(Lock)
Read(读取):
作用于主内存中的变量,将变量从主内存传输到工作内存中的过程
Load(加载):
作用于工作内存中的变量,把read操作从主内存中得到的变量的值放入工作内存的变量副本中。
Use(使用):
作用于工作内存中的变量,把工作内存中变量传递给执行引擎用于计算等等。(可理解为使用这个变量)
Assign(赋值):
作用于工作内存中的变量,把一个从执行引擎接收到的值赋值给工作内存中的变量。(可理解为给变量赋值。)
Store(存储):
作用于工作内存中的变量,把工作内存中的一个变量传送到主内存中,以便随后write 操作使用。
Write(写入):
作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
需要注意一下几点:
1.read和load ;store和write 是成对出现的,才能实现数据完整的拷贝
2.执行了assign(赋值),就必须执行store和write 把更改同步到主内存
3.use前必须执行load,load前必须执行read
4.lock只允许一个线程同时执行
5.lock一个变量的时候,工作内存中的此变量的值将会清空,所以use(使用)前必须重新read(读取)和load(加载)初始化变量的值。
6.unlock前必须把变量刷会主内存(即store和write)
问题一:可见性问题
可见性问题指的是一个线程对变量操作之后,其他线程不能及时的看见这个操作。
比如线程A对变量i进行了值的修改从0改到了1,而线程B正在使用这个i值,但是拿到的始终是初始值0.
在JMM(java内存模型) 中所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程如果要访问主内存的变量,他会首先从主内存中拷贝一份副本到本线程的工作内存中,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问其他线程工作内存中的变量。
所以,就可能出现线程A改了某个变量的值,但是线程B不可见的情况。
看看如下代码示例:
运行之后会发现一直看不到打印日志,这就说明了B线程修改了stopThreadFlag的值,但是A线程一直读取不到。如果我们把stopTreadFlag定义为volatile变量的话,就能够看到控制台的打印了,读者可以试试。
使用volatile修饰共享变量后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,线程操作变量副本并写回主内存。volatile保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值。
那么为什么volatile修饰的变量能够刷回主内存呢
通过对OpenJDK中的unsafe.cpp源码的分析,会发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。当CPU发现这个指令时,立即会做两件事情
1.会将当前处理器缓存行的数据直接写回到系统内存中
2.这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。这是通过cpu总线缓存一致协议来保证的
缓存一致性协议
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
问题二:重排序问题
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。从java代码一直到最后指令被执行,有多个步骤下会重排指令。
编译器重排序
编译器在编译的时候对没有数据依赖性的操作可以重排序,重排序后不会影响程序语意
由于编译器重的原因,在并发编程的时候就会出现意想不到的问题。如下示例:
B线程打印a的值可能打印出 0,但是b已经是true了。这就是编译起重排序导致。编译器编译的时候认为线程A的操作,a和b两个数据没有数据依赖性,可能把b=true排序在a=1的前面。这时候B线程执行的时候取得b的值是true,但是a的值还没有改变。
指令集重排序
处理器改变对应机器语言的执行顺序,重排后不会影响程序语意
来看一个例子:
这是一个典型的获取单例的例子,但是这样写是有问题的。如果线程A和线程B同时调用getInstance来获取单例,可能其中一个线程调用tools的时候会报空指针异常。为什么呢?
可能会有读者提问,不是判断了tools == null了吗?为什么还会报空指针异常。
如果了解字节码指令的读者会知道对应一个new关键字的时候,处理字节码的常规顺序是
1.内存分配
2.初始化内存实例
3.引用指向内存实例
这里的2和3 的顺序可能被重排,所以当引用指向这块内存的时候内存其实可能没有初始化,如果使用这个引用可能报空指针异常。
内存系统重排序
处理器高速缓存的数据在刷会主内存的时候可能会乱序
假设有处理器A和处理器B两个处理器,a和b的初始化状态为0 。在处理器A中执行下面代码
B中执行下面代码
两个线程执行后,得到的结果可能是x=y=0。来看一下处理器和内存的交互图
A线程执行的正常顺序:A1->A2->A3
B线程执行的正常顺序:B1->B2->B3
所以按照正常的读写顺序(不考虑未及时刷回主存因素)可能的结果为如下两种结果
x=1 y=0 或 x=0 y=1
因为现代处理器都会使用写缓存,因此现在处理器都会允许对写-读的操作进行重排序,重排序后的顺序
A线程执行的正常顺序:A1->A3->A2
B线程执行的正常顺序:B1->B3->B2
所以导致x = y = 0
volatile是怎么处理重排序问题的
volatile通过 “内存屏障” 的方式来防止指令被重排,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。大多数的处理器都支持内存屏障的指令。
下面是基于保守策略的JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障,禁止上面的普通写和他重排在每个volatile写操作的后面插入一个StoreLoad屏障,禁止跟下面的volatile读/写重排在每个volatile读操作的后面插入一个LoadLoad屏障,禁止下面的普通读和voaltile读重排在每个volatile读操作的后面插入一个LoadStore屏障,禁止下面的普通写和volatile读重排