转载声明:文章来源:https://blog.csdn.net/weixin_41685207/article/details/109409674
在并发编程中,我们会遇到三个概念:原子性、可见性、有序性。
· 原子性:一个或多个操作为一个整体,要么都执行且不会受到任何因素的干扰而中断,要么都不执行,synchronized 可以保证代码块的原子性。
· 可见性:当多个线程共享同一变量时,若其中一个线程对该共享变量进行了修改,那么这个修改对其他线程是立即可见的。
· 有序性:程序执行的顺序按照代码的先后顺序执行。
1、物理计算机内存模型
在物理机中,为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存中的数据读取到处理器内部的高速缓存中,然后再进行操作,如下图所示。
2、Java 内存模型
Java 内存模型规定了所有的变量都存储在主内存中,同时每条线程都有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的副本,线程对该变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据,不同的线程之间也无法直接访问对方工作内存中的变量,不同线程之间变量值的传递均需要通过主内存来完成,如下图所示。
这里所讲的主内存、工作内存和 Java 堆、栈、方法区等并不是同一个层次的堆内存的划分,如果非要勉强对应起来的话,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应虚拟机栈中部分区域。
volatile 主要有两个作用:
1. volatile 保证了可见性
2. volatile 禁止指令重排,保证了有序性
下面我们来逐条分析其实现原理
1. volatile 如何保证可见性?
Java 内存模型对 volatile 变量定义的特殊规则如下所示(V 表示一个 volatile 变量):
· 在工作内存中,每次使用 V 前必须先从主内存刷新最新的值,用于保证能看见其他线程对 V 所做的修改。
· 在工作内存中,每次修改 V 后都必须立刻同步回主内存中,用于保证其他线程可以看到当前线程对 V 所做的修改。
· volatile 所修饰的变量不能被指令重排序优化,从而保证代码的执行顺序和编写顺序相同。
上述三个规则中的前两个保证了 volatile 变量的可见性(第三条规则保证了 volatile 变量的有序性)。
volatile 虽然保证了可见性,但在并发情况下它仍然可能是线程不安全的,因此在不符合以下两条规则的运算场景中,我们仍需要通过加锁(使用 sychronized、java.util.concurrent 中的锁或原子类)来保证原子性:
· 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
· 变量不需要与其他的状态变量共同参与不变约束。
下面我们来从更底层的角度来看一下 volatile 是如何保证可见性的:如果对声明了 volatile 的变量执行写操作,JVM 会向处理器发送一条 Lock 前缀指令,该指令在多核处理器下会引发两件事情:
· 将当前处理器缓存行的数据写回到系统内存。
· 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
下面我们来详细说明一下 Lock 前缀指令引发的两件事情。
· Lock 前缀指令会引起处理器缓存回写到内存中。Lock 前缀指令在执行指令期间,会声言处理器的 LOCK# 信号,在多处理器环境中,LOCK# 信号会确保在声言该信号期间,处理器可以独占任何共享内存。比较老的处理器通过 LOCK# 信号锁总线来达到独占共享内存的目的;但现在的处理器不会声言 LOCK# 信号,而是会锁定这块内存区域的缓存并回写到内存,同时使用缓存一致性机制来确保修改的原子性,这一操作被称为缓存锁定,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域。
· 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。处理器通过 EMSI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。
2. volatile 如何保证有序性?
volatile 关键字会禁止指令重排,它有两层语义:
· 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作肯定已经全部进行,且结果对后面的操作已经可见,并且在其后面的操作肯定还没有进行。
· 不能将 volatile 变量后面的语句放在其前面执行,也不能把 volatile 变量前面的语句放到其后面执行。
上面这两句话可能有些抽象,我们举一个例子,代码如下:
/**
* x,y 为非 volatile 变量
* flag 为 volatile 变量
*/
x = 1; // 语句一
y = 2; // 语句二
flag = 3; // 语句三
x = 4; // 语句四
y = 5; // 语句五
上述代码在进行指令重排时有如下限制:
· 语句一和语句二不能重排到语句三的后面,但语句一和语句二可以在语句三前面进行重排
· 语句四和语句五不能重排到语句三的前面,但语句四和语句五可以在语句三后面进行重排
· 当执行到语句三时,语句一和语句二必须已经执行完毕,且执行结果对语句三、语句四和语句五可见
· 当执行到语句三时,语句四和语句五必须都还未执行
1. 双重检测锁实现单例模式
代码如下:
public class Singleton {
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这里使用 volatile 关键字的作用是禁止指令重排,上述代码中 instance = new Singleton() 实际上不是原子性操作,可以拆分为以下三个步骤:
memory = allocate(); //1 分配对象的内存空间
initInstance(memory); //2 初始化对象
instance = memory; //3 设置 instance 指向刚分配的内存地址
如果没有 volatile 修饰变量 instance 的话,上述伪代码的顺序就可能变为:
memory = allocate(); //1 分配对象的内存空间
instance = memory; //3 设置 instance 指向刚分配的内存地址(此时对象还未初始化)
initInstance(memory); //2 初始化对象
这样的话返回的可能就是还未初始化的对象,有可能会造成程序运行错误。
而如果加上了 volatile 的话,因为 volatile 具有禁止指令重排的作用,对象就可以正常进行初始化了。
2. 状态标记量
下面代码使用了 volatile 保证可见性的作用
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
下面代码使用了 volatile 禁止指令重排的作用
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);