cpu的缓存一致性以及java的可见性 volatile解释

末尾有惊喜!!!

1.为什么引入cache

随着时间的推移,CPU 和内存的访问性能相差越来越大,于是就在 CPU 内部嵌入了 CPU Cache(高速缓存),CPU Cache 离 CPU 核心相当近,因此它的访问速度是很快的,于是它充当了 CPU 与内存之间的缓存角色。

缓存集成到芯片的方式有多种。在过去的单核时代,处理器和各级缓存都只有一个,因此缓存的集成方式相对单一,就是把处理器和缓存直接相连。2004 年,Intel 取消了 4GHz 奔腾处理器的研发计划,这意味着处理器以提升主频榨取性能的时代结束,多核处理器开始成为主流。

在多核芯片上,缓存集成的方式主要有以下三种:

集中式缓存:一个缓存和所有处理器直接相连,多个核共享这一个缓存;

分布式缓存: 一个处理器仅和一个缓存相连,一个处理器对应一个缓存;

混合式缓存:在 L3 采用集中式缓存,在 L1 和 L2 采用分布式缓存。

图片

了解了缓存的物理架构,来看下缓存的工作原理

2.缓存的工作原理

首先,我们来理解一个概念,cache line。cache line 是缓存进行管理的一个最小存储单元,也叫缓存块。从内存向缓存加载数据也是按缓存块进行加载的,一个缓存块和一个内存中相同容量的数据块(下称内存块)对应。这里,我们先从如何管理缓存块的角度,来看下缓存块的组织形式:

图片

通过前面的分析,我们已经知道,CPU 将未来最有可能被用到的内存数据加载进缓存。如果下次访问内存时,数据已经在缓存中了,这就是缓存命中,它获取目标数据的速度非常快。如果数据没在缓存中,这就是缓存缺失,此时要启动内存数据传输,而内存的访问速度相比缓存差很多。所以我们要避免这种情况。下面,我们先来了解一下哪些情况容易造成缓存缺失,以及具体会对程序性能带来怎样的影响。

3.缓存一致性

缓存在带来性能提升的同时,也引入了缓存一致性问题。缓存一致性问题的产生主要是因为在多核体系结构中,如果有一个 CPU 修改了内存中的某个值,那么必须有一种机制保证其他 CPU 能够观察到这个修改。于是,人们设计了协议来规定一个 CPU 对缓存数据的修改,如何同步到另一个 CPU。

在缓存一致性的问题中,因为 CPU 修改自己的缓存策略至关重要,所以我们就从缓存的写策略开始讲起。

3.1 缓存写策略

在高速缓存的设计中,有一个重要的问题就是:当 CPU 修改了缓存中的数据后,这些修改什么时候能传播到主存?解决这个问题有两种策略:

写回(Write Back)
写直达(Write Through)

当 CPU 采取写回策略时,对缓存的修改不会立刻传播到主存,只有当缓存块被替换时,这些被修改的缓存块,才会写回并覆盖内存中过时的数据;当 CPU 采取写直达策略时,缓存中任何一个字节的修改,都会立刻传播到内存,这种做法就像穿透了缓存一样,所以用英文单词“Through”来命名。

3.2 缓存更新策略

同时,当某个 CPU 的缓存中执行写操作,修改其中的某个值时,其他 CPU 的缓存所保有该数据副本的更新策略也有两种:

写更新(Write Update) / 写广播
写无效(Write Invalidate)

如果 CPU 采取写更新策略,每次它的缓存写入新的值,该 CPU 都必须发起一次总线请求,通知其他 CPU 将它们的缓存值更新为刚写入的值,所以写更新会很占用总线带宽。如果一个 CPU 缓存执行了写操作,其他 CPU 需要多次读这个被写过的数据时,那么写更新的效率就会变得很高,因为写操作执行之后马上更新其他缓存中的副本,所以可以使其他处理器立刻获得最新的值。

如果在一个 CPU 修改缓存时,将其他 CPU 中的缓存全部设置为无效,这种策略叫做写无效。这意味着,当其他 CPU 再次访问该缓存副本时,会发现这一部分缓存已经失效,此时 CPU 就会从内存中重新载入最新的数据。

在具体的实现中,绝大多数 CPU 都会采用写无效策略。这是因为多次写操作只需要发起一次总线事件即可,第一次写已经将其他缓存的值置为无效,之后的写不必再更新状态,这样可以有效地节省 CPU 核间总线带宽。

3.3 数据不在缓存

另一个方面是,当前要写入的数据不在缓存中时,根据是否要先将数据加载到缓存中,写策略又分为两种:

写分配(Write Allocate)
写不分配(Not Write Allocate)

在写入数据前将数据读入缓存,这是写分配策略。当缓存块中的数据在未来读写概率较高,也就是程序空间局部性较好时,写分配的效率较好;在写入数据时,直接将要写入的数据传播内存,而并不将数据块读入缓存,这是写不分配策略。当数据块中的数据在未来使用的概率较低时,写不分配性能较好。

如果缓存块的大小比较大,该缓存块未来被多次访问的概率也会增加,这种情况下,写分配的策略性能要优于写不分配。我们将“写直达”与“写不分配”组合起来讲解,把“写回”和“写分配”组合起来讲解

小结

从缓存和内存的更新关系看,写策略分为写回和写直达;从写缓存时,CPU Cache之间的更新策略来看,写策略分为写更新和写无效;从写缓存时数据是否被加载来看,写策略又分为写分配和写不分配。

在介绍完缓存写策略这些概念之后,我们来具体看下什么是缓存一致性问题。

3.4 解决缓存一致性

现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性(*Cache Coherence*) 的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。

那缓存一致性的问题具体是怎么发生的呢?我们以一个含有两个核心的 CPU 作为例子看一看。

假设 A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i(初始值为 0 )。

图片

这时如果 A 号核心执行了 i++ 语句的时候,为了考虑性能,使用了我们前面所说的写回策略,先把值为 1 的执行结果写入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的,因为写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里。

如果这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0。这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。

图片

那么,要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:

  • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(*Wreite Propagation*)
  • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(*Transaction Serialization*)

第一点写传播很容易就理解,当某个核心在 Cache 更新了数据,就需要同步到其他核心的 Cache 里。

而对于第二点事务的串形化,我们举个例子来理解它。

假设我们有一个含有 4 个核心的 CPU,这 4 个核心都操作共同的变量 i(初始值为 0 )。A 号核心先把 i 值变为 100,而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。

图片

那么问题就来了,C 号核心先收到了 A 号核心更新数据的事件,再收到 B 号核心更新数据的事件,因此 C 号核心看到的变量 i 是先变成 100,后变成 200。

而如果 D 号核心收到的事件是反过来的,则 D 号核心看到的是变量 i 先变成 200,再变成 100,虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。

所以,我们要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。

要实现事务串形化,要做到 2 点:

  • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
  • 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。

那接下来我们看看,写传播和事务串形化具体是用什么技术实现的,在这之前先了解一些概念

总线

CPU要和存储设备进行交互,必须要通过总线设备,在获取到总线控制权后才能启动数据信息的传输,而CPU要想从主存读写数据,那么就必须向总线发起一个总线事务(读事务或写事务)来从主存读取或者写入数据。

在这里插入图片描述

总线嗅探

要解决缓存一致性问题,首先要解决的是多个CPU核心之间的数据传播问题。最常见的一种解决方案呢,叫作 总线嗅探(Bus Snooping)。这个名字听起来,你多半会很陌生,但是其实特很好理解。

我还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。

可以发现,总线嗅探方法很简单, CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。

另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串形化。

总线仲裁

导致缓存不一致的另外一个问题在于,CPU操作共享数据的顺序性,想让并发的操作变得有序,那么常用的方式就是让操作的资源具备独占性,这也就是我们常用的的方式加锁,当一个CPU对操作的资源加了锁,那么其它CPU就只能等待,只有等前一个释放了锁(资源占用权),后面的才能获得执行权,从而保证整体操作的顺序性。

而实现这个机制的功能就叫“总线仲裁”,在多个CPU同时申请对总线的使用权时,为避免产生总线冲突,需由总线仲裁来合理地控制和管理系统中需要占用总线的申请者,在多个申请者同时提出总线请求时,以一定的优先算法仲裁哪个应获得对总线的使用权。

在这里插入图片描述

3.5 基于总线锁解决缓存一致性

什么是总线锁呢?

以 i++为例,i的初始值是0.那么在开始每块缓存都存储了i的值0,当core1做i++的时候,其缓存中的值变成了1,即使马上回写到主内存,那么在回写之后core2缓存中的i值依然是0,其执行i++,回写到内存就会覆盖第一块内核的操作,使得最终的结果是1,而不是预期中的2

总线锁流程:

在CPU1要做 i++操作的时候,其在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,也就是阻塞了其他CPU,这就使得同一时刻只有一个core 可以访问共享内存(也就是i),从而解决了缓存不一致的问题,你不是多个core 缓存了变量i,然后修改导致彼此之间不一致吗,那我只让一个core 读内存,其他的core不能访问内存,实现串行化,自然也没有缓存一致性问题了,但是这种方法的代价是,cpu的利用率直线下降,只要一个CPU占用了总线,那么其它的CPU就无法与主存进行通讯而只能等待前一个执行完成,所以为了减小锁的粒度,引入了缓存锁

3.6.基于缓存锁解决缓存一致性

所谓“缓存锁”是指多个cpu想写同一个cache line的数据,加上LOCK前缀,去总线申请缓存锁,如果可以,那就会把当前cache line的锁住,然后其他cpu如果此时也要对这个内存块的cache line操作,也需要去总线申请锁,如果失败,需要等待锁释放,这种实现是基于mesi协议的。

缓存一致性协议有多种,有MSI、MESI、MESOI,大家总体的思路是一样的,不同的是后面的协议通过增加了某些状态,从而在某些场景能进一步减少通过总线与主存打交道的操作,我们这里来了解的是其中比较出名的MESI。

MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改
  • Exclusive,独占
  • Shared,共享
  • Invalidated,已失效

这四个状态来标记 Cache Line 四个不同的状态。

「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。

「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。

另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。

那么,「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。

我们举个具体的例子来看看这四个状态的转换:

  1. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
  2. 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
  3. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。
  4. 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
  5. 如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。

所以,可以发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。

事实上,整个 MESI 的状态可以用一个有限状态机来表示它的状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件

小结: 其实计算机的底层都是相通的,比如这里的总线锁和缓存锁,不就类似于java的hashtable和concurrentHashMap吗,hashtable 读写都加synchronized,不就是总线锁吗,concurrentHashMap ,底层不就是缓存锁吗?

4.缓存一致性带来的问题

因为一个缓存行的数据在多个 CPU 核心的 Cache 里都有。所以,当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 缓存行都变成无效的状态,等其他CPU都响应对于invalid 操作的ACK 后,修改数据的CPU才能更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是获取当前对应 Cache Block 数据的所有权,也就是所谓的缓存锁。

从广播指令,到收到所有其他CPU的ACK之后才能继续后面的操作,整个过程都处于阻塞状态,而对于CPU来说这个时间是很漫长的,所以就从两个方向进行了优化。一方面是在CPU等待其他CPU 回复的过程中可以去干一些其它的事情,所以就有了Store Buffere 。 另一方面是尽量缩短其他CPU回复invalid ack的时间,所以就有了Invalidate Queue。

Store Buffer

修改数据的时候,必须先广播invalid指令给其它CPU,然后等其它CPU收到消息并把自己缓存行标记为失效,最后再响应ACK后,

再进行数据修改。 这个过程对于人来说时间很短,但对于CPU来说,可能相当于我们打开电饭煲开关,然后坐在那里等饭熟,既漫长,又无聊,而且还感觉很傻。 所以在等待饭熟(其它CPU响应ACK)的这段时间,何不去做点其它事呢,所以就有了Store Buffer。

此时,CPU广播了通知之后,不再傻等着其它CPU回复了,而是把广播invalid指令发出去以后,然后直接把要修改的数据放到 Store Buffer里,然后就去干其它事情了,当等到其他CPU都响应了ACK之后,然后再回头从Store Buffer读取出来执行最后的数据修改操作。

Store Forward(存储转发)

Store Buffer 的确提高了CPU的资源利用率,不过优化了带来了新的问题,回到上面CPU修改数据的第一步,如果第一步完成了之后(这个时候数据还在strore Buffer中,自己的缓存中还是旧值),如果此时CPU-1接到了一个读取a共享变量的指令,那么CPU这时候会从自己的缓存中去读取共享变量的数据,而当前缓存中的数据并不是最新的,那么这是一个很明显的问题。所以没办法,要解决这个问题就必须要求CPU读取数据时得先看Store Buffer里面有没有,如果有则直接读取Store Buffer里的值,如果没有才能读取自己缓存里面的数据,这也就是所谓的“Store Forward”。

Invalidate Queue(失效队列)

从CPU广播,到其他CPU收到广播消息、到其他CPU标记自己的缓存行为invalid,到响应消息,这个过程最慢的一环在于CPU标记自己的缓存行为invalid的过程,尤其是CPU在执行其它指令的期间并不能马上来处理invalid的广播消息,所以就有了失效队列的优化。

收到广播的CPU为了尽快响应 invalid ACK,所以就增加了一个失效队列,当收到其他CPU广播的invalid 消息后,不一定要马上处理,而是把放这个“失效队列里面”,然后就马上返回 invalid ack 。然后当自己有时间的时候再去处理失效队列里的消息,最后通过这种异步的方式,加快了CPU整个修改数据的过程。

MESI 优化带来的问题

任何优化都是有代价的,这里经过了 store buffer 和invalid queue 优化后性能的确是有了提升,不过随之而来的也面临着一个问题,最初的MESI虽然整个过程是同步进行的,但是这样可以确保每个操作都真正意义上的执行了,从而保证了数据的强一致性。

但是加入了store buffer之后,就使得在修改操作完成后并不能保证缓存和内存的数据得到即时更新。 而在加入invalid queue之后,也使得其它CPU在修改了共享变量之后,并不能即时的把数据标记失效,这就可能造成在某一段时间内,处理器之间还是会存在数据的不一致,整个数据变更的过程变成了最终一致性

内存屏障

从上面得出的结论来看,我估计你会想到内存屏障会是个什么东西了,内存屏障就可以简单的认为它就是用来禁用我们的CPU缓存优化的,使用了内存屏障后,写入数据时候会保证所有的指令都执行完毕,这样就能保证修改过的数据能即时的暴露给其他的CPU。在读取数据的时候保证所有的“无效队列”消息都已经被读取完毕,这样就保证了其他CPU修改的数据消息都能被当前CPU知道,然后根据Invalid消息判断自己的缓存是否处于无效状态,这样就读取数据的时候就能正确的读取到最新的数据,处理器提供了以下几种内存屏障。

Store Barrier(写屏障)

强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store缓冲区的数据都刷到CPU缓存。

结合上面的场景,这个指令其实就是告诉CPU,执行这个指令的时候需要把store buffer的数据都同步到内存中去。

Load Barrier(读屏障)

强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。

这个指令的意思是,在读取共享变量的指令前,先处理所有在失效队列中的消息,这样就保证了在读取数据之前所有失效的消息都得到了执行,从而保证自己是读取到的树是最新的。

Full Barrier(全能屏障)

包含了Store Barrier 和Load Barrier的功能。

JMM对内存屏障的支持

内存屏障提供了一套解决CPU缓存优化而导致的顺序性和可见性问题的方案,但是由于不同的硬件系统提供给的“内存屏障”指令都不一样,所以作为软件开发人员来说需要熟悉每个内存屏障的指令实在没必要,所以我们的JAVA语言把不同的内存屏障指令统一进行了封装,让我们的程序员不需要关心到系统的底层,只需要关心他们的自己的程序逻辑开发和如何使用这套规范即可,而封装这套解决方案的模型就是我们常说的Java内存模型JMM(Java Memory Model)。

jvm 提供了 4个屏障:loadload,storestore,loadstore,storeload ,其中x86只有storeload问题,所以实现底层实现的调用fence方法,其中就是使用了lock 前缀 ,lock前缀具有处理器级内存屏障,处理器级内存屏障自然而然也具备禁止编译器优化的功能,并且lock指令会强制等待刷store buffer

问1:为何需要内存屏障

内存屏障(Memory Barrier),也有叫内存栅栏(Memory Fence),还有的资料直接为了简便,就叫 membar,这些其实意思是一样的。内存屏障主要为了解决指令乱序带来了结果与预期不一致的问题,通过加入内存屏障防止指令乱序(或者称为重排序,reordering)。

那么为什么会有指令乱序呢?主要是因为 CPU 乱序(CPU乱序还包括 CPU 内存乱序以及 CPU 指令乱序)以及编译器乱序。内存屏障可以用于防止这些乱序。如果内存屏障对于编译器和 CPU 都生效,那么一般称为硬件内存屏障,如果只对编译器生效,那么一般被称为软件内存屏障。

问2:既然有了mesi,java为什么还需要volatile?

volatile的历史:java是由c和c++实现的,那我们来看c语言当中volatile,是为了解决什么问题呢?

假设你有个程序,从输入键盘中读取用户输入的内容(内存变量),然后输出到控制台,简单流程就是这样

编译器发现你每次都要去内存读取变量a,于是做了优化,生成的指令,每次去cache/寄存器拿,不去内存中拿,导致用户第一次输入A,被缓存到cpu中,用户在输入BC,由于cpu缓存了A,所以只输出A,由于编译器的优化,导致问题,于是c语言的volatile禁止编译器级的优化,每次从主内存获取数据,除了编译器优化,还有cpu指令重排序,以及cpu内存写入乱序(store buffer)

答案: mesi是默认生效的,不需要手动开启,java的volatile和mesi没有关系,只是和编译器有关

很多java程序员都见过下面这个demo

public static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        while (flag) {

        }
        System.out.println("子线程结束");
    }).start();
    Thread.sleep(3000);
    flag = false;
    System.out.println("主线程结束");
}

大多数计算机的执行结果都是只输出主线程结束,而子线程未输出,注意此时程序还在启动中,

然后很多博客以及机构就说,这是缓存一致性的问题,导致线程不可见,额,其实刚开始学多线程的时候,我也是这样理解的,后面随着深入了解并发编程,以及看一些大佬写的书籍后,我发现,这个是错误的,就拿intel 的机器来说,它的诞生是比java早的,如果一个多cpu或多核cpu不支持一致性协议,你可以想象这个机器运行的程序会出现什么问题,这里可以添加jvm参数禁止jit优化

 -Djava.compiler=NONE 

或者使用强制虚拟机运行于“解释模式”,还有很多参数如:XX:TieredStopAtLevel,也不会有jit优化

 -Xint 

小插曲:

**Hotspot有两种 JIT 即时编译器,分别为C1 编译器和C2 编译器,这两个编译器的编译过程是不一样的。 C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,也称为Client Compiler。C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Compiler。
在 Java1.7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作,也即我们只能二选一,比如我们想要编译速度快的编译器就选择C1,想要编译效果好但较慢的编译器只能选择C2。但幸运的是,从Java1.7开始引入了分层编译,这种方式综合了C1的启动性能优势和 C2 的优化效果好的性能优势,当然我们也可以通过参数 -client 或者-server 强制指定虚拟机的即时编译模式。 在 Java1.8 中,Hotspot默认开启了分层编译,如果只想开启 C2,可以使用启动参数: -XX:-TieredCompilation 关闭分层编译,如果只想用 C1,可以使用启动参数使用参数:-XX:TieredStopAtLevel=1 **

然后发现,子线程结束也输出了,程序也正常关闭了,或者你可以通过hsdis打印java执行的汇编代码,你也可以看到生成的汇编就是一直jmap一个内存地址,这时明白了,原来这是jit(注意:是c2,c1不会)优化的问题导致可见性,编译器直接把每次判断flag是不是等于true,优化成了 while(true),所以导致所谓的可见性问题

然后更有趣的是,这些问题可能在某些电脑上不会出现,比如我之前的dell灵越,不加volatile,也可保证可见性(该说法不准确,准确来说是我电脑上的idea 上运行该demo不行,idea启动程序添加了参数,如果使用cmd 运行该demo,线程停不下来,说明jit进行了优化)

所以其实java中大多数的可见性问题都可以通过禁止编译器优化解决,因为在x86中 reorder buffer的存在,cpu会乱序执行,但是写入内存前会按照程序中的顺序进行重排序,所以不会说,cpu执行乱序了,写到内存里也是乱序的,这点x86不会出现,然后就是store buffer的问题,大家要知道的是,buffer导致了最终一致性,buffer大小有限,它迟早会刷到cache和内存中,所以造成的可见性问题是什么,短暂的,所以为什么说java中volatile 保证了可见性和有序性,因为 lock 前缀是处理器提供的 硬件 指令,保证了编译器和cpu不会对它优化,每次都读写内存

回到问题:为什么有了mesi 还需要volatile详细的可以看这个文章
CPU有缓存一致性协议(MESI),为什么JMM还需要volatile关键字
我这里只做总结:

1.mesi保证了cache 一致性,但问题是我有store buffer,我不写cache ,buffer是cpu私有的,不写cache 怎么保证一致性
2.就算没有buffer,编译器和cpu也会对代码优化,进行重排序,这个时候就需要程序员手动添加内存屏障,告诉计算机,不要给我优化

java的volatile保证了可见性(使用lock指令强制把buffer刷到cache,由mesi保证cache一致性,保证了强一致性)和有序性(禁止编译器和cpu乱序执行和优化),但是可能会牺牲性能

问3:内存屏障与原子操作是两个不同的概念

内存屏障强调的是有序性,而原子操作则是强调多个步骤要么都完成,要么都不做。也就是说一个操作中的多个步骤是不能存在有些完成了,有些没完成的状态的。

问4: java 线程 安全三大要点:原子性,可见性,有序性

原子性

一段操作,要么全部不执行,要么,全部执行,不能被穿插
比如:我现在执行i++, 生成的汇编举例如下:
1.读I的值到寄存器 movl(%ebp-4),%eax;
2.对寄存器+1 addl $1,%eax
3.将寄存器的值写入内存 movl %eax,(%ebp-4)

1.线程调度(单cpu下线程a和线程b)
线程a 先执行,如果线程a执行完步骤2,发生了中断,导致线程切换,此时执行线程b,线程b直接三步走完,然后在切换会线程a,就导致运行出错了
2.多cpu同时执行(cpu1 执行线程a,cpu2执行线程b)

cpu1 执行完步骤2的时候,cpu2已经执行完了步骤3,导致结果错误,因此需要使用intel提供的lock前缀指令保证原子性

可见性

上一个线程a修改了变量c,线程b可以看到 线程a修改后的变量c

编译器优化,cpu指令重排序,cpu内存写入乱序决定了可见性,
jvm提出的jmm只是对不同底层硬件的一个抽象,提出的Happen-Before规则,是为了更好的让java程序员遵守多线程编程的规范,知道多线程程序该怎么写

有序性

程序员写的代码顺序和真正执行时的顺序是否一致,一致就保证了有序,不一致就不保证有序

重排序有三种:编译器重排序,cpu指令重排序,内存重排序(cpu 执行指令的顺序和写入主内存的顺序不一致)
为什么重排序,因为执行效率问题,重排序会导致可见性

最终:本章是作者看了很多大佬的书籍和一些知名的博主文章后,小总结一下。只是描述了一些点,并不能概况全部.因为其实在你不断学习的过程,你会发现过去一些错误的知识点,毕竟有一说一,多线程并发这块相关的东西真的很难,它涉及的东西很多,需要你了解很多,cpu架构,汇编,编译器优化,cpu内存屏障和编译器屏障,再到上层jvm 规范等,我也是不断学习,然后对本文进行补充。

文章内容来自:极客时间的编程高手必学的内存知识

​ https://mp.weixin.qq.com/s/PDUqwAIaUxNkbjvRfovaCg

​ https://zhuanlan.zhihu.com/p/84500221

看到文章末尾的,可以看下的我的程序人生这篇文章,主要是讲我在编程这条路上的经历,祝愿对你有用,感谢!

  • 6
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值