Java 中的 volatile
Contents
volatile 是轻量级的 synchronized,其在多处理器中保证了共享变量的可见性
,即当一个线程修改一个共享变量时,另一个线程会读到这个修改的值。但 volatile 不能保证原子性
(例如自增操作),详细的说明将会在下文进行展开。保证并发程序正确执行还需要做到有序性
,满足以上三个原则,才能够正确的执行并发程序。而本文将对 volatile 关键字进行详细解释,结合具体例子的同时说明 volatile 关键字的使用场景。
操作系统的内存模型
CPU 在执行指令的时候,往往会从主存中获取临时数据,由于 CPU 执行指令的速度要快于主存,所以在读取临时数据的时候需要频繁的从主存中对数据进行读取和写入,由于速度不匹配的原因,势必会降低指令的执行速度。因此,就用到了高速缓存
。
以i = i + 1;
语句为例,在程序运行的时候,会先从主存中读取 i 的值,然后将 i 复制到高速缓存中,其次 CPU 获取到 i 的值之后执行累加指令对 i 进行加 1 操作,然后将结果写入高速缓存,最后将高速缓存中新的值 i 刷新到主存中。
在单线程下没有什么问题,但在多线程环境下,由于每个线程都有属于自己的高速缓存,如果此时每个线程都将自己高速缓存中的数据刷新到主存中的话,就有可能造成缓存数据不一致问题。而这些线程共同操作的变量就属于共享变量
。
为了解决缓存不一致性问题,在硬件层面上通常来说有以下两种解决方法:
-
- 通过在总线加 LOCK# 锁的方式(在软件层面,效果等价于使用 synchronized 关键字);
-
- 通过缓存一致性协议(在软件层面,效果等价于使用 volatile 关键字)。
对于在总线加 LOCK# 锁的方式,由于 CPU 和其他部件是通过总线进行通信的,如果对总线加锁的话,则会阻塞 CPU 对其他部件的访问,无异于效率低下。而缓存一致性协议可以解决此问题,该协议保证了每个 CPU 中的缓存里面的共享变量的副本是一致的。即当 CPU 进行写操作时,如果发现其操作的变量是共享变量,则会对含有此共享变量副本的其他 CPU 发送信号通知,其他 CPU 将该变量的缓存行设置为无效状态。因此,其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
JMM
Java 中的内存模型可以屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,其定义了程序中变量的访问规则。在 JMM 中也会存在缓存一致性和指令重排问题。
JMM 规定所有的变量都是存储在主存
中,而每个线程都有自己的本地内存
。每个线程对变量的所有操作都必须在本地内存中进行,不能对主存进行操作,并且每个线程不能访问其他线程的本地内存。对于语句i = 1;
,线程必须在自己的本地内存对 i 进行赋值操作,然后再写入到主存中,而不是直接将 1 写入到主存中。
并发编程的三个原则
原子性
即不可被中断的一个或一系列操作,要么全部执行并且执行的过程中不会被任何操作给打断,要么就都不执行。
对于下面四条语句,只有第一条语句才是原子操作,其它语句均不符合原子操作。第一条语句仅有一个操作,那就是线程会将数值 10 直接写入到内存中;第二条语句包含两个操作:读取 x 的值以及将 x 值写入内存中。剩下的两条语句执行的操作是一样的,都是首先获取 x 的值,然后执行加 1 操作,最后再将 x 写回内存。
|
|
注意:只有简单的读取、赋值才是原子操作,但为什么像第二、第三、第四条那样混合的语句就不属于原子操作呢?因为,在多线程并发的情况下,会存在多个线程对共享变量进行读改写
的操作(如 i++),多个线程同时从各自的缓存中读取变量,分别进行加 1 操作,然后又分别刷新(写回)到内存中,这导致的结果是:共享变量的值是不确定的,等到其它线程再去读取该值的时候,就会发生数据不一致问题。所以在多线程情况下,可以通过使用 synchronized 关键字和 Lock 来实现原子操作,即能够保证任一时刻只有一个线程执行该代码块,此时就保证了原子性。
可见性
即当多个线程访问同一个共享变量时,假如一个线程修改了这个变量的值,那么其他线程能够立即看得到修改的值。
如下面的例子所示,当线程 1 执行到i = 10;
时,会将 i 的初始值加载到 CPU1 的本地内存中,然后赋值为 10,CPU1 中的本地内存中的 i 就变成了 10,此时还没有立即写回到主存中。而线程 2 执行j = i;
时,会先去主存中读取 i 的值并加载到 CPU2 的缓存中,但是此时主存中的 i 还是之前的 0,那么 j 就会被赋值为 0,而不是 10。
|
|
产生上面的原因是因为:线程 1 在自己的本地内存中修改了 i 值之后,没有立即写回到主存中,从而造成线程 2 没有立即看到
线程 1 修改后的值。
当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
此外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性。
有序性
即程序会按照代码的先后顺序执行。下面结合具体的例子,来看一下在 JVM 中是否真的是按照代码的顺序执行的。
|
|
正常情况下,第三条语句会在第四条语句前执行,这也符合按照代码顺序执行的要求,但也会发生第三条语句在第四条语句之后执行的情况,此时称为指令重排。为了提高程序的执行效率,处理器可能会对代码进行优化,它不能保证程序中各个语句的执行先后顺序同代码中的顺序一致,但会保证程序最终执行结果和代码顺序执行的结果是一致的(单线程情况下)。
通过上面的例子可以看出,第三条语句与第四条语句,不管哪条语句先执行,其对程序最后的结果不会造成影响。而下面的例子就不同了:
|
|
其中,第一条语句和第二条语句可以互换,而第三条语句和第四条语句不能互换,即不能实现指令重排。这是因为指令重排需要考虑到指令之间的数据依赖性。也就是说,执行语句b = a * a;
的时候,需要用到指令a = a + 3;
中的a
。所以,第三条语句一定会在第四条语句之前执行。
在多线程环境下,指令重排会影响程序的执行结果,如下所示:
|
|
此时,由于语句 1 和语句 2 互不依赖,因此可能会发生指令重排。假如发生了重排序,在线程 1执行过程中先执行语句2,而此时 线程2会以为初始化工作已经完成,那么就会跳出 while 循环 ,去执行 doSomethingwithconfig(context) 方法,而此时 context 并没有被初始化,就会导致程序出错。
在 JMM(Java 内存模型)中,可通过 volatile 关键字来保证一定的有序性。如果满足happends-before
原则,则可以保证有序执行。具体可分为以下类别:
- 程序次序规则
- 锁定规则
- volatile 变量规则
- 传递规则
- 线程启动、中断、终结规则
- 对象终结规则
程序次序规则是指:在一个线程内,按照代码的顺序,书写在前面的操作先行发生于书写在后面的操作。这条规则对于单线程来说是适用的,但对于多线程来说,无法保证程序的正确执行。因为,JVM 可能会对不存在数据依赖的指令进行重排操作。
锁定规则是指:在单线程或多线程中,对同一个锁来说,如果处于被锁定状态,则需要进行解锁后才能再次加锁。
volatile 变量规则指:如果一个线程进行写一个变量,然后线程进行读取,那么写入操作肯定是会在读操作之前发生的。
volatile 关键字
volatile 关键字可以确保两方面的内容:
- 保证了多个线程共同操作共享变量的可见性,即一个线程修改了共享变量的值,则对其它线程来说是立即可见的。
- 禁止指令重排。
下面通过一个例子进行说明。如下所示:
|
|
线程 1 在运行的时候,会将 stop 拷贝一份放进自己的本地内存中,等到线程 2 更改了 stop 后,可能会出现以下两种情况:
- 线程 2 对变量的修改没有立即刷新到主存中;
- 即使线程 2 对变量 2 进行了修改,并且也立即刷新到了主存中,但是线程 1 没有立即知道线程 2 对变量的更新而一直循环下去。
这两种情况都会导致线程 1 发生死循环,而引入了 volatile 关键字后,就不会发生上面的问题,而是:
- 被 volatile 关键字修饰后的共享变量会强制修改的值立即写入主存;
- 使用 volatile 关键字的话,线程 2 对共享变量进行修改时,会导致线程 1 的本地内存中的变量 stop 缓存行无效;
- 由于线程 1 的本地内存中所缓存的 stop 变量无效,所以等到线程 1 想要再次读取变量 stop 的值的时候,会去主存中读取。
看如下实例:
|
|
若直接执行上述程序,而不对共享变量flag
使用 volatile 修饰的话,则等待两秒后,程序会出现死循环,如下所示:
若在使用 volatile 关键字修饰flag
的情况下运行此程序,由于保证了可见性,则程序可以正确结束,如下所示:
若不适用关键字 volatile 修饰flag
,而是分别添加语句 1、语句 2、语句 3,则得到的结果都是一样的,均可正常结束。也就是说:除了 volatile 可以保证可见性之外,synchronized 也可以保证可见性。因为每次运行 synchronized 块或者 synchronized 方法的时候,都会导致线程的本地内存与主存同步,使得其他线程可以取得共享变量的新值。即 synchronized 的语义范围不但包括 volatile 具有的可见性,也包含原子性,但不能禁止指令重排。
对于语句 1 来说,其源码内部含有被 synchronized 修饰的同步操作。
通过上面的例子,可以看到 volatile 关键字可以确保共享变量的可见性。下面再来看一看其是否支持原子性。
|
|
运行上述程序,得到的结果每次都是小于 10000 的数,这是因为:虽然 volatile 能够保证可见性,但不能保证原子性。可见性只能保证每次读取的是最新的值,但是 volatile 没法保证对变量操作的原子性。
由于自增操作count++;
不具备原子性,其包括读取原始值
、进行加 1 操作
和写入本地内存
三个原子操作。因此可能会发生如下场景:
- 假如某个时刻,count 的值为 10,线程 1 对变量进行自增操作;
- 线程 1 读取了 count 的原始值为 10,然后线程 1 就被阻塞了;
- 此时,线程 2 对 count 进行自增操作,线程 2 去读取 count 的原始值;
- 由于线程 1 只对变量 count 进行读取操作,而没有对变量进行修改操作,所以不会导致线程 2 的本地内存中缓存的 count 缓存行无效,所以线程 2 直接去主存中读取 count 的值,发现 count 的值是 10,然后进行加 1 操作;
- 此时,线程 2 只是执行了 count+1 操作,还没有将值写入到线程 2 的本地内存中;
- 此时线程 2 被阻塞,线程 1 进行加 1 操作,由于 count 之前的值仍然是 10,所以线程 2 进行加 1 操作后变成 11 写入到本地内存中,然后刷新到主存中;
- 虽然线程 1 能感受到线程 2 对 count 的修改,但是由于线程 1 只剩下对 count 的写操作了,而不需要进行读操作了;
- 因此,线程 2 对 count 的修改并不能影响到线程 1。于是线程 1 也将 11 写入本地内存然后并刷新到主存中;
- 此时的情况就是,两个线程分别进行了一次 自增操作后,count 只增加了 1。
要想保证多个线程对 count++ 操作正常的执行,只需要在addCount()
方法上添加 synchronized 以及 static 即可。如下所示:
添加 static 是为了确保 synchronized 与 static 锁的就是 Mythread.class 对象。
|
|
总结
通过具体的例子可以看到,volatile 无法替代 synchronized 关键字,因为 synchronized 能够防止多个线程同时执行一段代码段,而 volatile 仅能修饰共享变量而不能修饰代码段。其次,volatile 不能保证操作的原子性。
因此,volatile 关键字能够保证操作的可见性,在多线程环境下能够及时感知共享变量的修改,使得其他线程可以立即得到变量的最新值。volatile 是 synchronized 的轻量级实现,比 synchronized 的性能要好。
synchronized 能够保证操作的原子性
以及可见性
,但不能保证有序性(禁止指令重排)
;volatile 能够保证操作的可见性
以及有序性(禁止指令重排)
,但不能保证操作的原子性
。
参考
https://blog.csdn.net/justloveyou_/article/details/53672005
https://www.cnblogs.com/dolphin0520/p/3920373.html