前言并发编程从全面了解操作系统的底层工作开始,深入理解Java内存模型(JMM)和volatile关键字。前面我们从操作系统底层了解了现代计算机结构模型中的CPU指令结构、CPU缓存结构、CPU。运行调度和操作系统内存管理,学习Java内存模型(JMM)和volatile关键字的一些特性。本文将深入理解CPU缓存一致性协议(MESI),最后讨论既然CPU有缓存一致性协议(MESI),为什么JMM需要volatile关键字?CPU为什么有高速缓存(CacheMemory)CPU有高速缓存CPU在摩尔定律的指引下,以每18个月翻一倍的速度在发展,但是内存和硬盘的发展速度却远远落后于CPU。这就造成了高性能内存和硬盘的价格极其昂贵。但是CPU的高速计算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存,以解决I\O速度与CPU运算速度不匹配的问题。CPU在访问存储设备时,无论是访问数据还是访问指令,都倾向于聚集在一个连续的区域,这就是所谓的局部性原理。时间局部性:如果一个信息项正在被访问,它很可能在不久的将来再次被访问。如循环、递归、重复方法调用等。空间局部性:如果引用了一个内存位置,那么以后附近的位置也会被引用。例如顺序执行的代码,连续创建的两个对象,数组等。有缓存的CPU执行计算过程,数据加载到主存中。指令和数据加载到CPU缓存中。CPU执行指令,写入结果到缓存缓存中的数据写回主存当前流行的多级缓存结构由于CPU的运算速度超过了一级缓存的数据I/O能力,CPU厂商推出了一种多级缓存结构。多级缓存结构示意图如下:多核CPU多级缓存一致性协议MESI多核CPU有多个一级缓存。如果保证缓存内部数据一致,系统数据就不会混淆。这是一个一致的协议MESI。MESI协议缓存状态MESI是指四种状态的首字母。每个Cacheline有4个状态,可以用2个bit表示。它们是:Cacheline:缓存中存储数据的单位注:对于M和E状态是准确的,它们在cacheline中与S的真实状态是一致的,而S的状态可能是非持续的。如果一个缓存在S状态下使一个缓存行无效,另一个缓存实际上可能独占使用该缓存行,但是该缓存不会将缓存行提升到E状态,因为其他缓存不会广播它们使通知作废cacheline,并且由于cache不保存cacheline的copynumber,所以没有办法(即使有这样的通知)判断自己是否独占cacheline。从上面的意思来看,E状态是一种推测性的优化:如果一个CPU要在S状态下修改一个缓存行,总线事务需要将该缓存行的所有副本都变为无效状态,并在S状态下修改缓存E状态不需要使用总线事务。MESIStateTransition理解这个图的前置说明:1.触发事件1.缓存分类前提:所有缓存共同缓存主存中的某条数据。本地缓存:指的是当前CPU的缓存。触发缓存:触发读写事件的缓存。其他缓存:指除上述两种之外的缓存。注意:本地事件触发本地缓存与触发器缓存相同。上图切换解释:下图说明当一个cacheline处于调整状态时,需要调整另一个cacheline。例如:假设在S状态(共享)的缓存1中有一个变量x=0的缓存行。然后x的其他cacheline,如cache2和cache3,有变量x,调整为S状态(共享)或调整为I状态(无效)。多核缓存协同运行假设有A、B、C三个CPU,对应的三个缓存分别为缓存a、b、c。x的参考值在主存中定义为0。单核读那么执行流程是:CPUA发出指令从主存中读取x。通过总线从主存读取到缓存(Remoteread),也就是Cacheline被修改为E状态(独占)。双核读的执行流程是:CPUA发一条指令,从主内存中读取x。CPUA通过总线从主存中读取缓存a,并将缓存行设置为E状态。CPUB发出一条指令从主存中读取x。当CPUB试图从主存中读取x时,CPUA检测到地址冲突。这时CPUA响应了相关数据。此时x存放在cachea和cacheb中,x在chchea和cacheb中都被设置为S状态(shared)。修改数据,执行过程如下:CPUA完成计算后,需要下发指令修改x。CPUA将x设置为M状态(修改)并通知CPUB已经缓存了x,CPUB将本地缓存b中的x设置为I状态(无效)CPUA为x赋值。同步数据那么执行流程是:CPUB下发指令读取x。CPUB通知CPUA,当CPUA将修改后的数据同步到主存时,缓存a修改为E(独占)CPUA同步CPUB的x,同步后将缓存a和缓存b中的x设置为S状态(共享)。缓存行虚假共享什么是虚假共享?CPU缓存系统是以缓存行(cacheline)为单位存储的。目前主流的CPUCacheCacheLine大小为64Bytes。在多线程的情况下,如果需要修改“共享同一缓存行的变量”,会在不经意间影响到彼此的性能,这就是虚假共享(FalseSharing)。举个例子:现在有两个long变量a和b,如果t1正在访问a,t2正在访问b,而a和b正好在同一个cacheline,此时t1先修改a,会导致b刷新!如何解决虚假分享?Java8中添加了一个新的注解:@sun.misc.Contended。带有这个注解的类会自动填充缓存行。需要注意的是这个注解默认是无效的,需要在jvm启动的时候设置。-XX:-RestrictContended将生效。@sun.misc.ContendedpublicfinalstaticclassVolatileLong{publicvolatilelongvalue=0L;//publiclongp1,p2,p3,p4,p5,p6;}MESI优化和他们引入的问题缓存的一致性消息传递需要时间,这使得它可切换会有一个延迟。当一个缓存发生切换时,其他缓存会收到消息完成切换并发送响应消息。在这么长的系列时间里,CPU会等待所有的缓存响应完成。任何可能发生的阻塞都可能导致各种性能和稳定性问题。CPU切换状态阻塞解决方案——存储缓冲区(StoreBuffers)比如你需要修改本地缓存中的一条信息,那么你必须将**I(无效)状态**通知给其他拥有该缓存的CPU缓存缓存数据,等待确认。等待确认的过程会阻塞处理器,从而降低处理器性能。因为这个等待比一条指令的执行时间要长得多。StoreBuffers为了避免这种CPU计算能力的浪费,引入了**StoreBuffers**。处理器将要写入主存的值写入缓存,然后再继续做其他事情。当收到所有无效确认(InvalidateAcknowledge)后,数据才会最终提交。但这样做有两个风险。StoreBuffers的风险是第一位的:处理器会尝试从存储缓冲区(Storebuffer)中读取值,但它还没有提交。对此的解决方案称为StoreForwarding,它会在加载时返回是否存在于存储缓存中。第二:无法保证保存何时完成。值=3;voidexeToCPOA(){value=10;isFinsh=true;}voidexeToCPUB(){if(isFinsh){//value必须等于10?!assertvalue==10;}}想象一下,CPUA在开始执行时将isFinsh保存在E(独占)状态,但该值并没有保存在它的缓存中。(例如,无效)。在这种情况下,value比isFinsh更晚丢弃存储缓存。CPUB完全有可能读到isFinsh为true,但是value不等于10。也就是说isFinsh的赋值在value的赋值之前。这种可识别行为的变化称为重新排序。请注意,这并不意味着您的指令位置已被恶意(或出于善意)更改。这只是意味着其他CPU会按照它们在程序中写入的顺序读取结果。硬件内存模型执行失效不是一个简单的操作,需要处理器来处理。另外,存储缓冲区(StoreBuffers)并不是无限的,所以处理器有时需要等待失效确认返回。这两种操作都会显着降低性能。为了应对这种情况,引入了无效队列。他们的约定如下:对于所有收到的Invalidate请求,必须立即发送InvalidateAcknowlege消息。invalidate并没有真正执行,而是放在一个特殊的队列中,方便的时候执行。在处理Invalidate之前,处理程序不会向它处理的缓存条目发送任何消息。即使那样,处理器也不知道何时允许优化,何时不允许优化。只是处理器把这个任务丢给写代码的人。这就是内存屏障(MemoryBarriers)。写屏障存储内存屏障(又名ST、SMB、smp_wmb)是一条指令,它告诉处理器在执行它后面的指令之前应用存储缓冲区中已经存在的所有保存。读取屏障加载内存屏障(又名LD、RMB、smp_rmb)是一条指令,它告诉处理器在执行任何加载之前应用失效队列中已经存在的所有失效操作。voidexecutedOnCpu0(){value=10;//在更新数据之前,必须执行存储缓冲区(storebuffer)中的所有指令。storeMemoryBarrier();finished=true;}voidexecutedOnCpu1(){while(!finished);//读取前执行失效队列中数据相关的所有指令。loadMemoryBarrier();assertvalue==10;}总结既然CPU有缓存一致性协议(MESI),为什么JMM需要volatile关键字呢?volatile是java语言层面给出的保证,而MSEI协议是多核CPU保证缓存一致性的一种方法,中间还有很长的距离,我们可以先做几个假设:1.回溯到古代,CPU只有单核,或者多核但保证序列一致性,当然有没有MESI协议就无所谓了。这个时候我们是否需要java语言层面的volatile支持呢?我们当然需要它,因为在语言层面,编译器和虚拟机都可能存在为了性能优化而进行指令重排的可能,而volatile为我们提供了一种能力,我们可以告诉编译器哪些可以重排,哪些不可以.2、好吧,假设更进一步,假设Java语言层面不进行任何优化的指令重排,那么在多核CPU场景下,我们还需要volatile关键字吗?答案仍然是肯定的。因为MESI只保证多核CPU的独占缓存之间的一致性,但CPU不会直接将数据写入L1缓存,中间可能有一个storebuffer。一些arm和power架构的cpu也可能有loadbuffer或者invalidqueues等,所以光有MESI协议是不够的。3、接下来,让我们做一个更大胆的假设。假设cpu中这种storebuffer/invalidqueue是不存在的,cpu的数据直接写到cache中,读取也是直接从cache中读取,那还需要volatile关键字吗?你猜对了,还是需要的。原因就在于这个“一致性”。consistency和coherence都可以翻译成consistency,但是这里的MSEI协议只保证coherence,不保证consistency。一致性和连贯性之间有什么区别?以下段落摘自wiki:Coherence处理维护全局顺序,在该顺序中,所有处理器都可以看到对单个位置或单个变量的写入。一致性处理关于所有处理器的多个位置的操作顺序。所以MESI协议最多只保证一个变量在多个核上的读写顺序,多个变量不保证。可惜还是需要volatile~~4。好了,说到这里,我们做最后一个假设,假设cpu按照指令顺序fifo写入缓存,那么我们现在可以放弃volatile了吗?你怎么认为?那一定不行!因为对于arm、power等弱一致性架构的CPU,它们只保证有控制依赖、数据依赖、地址依赖等依赖的指令之间的提交顺序,而对于完全没有依赖的指令,比如如x=1;y=2,他们不会保证执行和提交的顺序,除非你用volatile,java把volatile编译成arm和power都能识别的barrier指令,这时候是有序的。总之,答案是:还是需要~~参考资料[1]http://igoro.com/archive/gallery-of-processor-cache-effects/[2]https://en.wikipedia.org/wiki/Sequential_consistency[3]https://en.wikipedia.org/wiki/Consistency_model[4]Maranget、Luc、SusmitSarkar和PeterSewell。“ARM和POWER宽松内存模型的教程介绍。”草案可从http://www.分类凸轮。交流。英国/~pes20/ppc-supplemental/test7.pdf(2012)。[5]https://www.zhihu.com/question/296949412?sort=createdPS:以上代码提交在Github上:https://github.com/Niuh-Study/niuh-juc-final.git
