并发编程(二)volatile和缓存一致性协议/JMM和计算机物理架构的关系
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并发可以认为是一种程序的逻辑结构的设计模式,可以在一个核上执行,也可以在多个核上执行。
我们下面根据一个例子看下,一个共享变量即使不加volatile修饰符被修改之后也能影响另外一个线程。
private boolean flag = true; public void refresh(){ flag = false; System.out.println(Thread.currentThread().getName()+"修改flag"); } public void load(){ System.out.println(Thread.currentThread().getName()+"开始执行....."); int i=0; while (flag){ i++; System.out.println(i); } System.out.println(Thread.currentThread().getName()+"跳出循环: i="+ i); } public static void main(String[] args){ ThreadTest test = new ThreadTest(); new Thread(() -> test.load(), "threadA").start(); try { Thread.sleep(2000); new Thread(()->test.refresh(),"threadB").start(); } catch (InterruptedException e) { e.printStackTrace(); } } public static void shortWait(long interval){ long start = System.nanoTime(); long end; do{ end = System.nanoTime(); }while(start + interval >= end); }
1:我们先来试下:System.out.println(i);
执行上面的main函数,看到输出结果:
我们看到threadA跳出了循环。。。。flag并没有被volatile修饰,但是threadA读到了被修改过后的值,这是什么原因呢?
2:我们把输出语句换成下面的代码:
try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
结果:
3:把输出语句改为shortWait(200L);方法调用。
结果:没有跳出循环。
如果把时间参数调大一点呢?shortWait(50000L);
执行发现跳出了循环
备注:其实还有一种情况,就是上面的while循环是个空代码块:这种情况是会一直不会跳出循环的,因为这个线程一直占着时间片,他的本地缓存不会失效,所以不会从主存中获取新的数据
while(flag){ }
为什么会出现上面的几种场景呢?
JMM(Java内存模型)
JSR133定义的规范:JMM描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有区域的访问方式,JMM是围绕原子性,有序性,可见性展开的。
线程之间通信共享数据必须通过主内存来协调。
上面两个线程对于flag的修改过程可以从下面的流程中描述:
ThreadA:
1)首先从主内存通过read指令读取flag=true的值。
2)通过load指令把flag=true加载到本地本地内存中作为变量的副本。
3)CPU内核使用use指令拿到flag=true运行while(flag)代码,而且一直占用cpu
ThreadB:
前三步都是一样的,只是在这个核里,拿到flag=true之后进行了赋值assign操作,本地内存中的flag=false,然后执行store指令刷回主存,但是刷回主存这个时间是没有办法确定的。
我们看上面三种场景ThreadA为什么会退出线程。
有了输出语句为什么会跳出循环呢?
这个因为:System.out.println 底层有锁,synchronized具有可见性保证,就是线程在执行到synchronized代码块的时候要保证其之前的操作线程之间是可见的。所以flag会重新加载。
JMM中关于synchronized有如下规定,线程加锁时,必须清空工作内存中共享变量的值,从而使用共享变量时需要从主内存重新读取;线程在解锁时,需要把工作内存中最新的共享变量的值写入到主存,以此来保证共享变量的可见性。(ps这里是个泛指,不是说只有在退出synchronized时才同步变量到主存) synchronized 关键字可以保证可见性吗? - minato丶的回答 - 知乎 https://www.zhihu.com/question/48313299/answer/1166823164
使用了Thread.sleep跳出循环
sleep虽然不会释放锁,但是会让出CPU的时间片,休眠结束后使用的变量还会从主存中重新获取。和下面那种跳出原因雷同。
先看第三种:使用了shortwait方法有时候会跳出循环有时候不会,这是因为flag=true在ThreadA中的本地缓存中失效的时候就会从主存中重新read这个 变量的值,如果读到flag=false的话就跳出了循环。
这个缓存是这个读取的内容在一定时间内没有被使用就会失效,具体这个时间是没办法确定的,所以我们在调用shortwait方法如果时间较短的话线程就不会跳出,如果时间稍长就会跳出的原因。
上面的几种情况肯定是不合理的,为了避免上述问题的出现,可以把共享变量加上volatile关键字,这个关键字可以让共享变量线程之间可见。例子就不再举了。
也就是说加上这个关键字之后,ThreadA 读取了flag=true,在使用,如果ThreadB也读取了flag=true,ThreadA是知道的,而且如果ThreadB修改了flag=false,ThreadB会马上把它刷回到主存中,ThreadA也会马上从主存重新读取flag的值到自己本地内存中。
volatile是通过加锁的方式来保证这种机制的。这个锁会触发缓存一致性协议来保证共享变量多线程之间可见,具体怎么保证的,见下面分析。
为了能从硬件的角度理解这个问题,我们从计算机的组成方面研究下。
程序从本地磁盘通过IO总线读取到内存,然后再把数据从内存读取到高速缓存中,然后再从高速缓存中读取到寄存器中。
CPU缓存即高速缓冲存储器,是位于CPU与主内存间的一种容量较小但速度很高的存储器。由于CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,
Cache中保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调用,减少CPU的等待时间,提高了系统的效率。
多CPU多核的缓存架构。上面的cache现在大部分为三层架构,L1,L2在CPU内,L3在外面是多个CPU共享的,读取速度也依次递减,成本也依次递减,大小依次递增。
我的电脑是下面的配置:2核,但是有超线程技术所以逻辑处理器是4个。
抽象的硬件架构多CPU多核缓存架构图:
1)因为CPU运行速度和内存的速度相差太大所以引入了高速缓存,但是引入高速缓存就会带来多线程访问的问题,例如下图Thread1从它的核中读取i=0进行i+1操作,Thread2从它的核中读取i=0进行i+2操作,那么最后主存里的i到底等于几呢?
这是不确定的,取决于那个线程最后回写主存,缓存不一致导致的问题,这肯定不是我们希望的,涉及人员肯定有解决方法。
2)早期的解决方法也是加锁,但是锁的是IO总线。这就变成串行肯定有性能问题。
后来引入锁缓存行。CacheLine: 64byte(64bit机)为一个缓存行,8个缓存行为一个内存块。如果锁的数据超过缓存行这个锁也会失效。锁缓存行有一套协议叫缓存一致性协议。
就是定义这个缓存行的锁如何有效。缓存一致性协议的实现很有很多比如:MESI,MSI,MOESI等。
大部分系统都会实现MESI。
在MESI协议下。Thread1和Thread2读取i=0就变成下面的这个流程。
(1)Thread1从主存中读取i=0,数据存在高速缓存中,这时候的状态为E(独占),在缓存行有监听机制,可以监听到缓存的主存的数据被其它CPU也读取了。
(2)Thread2从主存中读取i=0,数据存在高速缓存中,这个时候监听机制知道有两个核读取到了i=0数据,它们在缓存中的状态变成了S(共享)。
(3)Thread1把数据i=0从缓存读取到寄存器进行修改操作i=1,缓存行的数据被修改后状态变成M,监听到数据被修改后Thread2中的数据状态变成I。
(4)缓存一致性协议会保证这个修改了的数据立马刷回主存,而不是等待某个时间点再刷回主存,因为Thread2中的数据失效了需要从主存中再次读取数据,为了减少CPU等待时间,Thread1需要立即刷回主存而且能保证刷回主存是原子性的。
立即刷回主存就是靠Lock指令来实现的,这个硬件层面的,而我们在程序里加上volatile关键字的作用如下:
volatile缓存可见性实现原理
JMM内存交互层面:volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步会主内存,使用时必须从主内存刷新,由此保证volatile变量的可见性。
底层实现:通过汇编lock前缀指令,它会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存内存会导致其他处理器的缓存无效
汇编代码查看
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp
上面MESI的协议中,虽然Thread1立马把数据刷回主存,但是Thread2再从主存读取数据总会有性能的消耗,为了进一步提升性能有了MOESI。它能够做到在Thread1的缓存中监听i变量被修改后,直接从Thread1把修改后的值传递到Thread2,不用经过主存。
注意!!
缓存一致性协议并不能保证线程安全,它只能保证缓存中的数据回写到主存其它线程读到最新的数据这个过程的是原子性的,但是它不能保证 cpu1-缓存-主存<--->缓存-cpu2整个过程是原子性的。如果cpu1,cpu2同时对i进行了修改也会出现并发的问题。
我们看到多CPU多核缓存架构和JMM模型架构很像。
JMM与硬件内存架构的关系:
JMM模型跟CPU缓存模型结构类似,是基于CPU缓存模型建立起来的,JMM模型是标准化的,屏蔽掉了底层不同计算机的区别。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中。
JMM是一个抽象的模型,操作的是逻辑空间,而上面的缓存架构是物理模型真实存在的。JMM保证本地内存尽可能的映射到物理的高速缓存建立内存映射关系,主内存也尽可能映射到物理主内存,因此加上volatile之后底层加上Lock然后触发缓存一致性,从而保证了线程之间对共享变量的可见性。
但是volatile只能保证有序性和可见性但是不能保证线程安全,关于这一点下篇记录。