从案例到底层原理,彻底理解volatile可见性和禁止指令重排
共 15015字,需浏览 31分钟
·
2021-04-14 07:24
一. volatile保证可见性
public class TestMain {
private static boolean flag = false;
//private volatile static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
flag = true;
System.out.println("=======循环之前=======");
while (flag) {
}
System.out.println("=======循环之后=======");
}).start();
Thread.sleep(2000);
new Thread(() -> {
System.out.println("修改flag之前...");
System.out.println(flag); // true
flag = false;
System.out.println("修改flag之后...");
System.out.println(flag); // false 上面的线程没有跳出循环
}).start();
}
}
在这里,我们通过一个最简单的例子,来引入可见性。
在运行程序之前,我们先来分析一下上述代码的逻辑,推测一下结果。第1个线程启动后,将flag置为true,然后会陷入死循环。稍后,第2个线程启动后,将flag置为false。按理来说,此时第1个线程应该会跳出死循环才对。但运行结果却不是这样!flag是两个线程的共享变量,但是第2个线程将flag置为false之后,并没有被第1个线程所感知(不可见)。
如何解决这个问题?只需要在用 volatile 来修饰flag,保证flag多个线程之间可见即可。
二. Java内存模型(JMM)
为了更容易理解可见性,有必要简单引入一下JMM(Java Memory Model),对内存模型有大概的抽象了解。
1. JMM(Java Memory Model)
Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,是一种抽象的概念,并不真实存在!它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。它屏蔽掉了底层不同计算机硬件架构下内存的区别。也就是说,JMM 是 JVM 中定义的一种并发编程的底层模型机制。
2. JMM 的抽象示意图
JMM 规定:
所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
由于缓存的存在,就可能会出现以下两种情况而导致缓存不一致:
线程对共享变量的修改没有即时更新到主内存;
线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的;
3. 数据同步的八大原子操作
以上关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成。
简单了解一下即可,方便我们画图来解释上述的第一个例子。
lock (锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
unlock (解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read (读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
load (载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
use (使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
assign (赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
store (存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
write (写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
4. 流程图解释例1
如果对声明了volatile的变量进行写操作,JVM就立即会向处理器发送一条Lock前缀(硬件级别)的指令,立即将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了(总线嗅探机制,这是实现缓存一致性的常见机制)。 当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态。当处理器对这个数据进行修改操作的时候,发现缓存无效,会重新从系统内存中重新读取并更新到缓存。
除了volatile,加锁也能保证变量的内存可见性。因为当一个线程进入 synchronized 代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。除了 synchronized 外,其它锁也能保证变量的内存可见性。
二. volatile无法保证原子性
public class TestMain1 {
public volatile static int i = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for (int k = 0; k < 10; k++) { // 10个线程
new Thread(() -> {
for (int j = 0; j < 100000; j++) { // 10万
// synchronized (TestMain1.class) {
i++;
// }
}
latch.countDown();
}).start(); // 新建线程,并开始执行
}
latch.await(); // 阻塞,直到10个线程全部运行完成
System.out.println(i);
}
}
通过上述例子,可以证明volatile并不保证原子性!
上述代码中,开启了10个线程,每个线程对 i 自增 10 0000(10万)。如果不出现线程安全问题,那么最后的结果应该是 10 * 10 0000 = 100 万。但运行结果总是不足100万,并具有随机性。说明了,代码中出现了线程不安全的问题。
在并发场景下,变量 i 的任何改变都会立即被其他线程所感知,但是如果存在多条线程同时执行i++,仍然会出现线程安全问题。毕竟i++的操作,并不是原子操作。该操作是先读取 i 的值,将 i 加1,然后将新值写回主内存。如果第2个线程在第1个线程 读取旧值 和 写回新值 期间读取 i 的值,那么第2个线程就会与第1个线程一起看到同一个值,并执行相同值的加1操作。因此对于 i++ 这个非原子操作必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这种情况下就完全可以省去volatile修饰变量。
三. volatile禁止指令重排(保证有序性)
1. 通过例子窥探指令重排
我们先通过一个代码例子,来证明一下在底层,是有可能发现指令重排的。
注:程序执行可能要花个十分钟左右才能出结果,因为有100万次循环,而每次循环都要创建线程(这是一个比较费时的操作)
public class TestMain3 {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Set<String> resultSet = new HashSet<>();
for (int i = 0; i < 1000000; i++) { // 100万
x = 0; y = 0;
a = 0; b = 0;
Thread t1 = new Thread(() -> {
a = y; // 1
x = 1; // 2
});
Thread t2 = new Thread(() -> {
b = x; // 3
y = 1; // 4
});
t1.start();
t2.start();
t1.join();
t2.join();
resultSet.add(String.format("a=%d,b=%d", a, b));
}
System.out.println(resultSet);
}
}
分析以下上述代码。对于每一次循环,a和b可能的值如下:
a=0,b=0:此时代码的执行顺序可能是这样,① a=y,② b=x,③ x=1,④ y=1
a=0,b=1:此时代码的执行顺序可能是这样,① a=y,② x=1,③ b=x,④ y=1
a=1,b=0:此时代码的执行顺序可能是这样,① b=x,② y=1,③ a=y,④ x=1
a=1,b=1:从代码上来看,是不可能出现的。因为从代码上来看,代码1 先于 代码2,代码3 先于 代码4。这两个先后次序,是我们从代码中可以直观看出来的。上面3种情况,代码的执行顺序都蕴含了这两种先后次序!a为1,说明y必然为1(代码4必然执行了),由于我们认为 “代码3 先于 代码4”,所以代码3必然已经提前执行完了,那么b应该为0,不可能为1。
所以我们如果认为 “代码1 先于 代码2,代码3 先于 代码4”,那么就不可能会出现 a=1,b=1 的情况。但是程序运行结果却出现了这种情况,说明了底层发生了指令重排!
2. 指令重排
Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
从 Java 源代码到最终执行的指令序列,会分别经历下面3种重排序:
int a = 0;
// 线程 A
a = 1; // 1
flag = true; // 2
// 线程 B
if (flag) { // 3
int i = a; // 4
}
单看上面的程序好像没有问题,最后 i 的值是 1。但是为了提高性能,编译器和处理器常常会在不改变数据依赖的情况下对指令做重排序。假设线程 A 在执行时被重排序成先执行代码 2,再执行代码 1;而线程 B 在线程 A 执行完代码 2 后,读取了 flag 变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,那么 i 最后的值是 0,导致执行结果不正确。那么如何程序执行结果正确呢?这里仍然可以使用 volatile 关键字。
这个例子中, 使用 volatile 不仅保证了变量的内存可见性,还禁止了指令的重排序,即保证了 volatile 修饰的变量编译后的顺序与程序的执行顺序一样。那么使用 volatile 修饰 flag 变量后,在线程 A 中,保证了代码 1 的执行顺序一定在代码 2 之前。
3. as-if-serial语义
不管怎么重排序,单线程下程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
4. happens-before原则
只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦。幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:
程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
传递性:A先于B ,B先于C 那么A必然先于C
线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
对象终结规则:对象的构造函数执行,结束先于finalize()方法
5. 内存屏障
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏(Barrier),是一个CPU指令,它的作用有两个:
如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说禁止在内存屏障前后的指令执行重排序优化。
强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本
6. JMM提供的4种内存屏障指令
由于硬件层面的内存屏障的实现,不同的硬件架构,对应有不同的机器指令。JMM为了屏蔽了这种底层硬件平台的差异,提供了四类内存屏障指令,来为不同的硬件架构生成相应的内存屏障的机器码。
7. volatile的内存语义及其实现
volatile关键字的内存语义如下:
【可见性】保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
【有序性】禁止指令重排序优化。
Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile内存语义,JMM针对编译器制定的volatile重排序规则表
举例来说,第二行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
从上图可以看出:
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读或写时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
在每个volatile写操作的前面插入一个StoreStore屏障。禁止上面的普通写和下面的volatile写重排序。
在每个volatile写操作的后面插入一个StoreLoad屏障。防止上面的volatile写与下面可能有的volatile读/写重排序。
在每个volatile读操作的后面插入一个LoadLoad屏障。禁止上面的volatile读和下面所有的普通读操作重排序。
在每个volatile读操作的后面插入一个LoadStore屏障。禁止上面的volatile读和下面所有的普通写操作重排序。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。【在理解4种屏障指令的含义,应该也容易理解为什么要这么插入。之后也会有例子来帮助理解】
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图
上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图
上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。、
下面通过具体的示例代码进行说明。
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}
}
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化。
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障。
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,图3-21 中除最后的StoreLoad屏障外,其他的屏障都会被省略。
前面保守策略下的volatile读和写,在X86处理器平台可以优化成如下图所示。前文提到过,X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需 在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在 X86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
四. 阿里巴巴Java开发手册对饿汉式单例模式的规范
public class DoubleCheckLock {
// 阿里巴巴Java开发手册建议在该变量前加上volatile修饰
private volatile static DoubleCheckLock instance;
private DoubleCheckLock(){}
public static DoubleCheckLock getInstance(){
//第一次检测
if (instance==null){
//同步
synchronized (DoubleCheckLock.class){
if (instance == null){
//多线程环境下可能会出现问题的地方
instance = new DoubleCheckLock();
}
}
}
return instance;
}
}
出处:https://blog.csdn.net/qq_43290318/article/details/115588218
关注GitHub今日热榜,专注挖掘好用的开发工具,致力于分享优质高效的工具、资源、插件等,助力开发者成长!
点个在看 你最好看