【JUC】volatile和JMM
文章目录
- 【JUC】volatile和JMM
- 1. volatile
- 1.1 特点
- 1.2 内存语义
- 2. 内存屏障
- 2.1 分类
- 2.2 什么叫保证有序性?
- 2.3 内存屏障的4种插入策略
- 3. volatile特性
- 3.1 保证可见性
- 3.2 volatile读写过程
- 3.3 没有原子性
- 3.4 指令禁重排(有序性)
- 4. 正确使用volatile
- 4.1 单一赋值
- 4.2 状态标志
- 4.3 开销较低的读、写锁策略
- 4.4 DCL双端锁
- 5. 总结
1. volatile
1.1 特点
被volatile关键字修饰的变量有2大特点:
- 可见性
- 有序性(禁重排)
1.2 内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。
- 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
2. 内存屏障
内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。
内存屏障之前的所有写操作都要回写到主内存,
内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
- 写屏障(Store Memory Barrier):告诉处理器在写屏障之前将所有存储在缓存(store bufferes)中的数据同步到主内存。也就是说当看到Store屏障指令,就必须把该指令之前的所有写入指令执行完毕才能继续往下执行。
- 读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说在Load屏障指令之后就能够保证后整免得读取数据指令一定能够读取到最新的数据。
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。
一句话:对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读,也叫写后读。
2.1 分类
内存屏障可粗分为2种:
- 写屏障(Store Memory Barrier)
- 读屏障(Load Memory Barrier)
还可细分为以下4种:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 保证Load1的读取操作在Load2及后续读取操作之前执行 |
StoreStore | Store1;StoreStore;Store2 | 在Store2及其后的写操作执行前,保证Store1的写操作已刷新到主内存 |
LoadStore | Load1;LoadStore;Store2 | 在Store2及其后的写操作执行前,保证Load1的读操作已读取结束 |
StoreLoad | Store1;StoreLoad;Load2 | 保证Store1的写操作已刷新到主内存之后,Load2及其后的读操作才能执行 |
2.2 什么叫保证有序性?
禁重排,通过内存屏障禁重排。
- 重排序有可能影响程序的执行和实现,因此,我们有时候希望告诉JVM不要重排序。
- 对于编译器的重排序,JMM会根据重排序的规则,禁止特定类型的编译器重排序。
- 对于处理器的重排序,Java编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器排序。
happens-before之volatile变量规则
第一个操作 | 第二个操作:普通写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可重排 | 可重排 | 不可重排 |
volatile读 | 不可重排 | 不可重排 | 不可重排 |
volatile写 | 可重排 | 不可重排 | 不可重排 |
- 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
- 当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后。
- 当地一个操作为volatile写时,第二个操作为volatile读时,不能重排。
2.3 内存屏障的4种插入策略
读屏障:
- 在每个volatile读操作的后面插入一个
LoadLoad
屏障 - 在每个volatile读操作的后面插入一个
LoadStore
屏障
- 在每个volatile读操作的后面插入一个
LoadLoad
屏障,禁止处理器把上面的volatile读与下面的普通读重排序。 - 在每个volatile读操作的后面插入一个
LoadStore
屏障,禁止处理器把上面的volatile读与下面的普通写重排序。
写屏障:
- 在每个volatile写操作的前面插入一个
StoreStore
屏障 - 在每个volatile写操作的后面插入一个
SotreLoad
屏障
- 在每个volatile写操作的前面插入一个
StoreStore
屏障,可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。 - 在每个volatile写操作的后面插入一个
StoreLoad
屏障,作用是避免volatile写与后面可能有的volatile读/写操作重排序。
3. volatile特性
3.1 保证可见性
说明:保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见。
示例:
不加volatile,没有可见性,程序无法停止
static 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(1); } catch (InterruptedException e) { e.printStackTrace(); }
flag = false;
System.out.println(Thread.currentThread().getName()+"\t 修改完成flag: "+flag);
}
运行结果:
加了volatile,保证可见性,程序可以停止
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(1); } catch (InterruptedException e) { e.printStackTrace(); }
flag = false;
System.out.println(Thread.currentThread().getName()+"\t 修改完成flag: "+flag);
}
运行结果:
volatile特点:
- 线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存
- 线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存
3.2 volatile读写过程
Java内存模型中定义的8种工作内存与主内存之间的原子操作
read(读取)→load(加载)→use(使用)→assign(赋值)→store(存储)→write(写入)→lock(锁定)→unlock(解锁)
read: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
load: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
store: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存
write: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令:
lock: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
3.3 没有原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
volatile变量的复合操作(如i++)不具有原子性
class MyNumber
{
volatile int number;
public void addPlusPlus()
{
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);
}
}
运行结果如下:
事实上 i++
由三条指令组成,分别是获取i的值、计算i的值、为i赋值,所以 i++
不具备原子性。
如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于add方法必须使用synchronized修饰,以便保证线程安全.
多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,即操作非原子。若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致。
对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。
由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步
3.4 指令禁重排(有序性)
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序.
不存在数据依赖关系,可以重排序;存在数据依赖关系,禁止重排序
但重排后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑!
重排序的分类和执行流程如下:
- 编译器优化的重排序: 编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序
- 指令级并行的重排序: 处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行
数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。
示例:
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write(){
i = 2;
flag = true;
}
public void read(){
if(flag){
System.out.println("---i = " + i);
}
}
}
分析:
-
在
write()
方法中有一个普通写和一个volatile写。所以在volatile写之前插入一个StoreStore
屏障,之后插入一个StoreLoad
屏障。 -
在
read()
方法中有一个volatile读和一个普通读。所以在volatile读之后插入一个LoadLoad
屏障和LoadStore
屏障。
4. 正确使用volatile
使用volatile的几种场景:
- 单一赋值,不包括符合运算符(如i++之类)
- 状态标志,判断业务是否结束
- 开销较低的读、写锁策略
- DCL双端锁的发布
4.1 单一赋值
示例:
volatile int a = 10;
volatile boolean flag = false
4.2 状态标志
示例:
private volatile static boolean flag = true;
public static void main(String[] args)
{
new Thread(() -> {
while(flag) {
//do something......
}
},"t1").start();
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(2L); } catch (InterruptedException e) { e.printStackTrace(); }
new Thread(() -> {
flag = false;
},"t2").start();
}
4.3 开销较低的读、写锁策略
示例:
public class UseVolatileDemo
{
/**
* 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
* 理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
*/
public class Counter
{
private volatile int value;
public int getValue(){
return value; //利用volatile保证读取操作的可见性
}
public synchronized int increment(){
return value++; //利用synchronized保证复合操作的原子性
}
}
}
4.4 DCL双端锁
public class SafeDoubleCheckSingleton
{
//通过volatile声明,实现线程安全的延迟初始化。
private volatile static SafeDoubleCheckSingleton singleton;
//私有化构造方法
private SafeDoubleCheckSingleton(){
}
//双重锁设计
public static SafeDoubleCheckSingleton getInstance(){
if (singleton == null){
//1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
synchronized (SafeDoubleCheckSingleton.class){
if (singleton == null){
//隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
//原理:利用volatile,禁止 "初始化对象"(2) 和 "设置singleton指向内存空间"(3) 的重排序
singleton = new SafeDoubleCheckSingleton();
}
}
}
//2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
return singleton;
}
}
创建对象分为三步:
- 分配对象的内存空间
- 初始化对象
- 设置变量指向刚分配的内存地址
不加volatile关键字的话重排序之后可能变成1->3->2的顺序。
5. 总结
- volatile可见性
- volatile没有原子性
- volatile禁重排
- 内存屏障是什么
- 是一种屏障指令,它使得CPU或编译器对屏障指令的前和后所发出的内存操作执行一个排序的约束。
- 内存屏障能干嘛
- 阻止屏障两边的指令重排序
- 写数据时加入屏障,强制将线程私有工作内存的数据刷回主物理内存
- 读数据时加入屏障,线程私有工作内存的数据失效,重新到主物理内存中获取最新数据
- 内存屏障四大指令
- 在每一个volatile写操作前面插入一个StoreStore屏障
- 在每一个volatile写操作后面插入一个StoreLoad屏障
- 在每一个volatile读操作后面插入一个LoadLoad屏障
- 在每一个volatile读操作后面插入一个LoadStore屏障
- 三句话总结
- volatile写之前的操作,都禁止重排序到volatile之后
- volatile读之后的操作,都禁止重排序到volatile之前
- volatile写之后volatile读,禁止重排序