
1.11 volatile的作用
volatile的使用是为了线程安全,但volatile不保证线程安全。线程安全有三个要素:可见性、有序性和原子性。线程安全是指在多线程情况下,对共享内存的使用,不会因为不同线程的访问和修改而发生不期望的情况。
volatile有以下三个作用:
(1)volatile用于解决多核CPU高速缓存导致的变量不同步
这本质上是个硬件问题,其根源在于:CPU的高速缓存的读取速度远远快于主存(物理内存)。所以,CPU在读取一个变量的时候,会把数据先读取到缓存,这样下次再访问同一个数据的时候就可以直接从缓存读取了,显然提高了读取的性能。而多核CPU有多个这样的缓存。这就带来了问题,当某个CPU(例如CPU1)修改了这个变量(比如把a的值从1修改为2),但是其他的CPU(例如CPU2)在修改前已经把a=1读取到自己的缓存了,当CPU2再次读取数据的时候,它仍然会去自己的缓存区中读取,此时读取到的值仍然是1,但是实际上这个值已经变成2了。这里,就涉及了线程安全的要素:可见性。
可见性是指当多个线程在访问同一个变量时,如果其中一个线程修改了变量的值,那么其他线程应该能立即看到修改后的值。
volatile的实现原理是内存屏障(Memory Barrier),其原理为:当CPU写数据时,如果发现一个变量在其他CPU中存有副本,那么会发出信号量通知其他CPU将该副本对应的缓存行置为无效状态,当其他CPU读取到变量副本的时候,会发现该缓存行是无效的,然后,它会从主存重新读取变量。
(2)volatile可以解决指令重排序的问题
一般情况下,程序是按照顺序执行的,例如下面的代码:

如果i++发生在int i=0之前,那么会不可避免地出错,CPU在执行代码对应指令的时候,会认为1、2两行是具备依赖性的,因此,CPU一定会安排行1早于行2执行。
那么,int i=0一定会早于boolean f=false吗?
并不一定,CPU在运行期间会对指令进行优化,没有依赖关系的指令,它们的顺序可能会被重排。在单线程执行下,发生重排是没有问题的,CPU保证了顺序不一定一致,但结果一定一致。
但在多线程环境下,重排序则会引起很大的问题,这又涉及了线程安全的要素:有序性。
有序性是指程序执行的顺序应当按照代码的先后顺序执行。
为了更好地理解有序性,下面通过一个例子来分析:

理想的结果应该是,线程二不停地打印0,最后打印一个1,终止。
在线程一里,f和i没有依赖性,如果发生了指令重排,那么f = true发生在i++之前,就有可能导致线程二在终止循环前输出的全部是0。
需要注意的是,这种情况并不常见,再次运行并不一定能重现,正因为如此,很可能会导致出现一些莫名的问题。如果修改上方代码中i的定义为使用volatile关键字来修饰,那么就可以保证最后的输出结果符合预期。这是因为,被volatile修饰的变量,CPU不会对它做重排序优化,所以也就保证了有序性。
(3)volatile不保证操作的原子性
原子性:一个或多个操作,要么全部连续执行且不会被任何因素中断,要么就都不执行。一眼看上去,这个概念和数据库概念里的事务(Transaction)很类似,没错,事务就是一种原子性操作。
原子性、可见性和有序性,是线程安全的三要素。
需要特别注意的是,volatile保证可见性和有序性,但是不保证操作的原子性,下面的代码将会证明这一点:

在之前的内容有提及,volatile能保证修改后的数据对所有线程可见,那么,这一段对intVal自增的代码,最终执行完毕的时候,intVal应该为10000。
但事实上,结果是不确定的,大部分情况下会小于10000。这是因为,无论是volatile还是自增操作,都不具备原子性。
假设intVal初始值为100,自增操作的指令执行顺序如下所示:
1)获取intVal值,此时主存内intVal值为100;
2)intVal执行+1,得到101,此时主存内intVal值仍然为100;
3)将101写回给intVal,此时主存内intVal值从100变化为101。
具体执行流程如图1-2所示。
这个过程很容易理解,如果这段指令发生在多线程环境下呢?以下面这段会发生错误的指令顺序为例:
1)线程一获得了intVal值为100;
2)线程一执行+1,得到101,此时值没有写回给主存;
3)线程二在主存内获得了intVal值为100;
4)线程二执行+1,得到101;
5)线程一写回101;
6)线程二写回101;
于是,最终主存内的intVal值,还是101。具体执行流程如图1-3所示。

图1-2 自增操作的实现原理

图1-3 多线程执行自增操作的结果
为什么volatile的可见性保证在这里没有生效?
根据volatile保证可见性的原理(内存屏障),当一个线程执行写的时候,才会改变“数据修改”的标量,在上述过程中,线程一在执行加法操作发生后,写回操作发生前,CPU开始处理线程二的时间片,执行了另外一次读取intVal,此时intVal值为100,且由于写回操作尚未发生,这一次读取是成功的。
因此,出现了最后计算结果不符合预期的情况。
synchoronized关键字确实可以解决多线程的原子操作问题,可以修改上面的代码为:

但是,这种方式明显效率不高(后面会介绍如何通过CAS来保证原子性),10个线程都在争抢同一个代码块的使用权。
由此可见,volatile只能提供线程安全的两个必要条件:可见性和有序性。