目录
一、一个问题引发的思考
二、什么是可见性
2.1 硬件层面
2.1.1 CPU高速缓存
2.1.2 总线锁&缓存锁,和缓存一致性
2.1.3 既然cpu有机制可以达成缓存一致性,为什么还是会有可见性问题?
三、引出了MESI的一个优化(x86结构)
3.1 优化前cpu修改share状态缓存示意图:
3.2 Store Bufferes
3.2.1 指令重排序的过程
3.3 通过内存屏障禁止了指令重排序
四、软件层面
4.1 JMM
五、Volatile的原理
5.1 通过javap -v VolatileDemo.class查看字节指令
5.2java定义的内存屏障指令:
5.3 volatile解决可见性问题
5.4 单例模式中的可见性问题(DCL问题——双重检查锁)
六、Happens-Before模型
6.1 程序顺序规则(as-if-serial语义)
6.2 传递性规则
6.3 volatile变量规则
6.4 监视器锁规则
6.5 start规则(线程启动规则)
6.6 Join规则(线程终结规则)
参考博客
public class VolatileDemo {public static boolean flag = true;public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(()->{int i = 0;while (flag){i++;}});thread.start();Thread.sleep(1000);flag = false;}
}
执行上面一段代码,会发现虽然把flag变量改成了false,但是线程并没有停止,貌似main线程更改了值,对于thread线程来说并不知道,这就是我们常说的线程中的可见性问题,也是引起线程安全问题的根本原因。
那怎么解决这个问题呢?非常简单,java中最常见的就是通过volatile关键字解决,如下代码:
public class VolatileDemo {// 添加volatile关键字,解决可见性问题public volatile static boolean flag = true;public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(()->{int i = 0;while (flag){i++;}});thread.start();Thread.sleep(1000);flag = false;}
}
在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。
但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性问题。
CPU/内存/IO设备,由于运行速度的差异,所以对cpu有一定的优化
主要体现在三个方面:
因为高速缓存的存在,会导致一个缓存一致性问题。 下图是CPU高速缓存的一个模型图,我们可以分析出,ThreadA线程不能及时读取到ThreadB线程更改的值(数据不可见性),从而导致缓存数据不一致。
总线锁
L1是一级缓存,L1d是数据缓存、L1i是指令缓存
L2是二级缓存,大小比一级缓存大一点
L3是三级缓存,L3缓存主要目的是为了敬意不降低内存操作的延迟问题
总线锁:简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的 。
如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个CPU缓存的同一份数据是一致的就行。在P6架构的CPU后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要协会到主内存中的,就可以采用缓存锁来解决问题。
所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。
总线锁和缓存锁怎么选择,取决于很多因素,比如CPU是否支持、当前数据是否存在于缓存行以及存在无法缓存的数据时(比较大或者快约多个缓存行的数据,必然还是会使用总线锁。)
缓存锁
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。接下来给大家简单讲解一下MESI表示缓存行的四种状态,分别是:
当cpu运行的时候,都会加载shop的值。以下是缓存各种状态示意图:
1.Exclusive——独占:
当stop值只存在某一个CUP0的缓存行中,这种状态叫缓存的独占状态。
2.Shared——共享
当stop值存在与多个cup中,叫共享状态。
3.Modify——修改
当stop值只存在CPU0的缓存中时,若修改stop值,会从独占状态变为修改状态。
4.Invalid——失效
当stop值同时存在CPU0和CPU1的的缓存中时,若CPU0修改stop值,此时会从CPU1的缓存行会变为失效状态。
此后CPU1会再次从主存中加载stop值。
缓存一致性
cpu就是通过缓存一致性协议或总线锁机制去达成缓存的一致性。
以下文字摘自于其他博客,以供参考:
既然CPU有缓存一致性协议(MESI),为什么JMM还需要volatile关键字?
volatile是java语言层面给出的保证,MSEI协议是多核cpu保证cache一致性的一种方法,中间隔的还很远,我们可以先来做几个假设:
1.回到远古时候,那个时候cpu只有单核,或者是多核但是保证sequence consistency,当然也无所谓有没有MESI协议了。那这个时候,我们需要java语言层面的volatile的支持吗?
当然是需要的,因为在语言层面编译器和虚拟机为了做性能优化,可能会存在指令重排的可能,而volatile给我们提供了一种能力,我们可以告诉编译器,什么可以重排,什么不可以。
2.那好,假设更进一步,假设java语言层面不会对指令做任何的优化重排,那在多核cpu的场景下,我们还需要volatile关键字吗?
答案仍然是需要的。因为 MESI只是保证了多核cpu的独占cache之间的一致性,但是cpu的并不是直接把数据写入L1 cache的,中间还可能有store buffer。有些arm和power架构的cpu还可能有load buffer或者invalid queue等等。因此,有MESI协议远远不够。
3.再接着,让我们再做一个更大胆的假设。假设cpu中这类store buffer/invalid queue等等都不存在了,cpu是数据是直接写入cache的,读取也是直接从cache读的,那还需要volatile关键字吗?
你猜的没错,还需要的。原因就在这个“一致性”上。consistency和coherence都可以被翻译为一致性,但是MSEI协议这里保证的仅仅coherence而不是consistency。那consistency和cohence有什么区别呢?
下面取自wiki的一段话:
Coherence deals with maintaining a global order in which writes to a single location or single variable are seen by all processors. Consistency deals with the ordering of operations to multiple locations with respect to all processors.
因此,MESI协议最多只是保证了对于一个变量,在多个核上的读写顺序,对于多个变量而言是没有任何保证的。很遗憾,还是需要volatile~~
4.好的,到了现在这步,我们再来做最后一个假设,假设cpu写cache都是按照指令顺序fifo写的,那现在可以抛弃volatile了吧?你觉得呢?
那肯定不行啊!因为对于arm和power这个weak consistency的架构的cpu来说,它们只会保证指令之间有比如控制依赖,数据依赖,地址依赖等等依赖关系的指令间提交的先后顺序,而对于完全没有依赖关系的指令,比如x=1;y=2,它们是不会保证执行提交的顺序的,除非你使用了volatile,java把volatile编译成arm和power能够识别的barrier指令,这个时候才是按顺序的。
最后总结,答案就是:还需要~~
以上过程,阻塞时间会很短,但依然会造成cpu资源的浪费。所以cpu引入了Store Bufferes。
Store Bufferes是一个写的缓冲,对于上述描述的情况,CPU0可以先把写入的操作先存储到StoreBufferes中,Store Bufferes中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到Acknowledgement,继续往下执行其他指令,直到收到CPU0收到Acknowledgement再更新到缓存,再从缓存同步到主内存。
我们来关注下面这段代码,假设分别有两个线程,分别执行executeToCPU0和executeToCPU1,分别由两个不同的CPU来执行。
引入Store Bufferes之后,就可能出现 b==1返回true ,但是assert(a==1)返回false。很多同学肯定会表示不理解,这种情况怎么可能成立?那接下来我们去分析一下。
/*伪代码*/
executeToCPU0(){a=1;b=1;
}executeToCPU1(){while(b==1){assert(a==1);}
}
上述代码图解:
这就是cpu层面的指令重排序。
上面的Store Bufferes 是为了提高cpu的利用率,但这样也带来了指令重排序。为了解决这个问题,cpu也提供了内存屏障的指令。
X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障):
上述加屏障后的伪代码:
volatile int a=0;
executeToCpu0(){a=1;//storeMemoryBarrier()写屏障,写入到内存b=1;// CPU层面的重排序//b=1;//a=1;
} executeToCpu1(){while(b==1){ //trueloadMemoryBarrier(); //读屏障assert(a==1) //false}
}
volatile会自动加写屏障和读屏障。
上述我们说得都是硬件层面解决可见性问题,并且是基于x86架构,但是我们的java代码是会运行在不同的cpu架构中的。
由此引出了java内存模型,它与jvm运行数据区不是一个概念。
编译器优化重排序伪代码:
int a=0;
executeToCpu0(){a=1;b=1;// 编译器层面也会重排序//b=1;//a=1;
} executeToCpu1(){while(b==1){ //trueassert(a==1) //false}
}
其实通过前面的内容分析我们发现,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?
其实前面在分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。
public static volatile boolean stop;
descriptor: Z
flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
volatile源码多了一个ACC_VOLATILE
一句话来说就是:提供防止指令重排序的机制,和内存屏障机制取解决可见性问题。
public class DoubleCheckSingleton {private static DoubleCheckSingleton instance = null; public static DoubleCheckSingleton getInstance(){if(instance==null){synchronized (DoubleCheckSingleton.class) {if (instance == null) {instance = new DoubleCheckSingleton();//这里由于out-of-order}}}return instance;}
}
上面的instance没有用volatile修饰,会有可见性问题:
这里说的是语句instance = new DoubleCheckSingleton()不是一个原子操作
instance = new DoubleCheckSingleton();//这里由于out-of-order 无序操作那么问题就来了:必然会做这么些事情
- 给DoubleCheckSingleton分配内存
- 初始化DoubleCheckSingleton实例
- 将instance对象指向分配的内存空间(instance为null了)
而在1,2,3中,执行顺序可能出现2,3或者3,2这种情况,如果是3,2 自然另一个线程拿到的可能是未初始化好的DoubleCheckSingleton
JDK1.5后可改为,private volatile static DoubleCheckSingleton instance = null 每次都从主内存读取instance。
除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。
从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。
所以我们可以认为在JMM中:
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。
int a=0;
int b=0;
void test(){int a=1; aint b=1; b//int b=1;//int a=1;int c=a*b; c
}
a happens -before b ; b happens before c
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
a happens-before b , b happens- before c, a happens-before c
这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
内存屏障机制来防止指令重排
public class VolatileExample{int a=0;volatile boolean flag=false;public void writer(){a=1; //1flag=true; //修改 2}public void reader(){if(flag){ //true 3int i=a; //1 4}}
}
1 happens-before 2 是否成立? 是 -> ?
3 happens-before 4 是否成立? 是
2 happens -before 3 ->volatile规则
1 happens-before 4 ; i=1成立
一个unLock操作先行发生于后面对同一个锁lock操作;(synchronized)
int x=10;
synchronized(this){//后续线程读取到的x的值一定12if(x<12){x&#61;12;}
}
x&#61;12;
假定线程A在执行过程中&#xff0c;通过执行ThreadB.start()来启动线程B&#xff0c;那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
public class StartDemo{int x&#61;0;Thread t1&#61;new Thread(()->{//读取x的值 一定是20if(x&#61;&#61;20){}});x&#61;20;t1.start();
}
假定线程A在执行的过程中&#xff0c;通过制定ThreadB.join()等待线程B终止&#xff0c;那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。
public class Test{int x&#61;0;Thread t1&#61;new Thread(()->{x&#61;200;});t1.start();t1.join(); //保证结果的可见性。//在此处读取到的x的值一定是200.
}
兴趣拓展&#xff1a;final关键字提供了内存屏障的规则
CPU有缓存一致性协议(MESI)&#xff0c;为何还需要volatile