内存可见性
volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销,倘若能恰当的合理的使用volatile,自然是美事一桩。
为了能比较清晰彻底的理解volatile,我们一步一步来分析。首先来看看如下代码
public class volatileDemo1 {
static boolean flag = true;
public static void main(String[] args) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t -----come in");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
},"t1").start();
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t -----come in");
while (flag) {
}
System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
},"t2").start();
}
}
上面这个例子,模拟在多线程环境里,t1线程对flag共享变量修改的值能否被t2可见,即是否输出 “-----flag被设置为false,程序停止” 这句话?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vzNZ5vt5-1656404046754)(JUC并发编程.assets/image-20220628160812458.png)]
答案是:NO! 输出结果如下~
这个结论会让人有些疑惑,可以理解。因为倘若在单线程模型里,因为先行发生原则之happens-before,自然是可以正确保证输出的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量flag来说,线程t1的修改,对于线程t2来讲,是"不可见"的。也就是说,线程t2此时可能无法观测到flage已被修改为false。那么什么是可见性呢?
所谓可见性,是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更,JMM规定了所有的变量都存储在主内存中。很显然,上述的例子中是没有办法做到内存可见性的。
volatile的内存语义
所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取,从而保证了可见性。
volatile变量有2大特点,分别是:
可见性
有序性:禁重排!
重排序是指编译器和处理器为了优化程序性能面对指令序列进行重新排序的一种手段,有时候会改变程序予以的先后顺序。
但重排后的指令绝对不能改变原有串行语义!
那么volatile凭什么可以保证可见性和有序性呢??
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
Java中的内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性。
粗分主要是以下两种屏障:
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话:对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读。
让我们来看看源码:sun.misc.Unsafe.java
主要包括以上三个方法,接着在对应的Unsafe.cpp 源码中查看:
在底层C++代码中发现其底层调用的是OrderAccess类中的方法~
我们发现其又细分了四种屏障,四大屏障分别是什么意思呢?
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 保证Load的读取操作在load2及后续读取操作之前执行 |
StoreStore | Store1;StoreStore;Store2 | 在Store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
LoadStore | Load1;LoadLoad;Store2 | 在Store2及其后的写操作执行前,保证Load1的读操作已读取结束 |
StoreLoad | Store1;StoreStore;Load2 | 保证Store1的写操作已刷新到主内存之后,Load2及其后的读操作才能执行 |
接下来结合底层linux_86代码来分析~
给大家讲解一下上表,主要有以下三种情况不允许重拍~
蓝色:当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
红色:当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会重排序到volatile写之后。
绿色:当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。
其他情况都允许被重排。
可见性案例
public class volatileDemo1 {
static volatile boolean flag = true;
public static void main(String[] args) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t -----come in");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
},"t1").start();
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t -----come in");
while (flag) {
}
System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
},"t2").start();
}
}
若不加volatile修饰为何t2 看不到被 t1线程修改为 false的flag的值?
使用volatile修饰共享变量后,被volatile修饰的变量有以下特点:
无原子性案例
首先我们先编写一个用 synchronized 修饰的案例:
class MyNumber {
int number;
public synchronized void addPlusPlus() {
number++;
}
}
在我们的main方法中开启一个线程执行 number++的方法,然后等待2秒,大家的预期值是不是1000呢?
public class volatileDemo2 {
public static void main(String[] args) {
MyNumber myNumber = new MyNumber();
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int i1 = 0; i1 < 1000; i1++) {
myNumber.addPlusPlus();
}
}).start();
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(myNumber.number);
}
}
结果是一致的,因为使用了 synchronized 修饰了number++方法,从而保证了原子性
接下来 使用 volatile修饰number~
class MyNumber {
volatile int number;
public void addPlusPlus() {
number++;
}
}
那为什么会出现不预期的结果呢?
对于volatile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅是数据加载时是最新的。但是在多线程环境下,“数据计算” 和 “数据赋值” 操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读主内存的最新值,操作出现丢失问题。即 各线程工作内存和主内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步。
禁重排
public class volatileDemo3 {
int i = 0;
volatile boolean flag = false;
public void write() {
if (flag) {
System.out.println("---i=" + i);
}
}
}
在本案例中 变量i 和 flag 语句的执行顺序如果被重排的话就会影响结果,存在数据依赖关系,禁止重排序。
说了这么多,那么在什么时候使用 volatile 呢?
这里要提提 DCL双端锁,小编最近面试有被问到~在接下来的博客里给大家谈谈单例模式