目录
volatile是Java虚拟机提供的轻量级的同步机制。
volatile关键字有如下两个作用
保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
禁止指令重排序优化。
volatile的可见性
关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中
public class VolatileVisibilitySample {
private boolean initFlag = false;
static Object object = new Object();
public void refresh(){
this.initFlag = true; //普通写操作,(volatile写)
String threadname = Thread.currentThread().getName();
System.out.println("线程:"+threadname+":修改共享变量initFlag");
}
public void load(){
String threadname = Thread.currentThread().getName();
int i = 0;
while (!initFlag){
synchronized (object){
i++;
}
//i++;
}
System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i);
}
public static void main(String[] args){
VolatileVisibilitySample sample = new VolatileVisibilitySample();
Thread threadA = new Thread(()->{
sample.refresh();
},"threadA");
Thread threadB = new Thread(()->{
sample.load();
},"threadB");
threadB.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
threadA.start();
}
}
线程A改变initFlag属性之后,线程B马上感知到
volatile无法保证原子性
/**
* volatile无法保证原子性
*/
public class VolatileAtomicSample {
private static volatile int counter = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
for (int j = 0; j < 1000; j++) {
counter++; //不是一个原子操作
}
});
thread.start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter);
}
}
在并发场景下,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时 调用increase()方法的话,就会出现线程安全问题
毕竟i++;操作并不具备原子性,该操作是 先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一 个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一 个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使 用synchronized修饰,以便保证线程安全
需要注意的是一旦使用synchronized修饰方法 后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就 完全可以省去volatile修饰变量。
volatile禁止重排优化
volatile是如何实 现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行 顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
由于编译 器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器 和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏 障禁止在内存屏障前后的指令执行重排序优化。
Memory Barrier的另外一个作用是强制刷出 各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
总之, volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化
volatile内存语义的实现
重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM 会分别限制这两种类型的重排序类型。 下图是JMM针对编译器制定的volatile重排序规则表。
举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或 写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
从上图可以看出: 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来 禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数 几乎不可能。为此,JMM采取保守策略。
下面是基于保守策略的JMM内存屏障插入策略。
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得 到正确的volatile内存语义。
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图
上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
LoadStore 屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不 改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障
public class VolatileBarrierExample {
int a;
volatile int m1 = 1;
volatile int m2 = 2;
void readAndWrite() {
int i = m1; // 第一个volatile读
int j = m2; // 第二个volatile读
a = i + j; // 普通写
m1 = i + 1; // 第一个volatile写
m2 = j * 2; // 第二个 volatile写
}
}
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即 return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见, 编译器通常会在这里插 入一个StoreLoad屏障。
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内 存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为 例,图中除最后的StoreLoad屏障外,其他的屏障都会被省略。
前面保守策略下的volatile读和写,在X86处理器平台可以优化成如下图所示。
X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。
在X86中, JMM仅需 在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存 语义。
这意味着在 X86处理器中,volatile写的开销比volatile读的开销会大很多(因为 执行StoreLoad屏障开销会比 较大)。
个人公众号,日常分享一个知识点,每天进步一点点,面试不慌