线程安全性之可见性有序性
文章目录
以下是学习中的一些笔记记录,如果有错误希望得到你的指正~ 同时也希望对你有点帮助~
线程可见性问题
public class ThreadVisibilityClass {
//static boolean flag = false;
static volatile boolean flag = false;
public static void main(String[] args) throws Exception{
new Thread(() -> {
int i = 0;
//活性失效
while (!flag){
i++;
}
System.out.println("i = " + i);
}, "Thread-Sub").start();
TimeUnit.SECONDS.sleep(1L);
//在main线程中去修改flag的值,没有做其他控制的话,子线程Thread-Sub中无法感知flag被main线程修改
flag = true;
System.out.println("main is end...");
System.out.println(Thread.currentThread().getName());
}
}
上面就是一个简单的可见性的例子,简单说就是Main线程修改了共享变量的值,Thread-Sub线程却不能及时读取到Main线程修改的最新值,这就是可见性问题
了解可见性本质
导致可见性的根本原因跟下面几方面优化有着密切关系
- CPU增加高速缓存
- 操作系统中,增加线程,进程 → 通过CPU时间片切换,提升CPU利用率
- 编译器(Jvm的深度优化)
CPU高速缓存
- 缓存行,是CPU和内存交互的最小工作单元,x86系统中,每个缓存行64个字节,CPU在读取内存数据的时候是以块的方式来读取,一次会读取64字节大小的数据,读取64个字节大小的原因其中之一是因为空间局部性原理,CPU会认为这64个字节的数据是接下来会使用到的数据,所以一次性读取,这也是提高CPU效率的一种方式
CPU高速缓存带来的问题
-
伪共享问题
- CPU的高速缓存是由多个缓存行来组成的
当CPU 一次从内存中读取了 x , y , z 缓存到CPU缓存中,假设 core0 核心只需要修改 x 的值,假设 core1 核心只需要修改 y ,当core0 核心获得执行权的时候,就会使得 core1中缓存的x, y, z失效,当core1 获得执行权的时候也会使得 core0 的执行权失效,来回反复使得效率很低,这就说明在多线程环境下,没有引入对齐填充会使得效率很低。
引入了对齐填充那么将会读取x 的时候自动填充满 64个字节,读取y 的时候自动填充64个字节,这就不会使得 core0 和 core1 中的缓存来回往复失效,这是一种空间换取时间的思想
/** * CPU高速缓存带来的伪共享问题 模拟多个CPU读取同一个缓存 * * @author zdp * @date 2022-05-06 16:35 */ public class CacheLineExampleClass implements Runnable { private int arrayIndex = 0; public final static long ITERATIONS = 500L * 1000L * 100L; private static ValueNoPadding[] longs; public CacheLineExampleClass(final int arrayIndex) { this.arrayIndex = arrayIndex; } public static void main(final String[] args) throws Exception { //循环创建数组,看消耗时长 for (int i = 1; i < 10; i++) { System.gc(); final long start = System.currentTimeMillis(); runTest(i); System.out.println(i + " Threads, duration = " + (System.currentTimeMillis() - start)); } } private static void runTest(int threadNums) throws InterruptedException { Thread[] threads = new Thread[threadNums]; longs = new ValueNoPadding[threadNums]; for (int i = 0; i < longs.length; i++) { longs[i] = new ValueNoPadding(); } for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new CacheLineExampleClass(i)); } for (Thread t : threads) { t.start(); } for (Thread t : threads) { t.join(); } } @Override public void run() { long i = ITERATIONS + 1; //每个线程去修改 对齐填充类中的 value 变量值 while (0 != --i) { longs[arrayIndex].value = 0L; } } /** * 对齐填充 一个缓存行 64 字节,long类型8字节,声明8个类型的long变量即可填充满一个缓存行 */ public final static class ValuePadding { protected long p1, p2, p3, p4, p5, p6, p7; protected volatile long value = 0L; protected long p9, p10, p11, p12, p13, p14; protected long p15; } /** * 未对齐填充 Contended 注解配合Jvm参数 -XX:-RestrictContended 使用才会生效 */ @Contended public final static class ValueNoPadding { //protected long p1, p2, p3, p4, p5, p6, p7; //8字节 protected volatile long value = 0L; } } //对齐填充效果 1 Threads, duration = 343 2 Threads, duration = 347 3 Threads, duration = 318 4 Threads, duration = 371 5 Threads, duration = 322 6 Threads, duration = 488 7 Threads, duration = 473 8 Threads, duration = 494 9 Threads, duration = 507 //未对齐填充效果 1 Threads, duration = 320 2 Threads, duration = 1965 3 Threads, duration = 1852 4 Threads, duration = 2390 5 Threads, duration = 3495 6 Threads, duration = 3919 7 Threads, duration = 3473 8 Threads, duration = 3226 9 Threads, duration = 2873
造成未对齐填充效率较低的原因就是就是CPU之间不断争抢导致各个CPU之间缓存不断失效,导致每次都从内存重新加载,从而使得效率非常低,对齐填充就解决了这个伪共享问题
- CPU的高速缓存是由多个缓存行来组成的
-
缓存一致性问题
在Processor 2 中修改了 CPU缓存中的值,而在 Processor 0中却未能及时可见-
总线锁思想
- CPU和外界的交互都需要通过Bus总线,在Bus总线上加上一个互斥锁,可以避免缓存一致性的问题,但是总线锁却违背了多核CPU的概念,因为在总线上加锁,同一时刻只能一个CPU来进行操作,其他CPU资源就浪费了
-
缓存锁思想 (缓存一致性协议)
-
在上述图例中,就相当于只针对于 X = 20 这个变量来进行加锁,当其他CPU也需要对该变量进行操作的时候,基于缓存一致性协议来保证数据的一致性,跟总线锁的思想相同,只是锁的粒度不一样,效率也更高了
-
缓存一致性协议
MESI
,MOSI
不同CPU实现不一样,主流的是MESI
协议- M(Modify): 共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
- E(Exclusive):缓存的独占状态,数据只缓存在当前CPU缓存中,且没有被修改
- S(Share):数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
- I (Invalid): 缓存已经失效
在CPU的缓存行中,每一个Cache一定会处于以下三种状态之一
- Shared
- Exclusive
- Invalid
CPU对于读的请求来说,M、E、S状态是可以直接读取的,I 状态下需要从内存中读取
CPU对于写的请求来说,M、E 状态是可以直接写的,S状态下,需要把其他CPU变成I 失效状态才可以写
各个CPU间如何实现状态的修改
- 每个CPU都会基于Snoopy嗅探协议来监听总线上的事件,当CPU接收到消息后,就会做出相应的处理
-
-
总结,在各个CPU之间,基于缓存锁,总线锁来保证数据的一致性(解决可见性),但是这个锁怎么加上的呢?在底层其实是会有一个汇编#Lock的指令,它会根据CPU的架构来决定加总线锁还是缓存锁,如果不支持缓存锁就会加总线锁(由CPU决定),那么加上这个Lock指令后就解决了多线程中的可见性问题,也就是说我们在使用Volatile 关键字的时候,底层最终会使用#Lock的汇编指令来解决可见性问题
可以通过汇编指令打印工具,将上述可见性 Demo 打印,可以发现,在加Volatile 关键字后,确实会在底层加上一个Lock的汇编指令
从CPU层面了解有序性问题
/**
* 线程安全-有序性
*
* @author zdp
* @date 2022-05-05 17:37
*/
public class ThreadOrderClass {
private static int x = 0,y = 0;
private static int a = 0,b = 0;
/**
* 指令重排序
*
* @author zdp
* @date 2022-05-05 17:55
*/
public static void main(String[] args) throws Exception{
int i = 0;
for (;;){
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
if(x == 0 && y ==0){
System.out.println("第" + i + "次(" + x + ", " + y + ")");
break;
}
}
}
}
指令重排序和可见性问题是两种问题,但是Volatile 可以基于#Lock指令来解决这两种问题
CPU层面的指令重排序
CPU层面和Jvm层面,优化执行的执行顺序
-
CPU层面是如何导致指令重排序的
前言:
在CPU的优化过程中,CPU基于
MESI
协议,如果CPU0
想去更改某个变量,然后发送失效指令给其他CPU时,等其他的CPU都接受到指令将其置为失效状态,然后CPU0
再更改后的值写到主内存中,然后在其他CPU执行失效指令时,CPU其实是处于空闲状态,这就导致了没有很好的利用CPU资源。为了尽可能的利用好CPU,引入了第一个优化 Store Buffer(异步的思想) :
-
第一个优化 Store Buffer
Store Buffer 图例:
在引入了 Store Buffer 之后,
CPU0
就先将要更改的变量值写到 Store Buffer 中,然后再发送失效指令让其他CPU失效,接着CPU0
继续做其他的事情,等到收到 其他CPU的反馈说失效指令已经执行完了,然后再将值从 Store Buffer 中 取出,再同步到内存中
Store Buffer优化带来的问题 :
正是做的这个Store Buffer异步优化,就是导致指令重排序的源头,看下面一个代码
a = 0; function(){ a = 1; b = a + 1; assert(b == 2);//false }
引入Store Buffer代码执行图例:
将上面代码套入到 Store Buffer 图例当中来看整体执行为什么 导致 b = 2 为 false 在
CPU1
和CPU2
中的缓存中都缓存有 a 变量,此时 a变量为 share 状态
CPU0
开始执行代码 a = 1 时
CPU0
将 a = 1 写入到 Store Buffer 中,然后发送给CPU1
一个 a变量的失效指令,CPU0
继续做其他事情,CPU1
基于嗅探协议接收到该失效指令返回一个失效答复invalidate ack
(相当于说CPU1
通知CPU0
已经接收到该失效指令了)并返回当前CPU1
中a的值,此时CPU0
将缓存行中的数据更新为,a = 0/E (并处于独占状态,因为CPU1
和CPU2
中的a已经失效了嘛)
CPU0
开始执行 b = a + 1; 因为此时在
CPU0
中还没有缓存b变量的值,接着就发起一个 read invalidate b 的指令从内存中读取 到 b = 0,然后b保存到CPU0
的缓存行中,此时b变量在CPU0
中处于独占状态,接着直接运算 b = a + 1; a此时在缓存行中的数据为 0 ,所以b = 1,然后CPU0
将 b运算后的值写入到缓存行 b = 1/M (b处于修改状态),这时,CPU1
通知CPU0
执行完毕,然后CPU0
从Store Buffer中取出 a = 1 更新到CPU0
的缓存行,最终 执行结果为 b =1; 所以由于CPU的异步优化导致指令重排序的问题。
-
第二个优化
Store ForWarding
由于引入Store Buffer优化之后,
CPU0
已经将 a = 1写入到了 Store Buffer 中,但是发送失效指令之后没有从 Store Buffer 中直接读取,上述代码若是CPU0
直接从 Store Buffer中读取,则没有问题,所以引入 Store Forwarding 之后,会直接发送失效指令之后会直接从 Store Buffer 中读取数据Store Forwarding 优化带来的问题,看下面代码:
int a = 0; b = 0; executeToCpu0(){ a = 1; b = 1; } executeToCpu1(){ while(b == 1){ assert(a = =1); } }
引入Store Forwarding 执行上述代码图例:
当两个线程并行执行上述方法CPU0
执行executeToCpu0
方法CPU1
执行executeToCpu1
方法,在CPU0
中独占 b变量,在CPU1
中独占a变量- 当
CPU1
执行 while(b == 1){}指令的时候,在CPU1
中缓存行中不存在b,所以CPU1
发送一个读指令 read b 从其他CPU
缓存行中读取 b,此时CPU1
还没有拿到b的值 CPU0
开始执行 a = 1;的指令。此时在CPU0
中又不存在a,因为a = 1; 是一个写操作,所以CPU0
先将 a = 1 写入到Store Buffer 中,然后发起一个read invalidate a 的失效指令(此时CPU0
还未接收到其他CPU执行指令完成的通知)- 紧接着
CPU0
继续执行 b = 1;因为之前b在CPU0
中是独占状态,所以此时CPU0
执行b = 1会直接修改缓存行中的值 b = 1/M 状态 。 - 执行到这里,此时
CPU1
接收到了 read b 指令的结果,收到了b的值 b = 1;此时CPU0
中的b变为共享状态,因为CPU1
中也用到了b,此时CPU1
执行 b = =1 返回为true; - 接着
CPU1
执行代码 assert(a==1); - 但是此时
CPU1
缓存行中a的值仍然是 0,所以执行 a == 1 返回false; - 此时
CPU1
接收到2步骤发送的 a 失效指令,CPU1
将 a 修改为 I 失效状态 CPU0
收到CPU1
的失效答复,此时 a 只在CPU0
缓存行中存在处于独占状态,然后CPU0
直接从 Store Buffer 中读取 a = 1,CPU0
修改缓存行中的 a = 1
这里导致a = =1为false的原因就是
CPU1
在接收CPU0
发送的a失效指令之前,就读取了CPU1
缓存行中a的值,也就是指令重排序导致的问题,到这里CPU的性能已经得到了极大的优化 - 当
-
第三个优化 Invalid Queue 失效队列
由于Store Buffer 的大小是有限制的,当Store Buffer 满了之后,CPU会处于阻塞状态,所以引入了Invalid Queue,引入Invalid Queue之后,CPU在处理任何
MESI
缓存一致性协议的状态的时候都必须看Invalid Queue中是否有该缓存行的失效消息没有处理,有就需要去处理,但是Invalid Queue还是存在数据不一致的问题,Invalid Queue的引入使得发送失效指令这个接收结果这个过程变成了异步的方式,也就意味着对Store Buffer中的数据处理的更快了Invalid Queue 图例:
还是跟上面一样两个线程并行执行上述方法CPU0
执行executeToCpu0
方法CPU1
执行executeToCpu1
方法,在CPU0
中独占 b变量,共享a变量,在CPU1
中共享a变量- 当
CPU0
执行代码 a = 1;此时CPU0
先将 a = 1 写入到 Store Buffer 中,然后发送一个invalidate a 的失效指令 CPU1
执行代码 while(b == 1){} ,因为CPU1
缓存行中不存在b,所以CPU1
发送一个read b的指令从CPU0
的缓存行中读取- 此时
CPU1
接收到了CPU0
发送过来的a失效指令,CPU1
直接将该指令消息丢到 Invalid Queue 失效队列中 CPU0
收到CPU1
返回的Invalidate ack
,立即读取Store Buffer中a的值,同步到CPU0
的缓存行中,至此CPU0
中 a = 1/M ,并且处于修改状态- 紧接着
CPU0
执行代码 b = 1;因为b在CPU0
中是独占状态,CPU0
可以直接修改b的值 b = 1/M,并且处于修改状态 - 到这,
CPU1
发送的read b 指令,接收到CPU0
的返回,b =1,此时CPU0
中b变更为共享状态 CPU1
执行 b = =1 返回 true ,CPU1
接着执行代码 assert(a == 1)CPU1
在执行 a = =1的时候,由于缓存行中的 a = 0(CPU1
丢入到Invalid Queue失效队列中的 a失效指令还未来得及处理),所以CPU1
执行代码 a = = 1;返回false- 最后
CPU1
处理Invalid Queue中的消息,将a置为失效状态
上述导致代码 assert(a == 1)为false是因为,在处理丢入到Invalid Queue中的消息之前,
CPU1
读取了缓存行中的 a - 当
-
第四个优化 内存屏障指令
在引入Store Buffer/Store Forwarding 之后,解决了多CPU缓存同步带来的CPU阻塞问题(就是基于
MESI
协议,一个变量为共享Share状态,当CPU接收到一个写操作的时候,需要将其他CPU中该变量置为I 失效状态,这个操作在没有引入Store Buffer 之前是同步的操作),但是又带来了指令执行顺序带来的可见性问题(各个CPU之间由于指令重排序导致的执行先后顺序导致 共享变量的 可见性问题(就比如失效队列中消息还未处理,就读取了缓存行中的数据)),为了解决Store Buffer 带来的指令执行顺序带来的可见性问题,因为Store Buffer/ Store Forwarding带来的问题在CPU硬件层面已经无法优化,因此引入了内存屏障指令,不同操作系统 内存屏障指令也不相同-
内存屏障
在
X86/64
系统架构中- 写屏障:
SFence (Save Fence)
,写屏障指令。在SFence
指令前的写操作必须在SFence
指令后的写操作前完成。 - 读屏障:
IFence (Load Fence)
,读屏障指令。在IFence
指令前的读操作必须在IFence
指令后的读操作前完成。 - 全屏障:
MFence (Modify/Mix)
,全屏障指令,在MFence
前的读写操作必须在MFence
指令后的读写操作前完成。
在Linux系统中,将这三种指令分别封装成了,
smp_wmb
-写屏障,smp_rmb
-读屏障,smp_mb
-读写屏障三个方法,调用这些方法就可以出发相应的屏障指令执行 - 写屏障:
前面说到为了解决多线程带来的可见性问题,使用了#Lock指令,而加上Volatile会在底层中加上一个汇编指令#Lock,这里#Lock指令不但能解决多线程带来的可见性问题,它还相当于一个全屏障指令 Full Barrier ,执行时会所主内存子系统来确保执行顺序,所以Volatile 关键字能够解决可见性和有序性问题
那到底这个屏障指令是如何工作的呢?回到上面代码:
int a = 0; b = 0; executeToCpu0(){ a = 1; b = 1; } executeToCpu1(){ while(b == 1){ assert(a = =1); } } //加入屏障指令,也就是在执行a == 1代码之前,必须先完成 a 的读取操作 int a = 0; b = 0; executeToCpu0(){ a = 1; smp_wmb(); b = 1; } executeToCpu1(){ while(b == 1){ smp_rmb(); assert(a = =1); } }
-
-
JMM (Java Memory Model)
模型
由于在不同的CPU架构中,实现内存屏障的指令不同
#Lock
#StoreBuffer
#LoadBuffer
在不同操作系统上都需要保证可见性,所以引入了
JMM
模型,解决不同操作系统之间的差异化
JVM
由于是跨平台的实现,为了解决不同平台上都能达到线程安全的目的,引出了JMM
(Java 内存模型),JMM
就是一种符合内存模型规范,屏蔽了各种硬件和操作系统访问差异,使得Java程序在各种平台下对内存的访问都能保证效果一致的规范
JMM
也是为了解决有序性、可见性问题,JMM
本身并没有解决CPU层面优化带来的可见性、有序性问题。我们平时开发在解决这些问题的时候,是用了volatile、final、synchronized这些关键字,那么这些关键字底层指令是怎么封装的,如何去保证可见性的呢?
-
在CPU层面提供了内存屏障指令,这些内存屏障指令是需要被调用的,在
JMM
里面对CPU层面内存屏障指令做了封装,提供了下面这些统一的内存屏障指令,相当于说在任何操作系统下都使用下面这些统一的内存屏障指令,JVM
相当于用了一个策略模式,针对不同的系统,对于底层的屏障指令进行不同的实现屏障类型 指令示例 说明 LoadLoad Barriers
Load1;LoadLoad;Load2
确保 Load1
数据的装载先于Load2
及所有后续装载指令的装载(这两个读操作不允许重排序)StoreStore Barriers
Store1;StoreStore;Store2
确保 Store1
数据对其他处理器可见(刷新到内存)先于Store2
及所有后续存储指令的存储 (这两个写操作不能重排序)LoadStore Barriers
Load1;LoadStore;Store2
确保 Load1
数据装载先于Store2
及所有后续的存储指令刷新到内存(Load1
的读操作优先于Store2
的写操作)StoreLoad Barriers
Store1;StoreLoad;Load2
确保 Store1
数据对其他处理器变得可见(指刷新到内存)先于Load2
及所有后续装载指令的装载。StoreLoad Barriers
会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令 -
在
x86
结构中CPU层面提供#Lock指令 -
JVM
层面对上述4中屏障指令的实现
inline void OrderAccess::loadload() {
acquire();
}
inline void OrderAccess::storestore() {
elease();
}
inline void OrderAccess::loadstore() {
acquire();
}
inline void OrderAccess::storeload() {
fence();
}
-
orderAccess_linux_x86.inline Linux系统x86 CPU架构
Lock 也是一个内存屏障
inline void OrderAccess::fence() { //如果是多核CPU,会加一个lock指令,锁住缓存行,基于缓存一致性协议来保证可见性 if (os::is_MP()) { // always use locked addl since mfence is sometimes expensive #ifdef AMD64 __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory"); #else __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory"); #endif } }
-
orderAccess_linux_sparc.inline Linux系统sparc CPU架构
#StoreLoad
也是一个内存屏障inline void OrderAccess::fence() { __asm__ volatile ("membar #StoreLoad" : : :); }
不同CPU结构内存指令屏障不同
-
在
JVM
字节指令中可以看出加了volatile关键字之后,flag中会增加一个ACC_VOLATILE
的标识//javap -v VolatileSeeLock.class public static volatile boolean flag; descriptor: Z flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE //在JVM中使用putstatic 修改变量值的时候会去判断变量是否被volatile修饰 if(cache -> is_volatile){ //.... //如果是volatile修饰的变量将会加一个storeload屏障,storeload上面会调用fence()全屏障指令,所以在底层指令中会加入一个#Lock,所以汇编指令也会输出一个lock,storeload指令就保证了变量的写操作一定会对后续操作可见 OrderAccess::storeload(); } //而这个is_volatile的判断是由 flag -> ACC_VALATILE来决定 bool is_volatile () const { return (_flags & JVM_ACC_VOLATILE ) != 0; }
JVM
是一种跨平台实现,为了保证多线程情况下能够在不同平台都保证线程安全性,引出了JMM
,JMM
是一种内存模型规范,屏蔽了不同操作系统之间的差异,保证Java程序在不同平台下的内存访问都能保证一致性。JMM
对不同CPU架构的内存屏障指令做了统一的定义,在JVM
中是怎么实现的呢?在JVM
中也有这四种定义,JVM
在实现这四种规范的时候,针对不同的CPU架构做了不同的实现,也就是针对不同的CPU架构,调用了其相对应的内存屏障指令,达到了操作系统CPU结构下访问内存都能达到一致性
-
加了volatile关键字后,什么情况下会导致重排序
volatile重排序规则表
是否能重排序 第二个操作 第二个操作 第二个操作 第一个操作 普通读/写 volatile读 volatile写 普通读/写 YES YES NO
| volatile读 | NO | NO | NO |
| volatile写 | YES | NO | NO |
举例:
//第一个volatile读操作
volatile int a = 0;
//第二个普通读
int b = 0;
第一个操作是读volatile修饰的变量
第二个操作是读普通变量
那么这a,b变量是不允许重排序的
Happens-Before 模型
在某些情况下,不需要通过volatile关键字,也能保证多线程环境下的可见性和有序性。在
Jdk
1.5开始,引入了一个Happens-Before
的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在Jvm
中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在Happens-Before
关系,两个操作可以是一个线程,也可以是不同的线程
程序顺序规则
可以简单认为是as-if-serial,不管怎么重排序,单线程的程序的执行结果不能改变
- 处理器不能对存在依赖关系的操作进行重排序,因为重排序会改变程序的执行结果
- 对于没有依赖关系的指令,即便是重排序,也不会改变单线程环境下的执行结果
举例:
int a = 1;//操作A
int b = 2;//操作B
int c = a * b; //操作C
A和B之间允许重排序,但是C不允许重排序,因为存在依赖关系。根据as-if-serial,在单线程环境下,不管怎么重排序,最终执行的结果都不会发生变化
传递性规则
用上述程序顺序规则代码说明:
- A happens-before B
- B happens-before C
- 那么A happens-before C
这里A happens-before B 不一定存在,也可能是 B happens-before A ,JMM不要求A一定要在B之前执行,但是要求前一个操作的执行结果对后一个操作可见。这里操作A的执行结果不需要对操作B可见,并且重排序操作A和操作B的执行结果与 A happens-before B顺序执行的结果一致,这种情况下,是允许重排序的
Volatile变量规则
对于volatile修改的变量的写操作,一定happens- before后续对volatile变量的读操作,这个是因为volatile底层通过内存屏障机制防止了指令重排,基于前面两种规则再结合volatile规则来分析下面代码的执行顺序
public class VolatileExample {
int a=0;
volatile boolean flag=false;
public void writer(){
a=1; //1
flag=true; //2
}
public void reader(){
if(flag) { //3
int i=a; //4
}
}
}
- 根据程序规则 1 happens-before 2 、 3 happens-before 4
- 2 happens-before 3 这是由volatile 规则产生的,对一个volatile 变量的读,总能看到任意线程对这个volatile 变量的写入
- 1 happens-before 4 根据传递性规则以及volatile的内存屏障策略共同保证
那么最后执行结果时,如果线程B执行reader() 方法时,如果flag 为true,那么意味着 i = 1成立
上述代码中1和2操作不存在重排序问题,因为volatile修改的重排序规则存在,如果第一个操作是普通变量的读、写,第二个操作是volatile的写操作,那么这两个操作之间是不允许重排序的,见上述volatile 重排序规则表
监视器锁规则
一个线程对于一个锁的释放锁操作,一定happens-before与后续线程对这个锁的加锁操作
int x=10;
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此处自动解锁
假设x的初始值是10,线程A执行完代码块后,x的值变成12,执行完成后释放锁。线程B进入代码块,能够看到线程A对x的写操作,也就是线程B看到x的值为12
Start规则
如果线程A执行操作 ThreadB.start()
,那么线程A的ThreadB.start()
之前的操作happens-before线程B中的任意操作
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
// 主线程调用 t1.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,x==10
});
// 此处对共享变量 x修改
x = 10;
// 主线程启动子线程
t1.start();
}
在上面代码中,在main线程中修改了x的值为10,那么在main线程调用start() 方法执行子线程的时候,x = 10的这个操作一定先于子线程中的任意操作,所以子线程中x的值一定为10
Join规则
如果线程A执行操作ThreadB.join()
,并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join()
返回之后的操作
int i = 0;
Thread t1 = new Thread(()->{
// 此处对共享变量 x 修改
x= 100;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 t1 可见
// 主线程启动子线程
t1.start();
t1.join()
// 子线程所有对共享变量的修改
// 在主线程调用 t1.join() 之后皆可见
// 此例中,x==100
if(i == 100){
System.out.println("x=100");
}
在上面代码中,main线程调用子线程t1
的join() 方法,那么子线程中的任意操作一定会先于main线程中join()方法之后的操作