上次文章中有讲到多线程带来的原子性问题,并且就原子性问题讲解了synchronized锁的本质以及用法,今天我们就着前面的内容跟着讲解,同样,我们在讲解前一样通过一个DEMO来引出今天的主题-----可见性问题
public class Volatlle {
public static boolean stop=false;
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
int i=0;
while (!stop){
i++;
// System.out.println("结果:"+i);
// try {
// Thread.sleep(0);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
}
System.out.println("结果:"+i);
});
thread.start();
Thread.sleep(1000);
stop=true;
}
}
我们运行上面代码,理论上分析我们可能会觉得应该会输出结果并且线程结束,但是我们看下图会发现现实与相像的差距,我们想要的输出一直没有出来,而且线程一直无法结束,导致这种现像的发生就是我们今天要讲的可见性问题。我们在外层加入一个值的变化,但子线程并不知道;为解决这种问题我们可以把public static boolean stop=false;加一个volatile关健字来解决;也可以在whie(!stop)中加入System.out.println("结果:"+i);输出或者加个Thread.sleep(0)来解决可见性问题
到了这里相信很多小伙伴们有问题了,我们就问题一个个解决,第一个问题System.out.println;关于这个打印语句能解决可见性问题我要分两问分解答:
- println底层用到了synchronized这个同步关键字,这个同步会防止循环期间对于stop值的缓存。
2.因为println有加锁的操作,而释放锁的操作,会强制性的把工作内存中涉及到的写操作同步到主内存,可以通过如下代码去证明。
3.从IO角度来说,print本质上是一个IO的操作,我们知道磁盘IO的效率一定要比CPU的计算效率慢得多,所以IO可以使得CPU有时间去做内存刷新的事情,从而导致这个现象。 比如我们可以在里面定义一个new File()。同样会达到效果。
第二个问题 :Thread.sleep(0)导致的可见性问题
官方文档上是说,Thread.sleep没有任何同步语义,编译器不需要在调用Thread.sleep之前把缓存在寄存器中的写刷新到给共享内存、也不需要在Thread.sleep之后重新加载缓存在寄存器中的值。编译器可以自由选择读取stop的值一次或者多次,这个是由编译器自己来决定的。但是我个人的理解是Thread.sleep(0)导致线程切换,线程切换会导致缓存失效从而读取到了新的值。(文档位置:https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.3)
一. volatile关键字(保证可见性)
我们运行下面代码,然后在VM options:中通过汇编指令
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,`*Volatlle.*`看到汇编指令lock指令
public class Volatlle { public volatile static boolean stop=false; public static void main(String[] args) throws InterruptedException { Thread thread=new Thread(()->{ int i=0; while (!stop){ i++; } System.out.println("结果:"+i); }); thread.start(); Thread.sleep(1000); stop=true; } }
我们通过汇编发现,如果加了volatile关健值会多一个lock指令,这个指令决定了可见性问题。
1.什么是可见性
在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性;那么导致这种现像发现的本质是什么呢?这就要从硬件发展方面去说明了:
硬件层面 CUP的速度>内存速度>IO设备为此做了很多优化:
- CPU层面增加了高速缓存
- 操作系统加入进程、线程、 CPU时间片来切换
- 编译器的优化 ,更合理的利用CPU的高速缓存.比喻线程A把运行的某个数据保存在调整缓存中,后面线程B如果也用到了缓存数据时,会将指令往前提,这就引起了指令重排序的问题;
二.CPU层面的高速缓存
在绝大多数情况下CPU不仅仅是只做运算,我们在运行开篇程序时,我们程序中的static变量一定存在内存中的,我们CPU在运行程序指令时要读取共享变量值,而这个值是存在内存中,所以我们线程和内存一定会存一个交互;在CPU和内存进行交互时如果IO的速度很慢的话就会引导致CPU一直阻塞,所以为解决这一个问题就在每个CPU核心里面引入了高速缓存。大家这时是不是第一个想法是,好像读数据库引入缓存把数据库数据先存缓存中呀;这个高速缓存分为几个类型,我们看下图说明,下图是我从《深入理解计算机系统》中截取的,有兴趣的朋友可以自己去看下;下图中的L1和L2是属于CPU核心独占的,L3是共享的;cpu在执行线程指令时,如果数据不存在缓存中时,他会先从主内存中加载,加载完成后会读取到CPU的高速缓存,中,如果下次还要用时就不用从主内存读取了,可以直接从缓存中拿了,这大大减少了IO交互的开销,从而提升了CPU的利用率;
一级缓存(L1 Cache)
一级缓存(Level 1 Cache)简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存,也是历史上最早出现的CPU缓存。由于一级缓存的技术难度和制造成本最高,提高容量所带来的技术难度增加和成本增加非常大。一般来说,一级缓存可以分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和指令。两者可同时被CPU访问,减少了CPU多核心、多线程争用缓存造成的冲突,提高了处理器的效能。
二级缓存(L2 Cache)
CPU未命中L1的情况下继续在L2寻求命中,L2 Cache(二级缓存)是CPU的第二层高速缓存,分内部和外部两种芯片。内部的芯片二级缓存运行速度与主频相同,而外部的二级缓存则只有主频的一半。L2高速缓存容量也会影响CPU的性能,原则是越大越好,现在家庭用CPU容量最大的是4MB,而服务器和工作站上用CPU的L2高速缓存普遍大于4MB,有的高达8MB或者19MB。
三级缓存(L3 Cache)
三级缓存是为读取二级缓存后未命中的数据设计的—种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,这进一步提高了CPU的效率。L3 Cache(三级缓存),分为两种,早期的是外置,截止2012年都是内置的。而它的实际作用即是,L3缓存的应用可以进一步降低内存延迟,同时提升大数据量计算时处理器的性能。降低内存延迟和提升大数据量计算能力对游戏都很有帮助。而在服务器领域增加L3缓存在性能方面仍然有显著的提升。比方具有较大L3缓存的配置利用物理内存会更有效,故它比较慢的磁盘I/O子系统可以处理更多的数据请求。具有较大L3缓存的处理器提供更有效的文件系统缓存行为及较短消息和处理器队列长度。
工作原理
CPU要读取一个数据时,首先从Cache中查找,如果找到就立即读取并送给CPU处理;如果没有找到,就用相对慢的速度从内存中读取并送给CPU处理,同时把这个数据所在的数据块调入Cache中,可以使得以后对整块数据的读取都从Cache中进行,不必再调用内存。正是这样的读取机制使CPU读取Cache的命中率非常高(大多数CPU可达90%左右),也就是说CPU下一次要读取的数据90%都在Cache中,只有大约10%需要从内存读取。这大大节省了CPU直接读取内存的时间,也使CPU读取数据时基本无需等待。总的来说,CPU读取数据的顺序是先Cache后内存(结构:CPU -> cache -> memory),缓存的容量远远小于主存,因此出现缓存不命中的情况在所难免,既然缓存不能包含CPU所需要的所有数据,那么缓存的存在真的有意义吗?CPU cache是肯定有它存在的意义的,至于CPU cache有什么意义,那就要看一下它的局部性原理了:
- 时间局部性:如果某个数据被访问,那么在不久的将来它很可能再次被访问
- 空间局部性:如果某个数据被访问,那么与它相邻的数据很快也可能被访问
为了保证CPU访问时有较高的命中率Cache中的内容应该按一定的算法替换,其计数器清零过程可以把一些频繁调用后再不需要的数据淘汰出Cache,提高Cache的利用率。在存在CPU三级缓存的计算机体系中,CPU与内存,缓存的关系就从单个CPU缓存演变成了三级缓存的架构了。有了高速缓存的概念后我们再来重新理解多线程下数据一致性问题就很简单,我们线程A在改变一个static变量时会先修改高速缓存的数据,在合适的时机CPU会把高速缓存数据IO到主内存中,但是在IO前,我们缓存行的数据和我们内存的数据是不一致的,假如我们在缓存同步主内存完成前另一个线程在读取staitc数据,这时他读取的就是一个老的数据,导致了数据的不一致性,这就是多线程一致性问题的本质了;问题出现在了那我们怎么解决这种不一致性问题呢,这时候想想我们数据库的读写一致性问题是不是很多有人想到了加锁;对,我们这里面解决这种问题也是加锁,我们CPU和内存的交互中间有个总线,所以在我们的通信总线上加锁,就能保证多个CPU核心访问内存的互斥性;但是这么玩就会带来一个性能问题,我们原本多线程本来就是为了提升性能,但这总线锁反而降低了性能。所以为解决总线锁带来的CPU利用率降低的问题,引用了一个缓存锁的概念。
三.总线锁&缓存锁
1.总线锁
总线锁,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的 。
2.缓存锁
当某个处理器想要更新主存中的变量的值时,如果该变量在CPU的缓存行中,并且在Lock期间被锁定,执行写回主存操作时,CPU通过缓存一致性协议,通知其它处理器使其它处理器上的缓存失效并重新从主存读取,以此来保证原子性。
四.缓存一致性协议
简单介绍下MESI协议:其为高速缓存一致性协议,MESI表示缓存行的四种状态,M(Modified 被修改的)、E(Exclusive 独占的)、S(Share 共享的)、I(Invalid 失效的)。
缓存行为CPU的高速缓存单位,缓存主存中的部分数据,每个缓存行中用2位来表示MESI四种缓存状态。
M:
当缓存行处于M状态时,表示主存内容只在本CPU中缓存,缓存内容与主存不一致,内容被修改,在其他CPU需要对该主存内容进一步读取之前,需先将缓存内容写回到主存,然后该缓存行状态变为S。
E:
当缓存行处于E状态时,表示主存内容只在本CPU中有缓存,缓存内容与主存一致。在其他CPU需要对该主存内容读取之前,需要将E状态更改为S。也可以在缓存写入时,将E状态改为M。
S:
当缓存行处于S状态时,表示主存内容可能在多个CPU中有高速缓存,缓存内容与主存一致,并且随时可被置为I(无效状态)。
I:
当缓存行处于I状态时,表示缓存无效。此时CPU需要读取时,需要去主存读取。
由上面的讲解我们对缓存一致性问题有了一定了解,但上面S状态时讲过缓存失效,下面我们就说下当缓存处于共享状态时他是怎么让其它CPU失效的;
假设我们现在有两个线程cpu0和cpu1,cpu0要去修改内存数据stop;他在写入之前为保证缓存一致性,所以他要对其它CPU发起一个失效通知,让其它cpu的核心缓存失效;cpu1在收到失效通知时会进行处理,处理完成后会返回cpu0一个回值,告诉他已经完成了失效操作,cpu0在等待回值的过程中是一个阻塞状态,这个过程是一个强一致性问题,在收到回值后,cpu0将修改值定入到内存;为了解决这强一致性问题引起的CPU阻塞,引入了Store Bufffferes 这个概念。
五.Store Bufffferes
Store Bufffferes是一个写的缓冲,对于上述描述的情况,CPU0可以先把写入的操作先存储到Store Bufffferes中,Store Bufffferes中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到回复,继续往下执行其他指令,直到收到CPU0回复再更新到缓存,再从缓存同步到主内存。但是这玩意又引来了一个新的问题-----指令重排序问题;我拿个场景来说,当我们如下场景
假设我们CPU0有个缓存行,缓存了b=0;CPU1缓存行缓存了a=0;此时cpu0去执行a=1,而cpu1去执行while(b==1)操作,cpu0在执行a=1时,因为CPU1缓存行有a的数据,为了保存缓存的一致性,他要对其它CPU发起通知,此时CPU1收到通知后a=0;会失效,CPU1将失效的a=0存入失效队列中,同时CUP0同步将a=1缓存到Bufffferes,假设这时CPU0还没有收到CPU1的返回回复,这时CPU0会执行后面的操作,此时cpu0对b的操作属于独占状态,所以CPU0直接改变本地缓存行,此时会发生一个现像,a还没运行结束结果b就已经生效了;此时假设CPU1也执行了他的程序,因为他也在守缓存一致性原则,所以CPU1读取的b的值是线程CPU0修改后的值,但是此时CPU0的Bufffferes还没有收到CPU1的返回回复,导致CPU1读取的a的值是本身缓存行的数据a=0;所以导致了指令重排序问题。
上面问题发生了我们就想怎么解决指令重排序问题,方案有两个,第一个方案就是让失效队列能够定时同步,或者是让Bufffferes数据实时的刷新到内存中就可以解决上面问题;为此,开发工程师们提供了一个内存屏障禁止了指令重排序
六. 内存屏障
在编译器方面使用volatile关键字可以禁止指令重排序,而在硬件方面实现禁止指令重排序的则是内存屏障。其中包括硬件层本来就有的LoadBarriers和StoreBarriers即读屏障和写屏障 及JVM封装实现的四种内存屏障。
1.从硬件层上
内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效
其中对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
2.JVM实现的内存屏障
1.LoadLoad:对于Load1;LoadLoad;Load2这样的情况,保证Load1先于Load2及之后的Load操作,且对其可见。例如
... int i = a; LoadLoad; int j = b;
在这段代码中,在int j = b以及后面的Load操作中,都能见到int i = a的操作,也就是int i = a先于后面的读取操作。即,禁止int i = a和之后的读操作重排序。
2.LoadStore:对于Load1;LoadStore;Store1来说,保证Load1操作先于Store1以及后面的Store操作,即对后Store操作可见。如:
int i = a; LoadStore b = 1; // int i = a对于b = 1及之后的store操作均可见。
3.StoreLoad:同上,Store1;StoreLoad;Load1情况来说,保证Store1操作先于后续的所有Load操作,并且其Store的变量操作对其他处理器可见。由于Store操作会立即刷新到内存并对其他处理器缓存可见的特性,其具备其他三个屏障的功能,但是相对的,其花费的开销较大。
4.StoreStore:在Store1;StoreStore;Store2情况中,保证Store1操作先于Store2操作,即在Store1后续的Store操作之前,Store1操作保证刷新到内存并且对其他处理器可见。
七.volatile关键字
(1)保证可见性,不保证原子性
a.当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去;
b.这个写会操作会导致其他线程中的缓存无效。
(2)禁止指令重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:
a.重排序操作不会对存在数据依赖关系的操作进行重排序。
比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
b.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。
使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile禁止指令重排序也有一些规则:
a.当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
b.在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。