被volatile修饰的变量有两大特点
可见性
写完后 立即刷新回主内存并及时发出通知,大家可以去主内存拿最新版,前面的修改对后面所有线程可见
有序性
不存在数据依赖关系,可以重排序
存在数据依赖关系,禁止重排序
但重排后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑!
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值 立即刷新回主内存中
当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新会到主内存中读取最新共享变量
所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取
volatile为什么可以保证可见性和有序性
内存屏障 Memory Barrier
内存屏障(也成为内存栅栏,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会 要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些你内存屏障指令,volatile实现了Java内存模型中的可见性和有序性(禁重排), 但volatile无法保证原子性。
内存屏障之前 的所有 写操作 都要回写到主内存
内存屏障之后 的所有 读操作 都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)
写屏障(Store Memory Barrier) :告诉处理器在写屏障之前将所有的存储在缓存(store bufferes)中的数据同步到主内存。也就是说当看到Store屏障指令,就必须把该指令之前所有写入指令执行完毕后才能继续往下执行。
读屏障(Load Memory Barrier) :处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说在Load屏障指令之后就能够保证后面的读取数据指令一定能够读取到最新的数据。
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话:对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读
粗分为2种
读屏障(Load Barrier):在读指令之前插入读屏障,让工作内存或CPU高速缓存当中的缓存数据失效,重新回到主内存中获取最新数据
写屏障(Store Barrier):在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatitle读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatitle写 | 可以重排 | 不可以重排 | 不可以重排 |
当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile 读之后的操作不会被重排到volatile读之前。
当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile 写之前的操作不会被重排到volatile写之后。
当第一个操作为volatile写时,第二个操作为volatile读时,不能重排
读屏障:在每个volatile读操作的后面插入一个LoadLoad屏障,在每个读操作的后面插入一个LoadStore屏障
写屏障:在每个volatile写操作的后面插入一个StoreStore屏障,在每个写倒错的后面插入一个StroeLoad屏障
JMM将内存屏障插入策略分为4中规则
volatile特性
说明:保证不同线程对某个变量完成操作后结果及时可见,即该共享变量一旦改变所有线程立即可见。
保证可见性
示例
//static boolean flag = true; static volatile boolean flag = true; public static void main(String[] args) { new Thread(() -> { System.out.println(Thread.currentThread().getName()+"\t -----come in"); while(flag) { } System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止"); },"t1").start(); //暂停几秒钟线程 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } flag = false; System.out.println(Thread.currentThread().getName()+"\t 修改完成flag: "+flag); }
上述代码:不加volatile,没有可见性,程序无法停止
加了volatile,保证可见性,程序可以停止
程序无法停止的问题可能:
主线程修改了flag之后没有将其刷新到主内存,所以t1线程看不到
主线程将flag刷新到了主内存,但是t1一直读取的是自己工作内存中flag的值,没有去主内存中更新获取flag最新的值(自己工作内存是每个线程私有的,主内存是共享内存)
想得到的结果:
线程中修改了自己工作内存中的副本之后,立即将其刷新到主内存
工作内存中每次读取工作变量时,都去主内存中重新读取,然后拷贝到工作内存
解决:
使用volatile修饰共享变量,就可以达到上面的效果,被volatile修改的变量有以下特点:
线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存
线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存
Java内存模型中定义的8种每个线程自己的工作内存 与主物理内存之间的原子操作
read(读取)→load(加载)→use(使用)→assign(赋值)→stroe(存储)→write(写入)→lock(锁定)→unlock(解锁)
read: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
load:作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
use:作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
store:作用于工作内存,将赋值完毕的工作变量的值写回给主内存
write:作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述6条只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令
lock:作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程
unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
没有原子性
class MyNumber { volatile int number; public void addPlusPlus() //synchronized { number++; } } public class VolatileNoAtomicDemo { public static void main(String[] args) { MyNumber myNumber = new MyNumber(); for (int i = 1; i <=10; i++) { new Thread(() -> { for (int j = 1; j <=1000; j++) { myNumber.addPlusPlus(); } },String.valueOf(i)).start(); } //暂停几秒钟线程 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(myNumber.number); //正确结果应该为:10000,实际输出却不是 } }
不加锁的情况下,当线程1对主内存对象发起read操作到write操作第一套流程的时间里,线程2随时都有可能对这个主内存对象发起第二套操作
对于volatile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是新的,也仅是数据加载时是新的。但是多线程环境下,”数据计算“ 和 ”数据赋值“ 操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读主内存最新值(本身具备可见性),操作出现写丢失问题,即各线程私有内存和主内存公共内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步。
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
public void add(){ i++; //不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分3步完成 }
如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全问题,因此对于add方法必须使用synchronzed修饰以便保证线程安全。
volatile变量不适合参与到依赖当前值的运算
指令禁重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序,不存在数据依赖关系,可以重排序
存在数据依赖关系,禁止重排序
但重拍后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑!
重排序的分类和执行流程
数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。
重排前 | 重排后 |
int a = 1; //第一句 int b = 2; //第二句 int c = a + b; //3 |
int b = 2; //第一句 int a = 1; //第二句 int c = a + b; //3 |
结论:假设编译器调整了语句的顺序,但是不影响程序的最终结果。 | 重排序OK |
下面的示例:若存在数据依赖关系,禁止重排序。重排序发生后,会导致程序运行结果不同
- | 代码 | 说明 |
写后读 | a = 1; b = a; |
写一个变量之后,再读这个变量 |
写后写 | a = 1; a = 2; |
写一个变量之后,再写这个变量 |
读后写 | a = b; b = 1; |
读一个变量之后,再写这个变量 |
volatile正确使用
单一赋值可以,但是含复合运算赋值不可以(i++之类)
// 单一赋值 volatile int a = 10; volatile boolean flag = false;
状态标志:判断业务是否结束
private volatile static boolean flag = true; public static void main(String[] args) { new Thread(()->{ while (flag){ //do something } },"t1").start();; try {Thread.sleep(2L);} catch (InterruptedException e) {throw new RuntimeException(e);} new Thread(()->{ flag = false; },"t2").start();; }
开销较低的读,写锁策略
/** * 使用:当读远多于写,结合使用内部锁和volatile变量来减少同步的开销 * 理由:利用volatile保证读取操作的可见性,利用synchronized保证复合操作的原子性 */ public class Counter{ private volatile int value; public int getValue(){ return value; //利用volatile保证读取操作的可见性 } public synchronized int intcrement(){ return value++; //利用synchronized保证复合操作的原子性 } }
DCL双端锁的发布(double-checked-locking)
多线程下的单例模式
volatile总结
1)volatile可见性
2)volatile没有原子性
3)volatile禁重排
4)volatile关键字系统底层加入内存屏障,两者是如何关联的
5)内存屏障是什么
6)内存屏障作用
7)内存屏障四大指令
3句话总结
1)volatile写之前的操作,都禁止重排序到volatile之后
2)volatile读之后的操作,都禁止重排序到volatile之前
3)volatile写之后volatile读,禁止重排序