一、是什么
volatile是Java的一个关键字,是Java提供的一种轻量级的同步机制,
二、能做什么
保证了不同线程对这个变量进行操作时的可见性,有序性。
三、可见性
可见性主要是指一个线程修改了共享变量的值,另一个线程可以看见。但是每一个线程都是要自己的工作内存,那么要如何实现线程之间的可见内?使用volatile关键字就可以有效的解决可见性问题。下面用一个例子来解释一下线程可见性的问题。
public class VolatileDemo {static boolean flag = false;public static void main(String[] args) {//启用一个线程,如果主线程修改该布尔值为true则退出循环,如果线程中对于修改的值不可间,那么程序会一直在循环中new Thread(() -> {System.out.println(Thread.currentThread().getName() + "线程启动,falg为" + flag);while (!flag) {}System.out.println(Thread.currentThread().getName() + "退出循环");},"t1").start();try {TimeUnit.SECONDS.sleep(2);} catch (InterruptedException e) {throw new RuntimeException(e);}//main线程把布尔值修改为trueflag = true;System.out.println(Thread.currentThread().getName()+"修改flag为" +flag);}
}
从上面的代码可以知道如果t1线程可以知道main线程的修改,那么t1线程中的for循环就可以正常退出,如果main线程的修改t1不可见,那么t1线程的循环就无法退出。如果我们在flag变量添加volatile关键字,如下所示。
public class VolatileDemo {//添加volatile关键字static volatile boolean flag = false;public static void main(String[] args) {//启用一个线程,如果主线程修改该布尔值为true则退出循环,如果线程中对于修改的值不可间,那么程序会一直在循环中new Thread(() -> {System.out.println(Thread.currentThread().getName() + "线程启动,falg为" + flag);while (!flag) {}System.out.println(Thread.currentThread().getName() + "退出循环");},"t1").start();try {TimeUnit.SECONDS.sleep(2);} catch (InterruptedException e) {throw new RuntimeException(e);}//main线程把布尔值修改为trueflag = true;System.out.println(Thread.currentThread().getName()+"修改flag为" +flag);}
}
那么没有加volatile关键字线程t1中为何看不到被主线程main修改false的flag的值?
可能原因
- 主线程修改了flag之后没有将其刷新到主内存所以t1线程看不到。
- 主线程将flag刷新到了主内存,但是t1一直读取的是自己工作内存中的flag的值,没有去主内存中更新获取flag最新的值。
使用volatile修饰共享变量有以下特点
- 线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存。
- 线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存。
四、有序性
指令重排
为了提高性能,在遵守 as-if-serial
语义(即不管怎么重排序,单线程下程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守。)的情况下,编译器和处理器常常会对指令做重排序。
一般重排序可以分为如下三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在
数据依赖性
,处理器可以改变语句对应机器指令的执行顺序。 - 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
重排序的执行流程为执行流程:
数据依赖性
若两个操作访问同一变量,且这两个操作中有一个为写操作,此两个操作间就存在数据依赖性。
下面用两个案列来说明什么是数据依赖性
public class volatileDemo01 {public static void main(String[] args) {int a = 1;int b = 2;int c = a + b;System.out.println(c);}
}
// 重排后的代码
public class volatileDemo01 {public static void main(String[] args) {int b = 2;int a = 1;int c = a + b;System.out.println(c);}
}
变量a和变量b调换位置,无论怎么调换都不会影响程序的最终结果所以就不存在数据依赖性。
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a=1;b=a; | 写一个变量之后,再读这个位置 |
写后写 | a=1;a=2; | 写一个变量之后,再写这个变量 |
读后写 | a=b;b=1; | 读一个变量之后,再写这个变量 |
上面三种情况是存在数据依赖关系的,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
内存屏障
为了实现 volatile 内存语义(即内存可见性),JMM 会限制特定类型的编译器和处理器重排序。为此,JMM 针对编译器制定了 volatile 重排序规则表,如下所示:
是否重排序 | 第二次操作普通读/写 | 第二次操作volatile读 | 第二次操作volatile写 |
---|---|---|---|
第一次操作普通读/写 | 是 | 是 | 否 |
第一次操作volatile读 | 否 | 否 | 否 |
第一次操作volatile写 | 是 | 否 | 否 |
上面表格的内容可以总结为以下3点
- 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了
volatile读之后的操作不会被重排到volatile之前
。 - 当第二个操作为volatile写是,不论第一个操作是什么,都不能重排序。这个操作保证了
volatile写之前的操作不会被重排到volatile之后
。 - 当第一个操作为volatile写是,第二个操作为volatile读时,不能重排。
内存屏障是一组处理器指令,它的作用是禁止指令重排序和解决内存有序性的问题
。
JMM把内存屏障指令分为四类
-
在每一个volatile写操作前面插入一个StoreStore屏障:StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已刷新到主内存。
-
在每一个volatile写操作后面插入一个StoreLoad屏障:StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。
-
在每一个volatile读操作后面插入一个LoadLoad屏障:LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
-
在每一个volatile读操作后面插入一个LoadStore屏障:LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
class VolatileTest{int i=0;// 没有加volatile多线程的情况下会发生指令重排boolean flag = false;public void set(){i=2;flag=true;}public void get(){if (flag){System.out.println(i);}}
}
加上volatile该程序就变成线程安全的程序了,我们分析以下这个代码。
class VolatileTest{int i=0;// 没有加volatile多线程的情况下会发生指令重排boolean flag = false;public void set(){i=2;flag=true;}public void get(){if (flag){System.out.println(i);}}
}
左边是set方法的分析,右边是get方法的分析。因为给flag添加了volatile关键字,所以当对于flag的读写都会添加相应的屏障,在每一个volatile写操作后面都会插入一个StoreLoad屏障,volatile写不能与后面可能有的volatile读/写操作重排序
,volatile前面插入一个StoreStore屏障,可以保证在volatile写之前,其前面的所有普通写操作都已刷新到主内存
。volatile读后面会添加LoadLoad屏障和LoadStore屏障。LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序
,LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序
。
五、无原子性
下面我将用一个例子来说明volatile的无原子性
class Number {volatile int num = 0;public void add(){num++;}
}
public class volatileDemo01 {public static void main(String[] args) {Number number = new Number();for (int i = 0; i < 10; i++) {new Thread(()->{for (int j = 0; j < 1000; j++) {number.add();}}).start();}try {TimeUnit.SECONDS.sleep(2);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println(number.num);}
}
上面这段代码如果是线程安全的话就会输出10000,但是由于volatile并不能保证原子性所以程序的输出结果每次基本上都不一样。
对于volatile变量具备可见性,JVM只是保证从主内存加载到线程工作内存值是最新的,也仅是数据加载时最新的。但是多线程环境下,“数据计算”和“数据赋值”操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读内存最新值,操作出现写丢失问题。各线程私有内存和主内存公共内存中变量不同步,进而导致数据不一致
。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步
。
六、使用
volatile的运用
-
当读远多于写,结合使用内部锁和volatile变量来减少同步的开销。
public class UseVolatileDemo {private volatile int value;// 利用volatile保证读取操作的可见性public int getValue() {return value;}// 利用synchronized保证复合操作的原子性public synchronized int incrementAndGet() {return value++;} }
-
状态标志,判断业务是否结束。
public class VolatileDemo {static volatile boolean flag = false;public static void main(String[] args) {//启用一个线程,如果主线程修改该布尔值为true则退出循环,如果线程中对于修改的值不可间,那么程序会一直在循环中new Thread(() -> {System.out.println(Thread.currentThread().getName() + "线程启动,falg为" + flag);while (!flag) {}System.out.println(Thread.currentThread().getName() + "退出循环");},"t1").start();try {TimeUnit.SECONDS.sleep(2);} catch (InterruptedException e) {throw new RuntimeException(e);}//main线程把布尔值修改为trueflag = true;System.out.println(Thread.currentThread().getName()+"修改flag为" +flag);} }
-
DCL双端锁的发布。
public class SafeDoubleCheckSingleton {private volatile static SafeDoubleCheckSingleton singleton;public SafeDoubleCheckSingleton() {}// 双重锁设计public static SafeDoubleCheckSingleton getInstance() {if (singleton == null) {synchronized (SafeDoubleCheckSingleton.class) {if (singleton == null) {// 利用volatile,禁止“初始化对象(2)”和“设置singleton指向内存空间(3)"的重排序singleton = new SafeDoubleCheckSingleton();}}}return singleton;} }
如果没有volatile在多线程的环境下该单列模式可能会产生线程安全问题。
使用限制
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。