在学习volatile之前,我们需要先了解下线程在java内存里面执行的原理:
每个线程获取到CPU的时钟区间之后,会从ready状态->running状态,在x86处理器下,每个线程在执行的时候,不会直接读取主内存,而是会在每个CPU的高速缓存里面读取数据,每次CPU在执行线程的时候,会将需要的数据从主内存读取到高速缓存中。
堆内存是线程共享的,对于上图中的running变量可称之为共享变量,而线程在工作时有自己的工作内存,对于共享变量running来说,线程1和线程2在运行的时候先把running变量从CPU的高速缓存中copy到自己工作内存,对这个变量的改变都是在线程自己的工作内存中,并不会直接的反映到其他线程,而在多核CPU的情况下,如果一个CPU进行了计算,然而其他CPU里面的高速缓存数据还是旧的,那么就会导致计算出错(脏数据)的情况。
上面扯了那么多意思就是:上述所说的共享变量也就是类变量,存在堆内存中,而堆内存是线程共享的。当类加载的时候,共享变量先加载到主内存中,然后CPU他会主动做一个判定,哪些变量可能会被使用到,就会将数据从主内存中加载到CPU的高速缓存中(感兴趣的同学可以了解下CPU的高速缓存,了解CPU是如何判定数据常用),然后线程得到cpu,线程会从CPU高速缓存中读取数据到自己的工作内存中。变量值发生改变是在线程的工作内存中,然后会被刷到CPU的高速缓存中,进而CPU会将数据刷到主内存中。
由于线程自己的工作内存,对变量做操作,将值的结果刷到CPU高速缓存,在刷到主内存中-------->这一个过程其余线程是不知道的。那么就会产生下面这样一个问题。
示例: 共享变量int running = 0; 线程中的操作: running=running+1;
线程1和线程2都同时获取到running = 0,因为线程没有获取到cpu都处于就绪状态,当线程1得到cpu执行了 running=running+1; 此时running=1,并将结果刷进了主内存中。而此时还没有得到cpu的线程2加载的running变量依旧是0 ======> 产生了脏数据,怎么解决?
volatile:可见性
为了避免这种情况,加上volatile,running变量改变其他线程很快就会知道,这就是线程的可见性,保证多个CPU之间的高速缓存是一致的,OS里面会有一个缓存一致性协议,volatile就是通过OS的缓存一致性策略来保持共享变量在多个线程之间的可见性。
缓存一致性:每个CPU会在总线上面有一个嗅探器,当一个CPU将高速缓存的内容写到主内存时候,每个CPU会去查看自己缓存里面的缓存行对应的内存地址的值是否被修改了,
如果发现被修改了,会将缓存里面的数据设为无效,当处理器要对自身告诉缓存里面的这个数据进行修改,会强制重新从系统主内存读取数据进来之后再去修改
就上述的示例,当running加上volatile后,线程1改变running的值后,将数据刷到主内存的时候,线程2得到的cpu中的数据会发生实时更新,拿到了running=1而不是0。
但是有这么一个局限性:
就是线程1 改变了running=1,最终将结果刷进主内存中了,根据缓存一致性协议,线程2起初得到的cpu中的高速缓存数据无效并且做了实时更新,但是在CPU高速缓存更新数据之前,线程2已经从cpu高速缓存加载到了running=0,这时候线程2在做 running的计算刷进主内中,这样子就会有数据的丢失,也就是说volatile并不能保证原子性。
使用场景:
(1)对变量的写操作不依赖于当前值。
(2)该变量没有包含在具有其他变量的不变式中。
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
所以可以看出,实际上volatile作为只保证可见性的并发策略,只适用于独立的不依赖于当前值的变量,一般来说是只能适合于Boolean变量并且是独立的与其他互不相关的Boolean变量。
volatile可见性实现原理:
volatile修饰变量时,发现add前面加个一个lock指令,如何不加volatile修饰,是没有lock的。
lock指令在多核处理器下会引发下面的事件:
将当前处理器的缓存行的数据写回到系统内存,同时使其他CPU里缓存了该内存地址的数据置为无效。
为了提高处理速度,处理器一般不直接和内存通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作完成后并不知道处理器何时将缓存数据写回到内存。但如果对加了volatile修饰的变量进行写操作,JVM就会向处理器发送一条lock前缀的指令,将这个变量在缓存行的数据写回到系统内存。这时只是写回到系统内存,但其他处理器的缓存行中的数据还是旧的,要使其他处理器缓存行的数据也是新写回的系统内存的数据,就需要实现缓存一致性协议。即在一个处理器将自己缓存行的数据写回到系统内存后,其他的每个处理器就会通过嗅探在总线上传播的数据来检查自己缓存的数据是否已过期,当处理器发现自己缓存行对应的内存地址的数据被修改后,就会将自己缓存行缓存的数据设置为无效,当处理器要对这个数据进行修改操作的时候,会重新从系统内存中把数据读取到自己的缓存行,重新缓存。
volatile:有序性
实际上,当我们把代码写好之后,虚拟机不一定会按照我们写的代码的顺序来执行。例如对于下面的两句代码:
int a = 1;
int b = 2;
对于这两句代码,你会发现无论是先执行a = 1还是执行b = 2,都不会对a,b最终的值造成影响。所以虚拟机在编译的时候,是有可能把他们进行重排序的。
为什么要进行重排序呢?
你想啊,假如执行 int a = 1这句代码需要100ms的时间,但执行int b = 2这句代码需要1ms的时间,并且先执行哪句代码并不会对a,b最终的值造成影响。那当然是先执行int b = 2这句代码了。
所以,虚拟机在进行代码编译优化的时候,对于那些改变顺序之后不会对最终变量的值造成影响的代码,是有可能将他们进行重排序的。
更多代码编译优化可以看我写的另一篇文章:
虚拟机在运行期对代码的优化策略
那么重排序之后真的不会对代码造成影响吗?
实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题的。具体请看下面的代码
public class NoVisibility{
private static boolean ready;
private static int number;
private static class Reader extends Thread{
public void run(){
while(!ready){
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args){
new Reader().start();
number = 42;
ready = true;
}
}
这段代码最终打印的一定是42吗?如果没有重排序的话,打印的确实会是42,但如果number = 42和ready = true被进行了重排序,颠倒了顺序,那么就有可能打印出0了,而不是42。(因为number的初始值会是0).。。。。。。。反正这个demo我在网上找的,我没运行出效果来..
因此,重排序是有可能导致线程安全问题的。
如果一个变量被声明volatile的话,那么这个变量不会被进行重排序,也就是说,虚拟机会保证这个变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。
例如把上面中的number声明为volatile,那么number = 42一定会比ready = true先执行。
不过这里需要注意的是,虚拟机只是保证这个变量之前的代码一定比它先执行,但并没有保证这个变量之前的代码不可以重排序。之后的也一样。
volatile有序性的实现原理:
volatile有序性的保证就是通过禁止指令重排序来实现的。指令重排序包括编译器和处理器重排序,JMM会分别限制这两种指令重排序。
那么禁止指令重排序又是如何实现的呢?答案是加内存屏障。JMM为volatile加内存屏障有以下4种情况:
- 在每个volatile写操作的前面插入一个StoreStore屏障,防止写volatile与后面的写操作重排序。
- 在每个volatile写操作的后面插入一个StoreLoad屏障,防止写volatile与后面的读操作重排序。
- 在每个volatile读操作的后面插入一个LoadLoad屏障,防止读volatile与后面的读操作重排序。
- 在每个volatile读操作的后面插入一个LoadStore屏障,防止读volatile与后面的写操作重排序。
上述内存屏障的插入策略是非常保守的,比如一个volatile的写操作后面需要加上StoreStore和StoreLoad屏障,但这个写volatile后面可能并没有读操作,因此理论上只加上StoreStore屏障就可以,的确,有的处理器就是这么做的。但JMM这种保守的内存屏障插入策略能够保证在任意的处理器平台,volatile变量都是有序的。
volatile关键字能够保证代码的有序性,这个也是volatile关键字的作用。
总结一下,一个被volatile声明的变量主要有以下两种特性保证保证线程安全。
- 可见性。
- 有序性。
总结下:volatile可见性的实现就是借助了CPU的lock指令,通过在写volatile的机器指令前加上lock前缀,使写volatile具有以下两个原则:
- 写volatile时处理器会将缓存写回到主内存。
- 一个处理器的缓存写回到内存会导致其他处理器的缓存失效。
volatile真的能完全保证一个变量的线程安全吗?
我们通过上面的讲解,发现volatile关键字还是挺有用的,不但能够保证变量的可见性,还能保证代码的有序性。
那么,它真的能够保证一个变量在多线程环境下都能被正确的使用吗?
答案是否定的。原因是因为Java里面的运算并非是原子操作。
原子操作
原子操作:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
也就是说,处理器要嘛把这组操作全部执行完,中间不允许被其他操作所打断,要嘛这组操作不要执行。
刚才说Java里面的运行并非是原子操作。我举个例子,例如这句代码
int a = b + 1;
处理器在处理代码的时候,需要处理以下三个操作:
- 从内存中读取b的值。
- 进行a = b + 1这个运算
- 把a的值写回到内存中
而这三个操作处理器是不一定就会连续执行的,有可能执行了第一个操作之后,处理器就跑去执行别的操作的。
证明volatile无法保证线程安全的例子
由于Java中的运算并非是原子操作,所以导致volatile声明的变量无法保证线程安全。
对于这句话,我给大家举个例子。代码如下: 举例 x++
package cn.com.boco.HermesService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo {
static volatile Integer x = 0;
static class TestThread1 implements Runnable {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
x++;
System.out.println("thread1 is " + "time is " + i + "result is " + x);
}
}
}
static class TestThread2 implements Runnable {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
x++;
System.out.println("thread2 is " + "time is " + i + "result is " + x);
}
}
}
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
service.execute(new TestThread1());
service.execute(new TestThread2());
service.shutdown();
}
}
结果 :
右边那个红色的框是最后一个输出,理论上应该输出2000 但是并没有,出现了数据丢失。
什么情况下volatile能够保证线程安全
刚才虽然说,volatile关键字不一定能够保证线程安全的问题,其实,在大多数情况下volatile还是可以保证变量的线程安全问题的。所以,在满足以下两个条件的情况下,volatile就能保证变量的线程安全问题:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他状态变量共同参与不变约束。
volatile+CAS可以保证原子性....感兴趣可以了解下