文章目录
volatile的特点
- 保证可见性
- 不保证原子性
- 禁止指令重排序(是保证
有序性
、可见性的原因)
1. 保证可见性
-
使用volatile关键字修饰的变量,保证了其在多线程之间的可见性,即每次读取到volatile变量,一定是最新的数据。
-
为什么volatile可以保证可见性?
- 现实中,为了获取更好的性能,JVM可能会对指令进行重排序,重排序不会改变单线程的结果,但多线程下可能会出现一些意想不到的问题。使用volatile则会禁止语义重排序(具体原理见下文),即保证先行发生原则,当然这也一定程度上降低了代码执行效率。
-
验证volatile可见性的实验代码如下:
private static void testVisibility() {
MyData mydata = new MyData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
try {
Thread.sleep(3000);
// TimeUnit.SECONDS.sleep(3);
}catch (InterruptedException e){
e.printStackTrace();
}
mydata.addTo60();
System.out.println(Thread.currentThread().getName() + "\t updated value: " + mydata.number);
}, "aaa").start();
int b = 0;
while (mydata.number == 0){
b++;//这个写在这不会影响实验结果
// System.out.println(Thread.activeCount());
// System.out.println("test");//println是个同步方法,可能会影响实验结果,所以不能写在这,具体原因 todo
// System.out.println(Thread.currentThread().getName() + ": "+mydata.number);
}
System.out.println(Thread.currentThread().getName() + "\t over");
}
- MyData类如下:
public class MyData {
// volatile public int number = 0;
public int number = 0;
// public AtomicInteger atomicInteger = new AtomicInteger();
public void addTo60() {
this.number = 60;
}
public void addOne(){
this.number++;
}
public void atomicAddOne(){
atomicInteger.getAndIncrement();
}
}
- 实验结果:如果number不用volatile修饰,主线程一直会在while处循环,加了就不会了
2. 并不能保证原子性
为什么呢?
- 要注意,保证可见性,仅仅是指A线程修改变量后会立刻刷新到主内存
- 但对于
B线程在A刷新之前已经读取了主内存的旧值
这种情况,volatile是没有办法解决的,也就是说volatile不保证原子性 - 验证volatile不保证原子性的代码如下:
private static void testAtomic() throws InterruptedException {
MyData mydata = new MyData();
for (int i = 0; i < 20; i++){
new Thread(() -> {
for (int j = 0; j < 1000; j++){
mydata.addOne();
//mydata.atomicAddOne();
}
}, String.valueOf(i)).start();
}
// Thread.sleep(11000);
while(Thread.activeCount() > 2){//当前活跃线程数大于2时,说明上面的20个线程还未执行完
Thread.yield();
}
System.out.println(Thread.activeCount());
System.out.println(mydata.number);
//System.out.println(mydata.atomicInteger);
}
- 如果volatile保证原子性,那么number的值应该是20000,但是实验结果是 < 20000
- 要解决原子性问题的话,可以使用
AtomicInteger
3. 禁止指令重排序
- 关于指令重排序的概念(参考并发基础)
- 禁止指令重排序是如何做到的?
- 先要了解内存屏障
内存屏障
- 内存屏障(Memory Barrier)是一种CPU指令。也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。 这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。
- 内存屏障的另一个作用是
强制刷出CPU的各种缓存数据,因此CPU上的线程都能读取到这些数据的最新版本
,这就是volatile保证可见性的原因
内存屏障共分为四种类型
- LoadLoad屏障:
- 抽象场景:Load1; LoadLoad; Load2
- Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:
- 抽象场景:Store1; StoreStore; Store2
- Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见
- LoadStore屏障:
- 抽象场景:Load1; LoadStore; Store2
- 在Store2被写入前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:
- 抽象场景:Store1; StoreLoad; Load2
- 在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。
一个变量被volatile修饰后,JVM会做两件事:
- 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
- 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
- 下面的代码中,由于加入了StoreStore屏障,屏障上方的普通写入语句 context = loadContext() 和屏障下方的volatile写入语句 contextReady = true 无法交换顺序,从而成功阻止了指令重排序。
boolean contextReady = false;
在线程A中执行:
context = loadContext();
StoreStore屏障
contextReady = true;
StoreLoad屏障
在线程B中执行:
while( ! contextReady ){
sleep(200);
}
doAfterContextReady (context);
- 内存屏障是
happens-before
原则的一种实现手段。可以这么说:可见性的保证是基于CPU的内存屏障指令,被JSR-133抽象为happens-before原则。 - 补充:volatile可以阻止编译时和运行时的指令重排。编译时JVM编译器遵循内存屏障的约束,运行时依靠CPU屏障指令来阻止重排。
那什么时候适合用volatile呢?
- 运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
volatile 实际应用场景
状态标志
- 比如我们工程中经常用一个变量标识程序是否启动、初始化完成、是否停止等。volatile 很适合只有一个线程修改,其他线程读取的情况。volatile 变量被修改之后,对其他线程立即可见。
- 如果状态的改变没有及时被其他线程读取,可能出问题
懒汉式单例模式
- 使用DCL机制的单例模式仍然不一定是线程安全的,因为有指令重排序的存在,具体如下图
- 使用volatile 修饰可以禁止指令重排,避免了下图这种情况的发生,且保证 singleton 的实例化能够对所有线程立即可见。