CPU 的缓存
引入
原始的cpu 和内存之间的交互
基本介绍
每个cpu 独享 L1缓存、L2 缓存,整个CPU 共享L3缓存
cpu缓存的工作原理
将数据的缓存行
从内存
中加载到cpu缓存
中,此加载过程会有一定的时间损耗,随后再对数据进行访问的时候就可以直接去缓存中拿数据,提高效率;
不足之处:但是 缓存行
也会是有大小限制的,16B-256B
不等,存储的数据超过这个容量的时候,就要进行强制驱离
,采用的算法就是 最近最少使用的删除(LRU算法:
当需要替换缓存中的数据或页面时,选择最近最少使用的数据或页面进行替换。具体来说,当一个数据或页面被访问时,它的访问时间被更新
,使得它成为最近使用的数据或页面。当需要替换时,选择访问时间最早的数据或页面进行替换
。)
缓存结构
可以将cpu看成一个数组,每个元素就是一个缓存项;下面是一个缓存项的大致内容:
+-------------------------------------------+
| tag | data block(cache line) | flag |
+-------------------------------------------+
tag: 保存了内存地址的一部分,是用来验证是否缓存命中的
data block(cache line): 从内存中拷贝过来的数据,也就是我们说的cache line
flag: 是一些标志位,比如缓存是否失效,写dirty等等
为了实现内存 和 缓存 的映射关系 ,缓存将一个内存地址分成下面几个部分
+-------------------------------------------+
| tag | index | offset |
+-------------------------------------------+
tag: 和缓存项中的tag对应,用来验证是否缓存命中的。
index: 缓存项数组中的索引。
offset: 缓存块(cache line)中的偏移,实际上就是指明内存值在cache line中的位置
缓存分配策略
缓存分配策略决定了哪些数据被存储在缓存中。常见的缓存分配策略包括:
- 直接映射(Direct Mapping):将数据存储在缓存中的
特定位置
,根据数据的地址进行映射
。每个数据块只能存储在缓存的一个特定位置。 - 全相联(Fully Associative):数据块可以存储在缓存的
任意位置
,没有固定的映射规则
。这种策略可以提高缓存的命中率,但也增加了查找的复杂性。 - 组相联(Set Associative):将缓存分为多个组,每个组中有
多个缓存行
。数据块根据一定的映射规则存储在特定的组中。这种策略是直接映射和全相联的折中,提供了一定的灵活性和性能。
缓存更新策略
读数据:
当CPU读数据时,如果该数据没有在缓存中出现,CPU会把数据拷贝到缓存 <写回缓存>
写数据
缓存写策略, 我们看到缓存当中有一个flag
,flag的存在是在表示该缓存行的状态
-
Write through 即:写直达更新缓存的数据,同时更新内存的数据
- 介绍:当cache写命中时,处理器对
Cache
写入的同时将数据写入到内存
中,内存的数据和Cache中的数据都是同步的
- 优点:这种方式比较简单、可靠。
- 缺点:每次对cache更新都需要对内存写操作,因此总线工作繁忙,内存的带宽被大大占用,因此运行速度会受到影响。
- 如果在写的时候数据没有在缓存中(write miss),也有两种策略
- WTWA(Write–Through–with–Write–Allocate)(写直达的写分配) 在写之前先把数据加载到缓存,然后再实施上面的写策略。适用于写操作较少的情况,避免了不必要的主存访问
- WTNWA法(WriteThrough–with.NO-Write–Allocate) ==(写直达的不写分配)==加载缓存,直接把数据写到内存。数据只有在没读到的时候才会加载到缓存。适用于写操作频繁且对数据一致性要求较高的情况
- 介绍:当cache写命中时,处理器对
-
Write back (写回)
-
当CPU对cache
写命中时
,只修改cache的内容不立即写入主存,只当此行被换出时才写回主存。这种策略使cache在
CPU-主存
之间,在读、写方向都起到高速缓存作用。对一个缓存行的多次写命中都在cache中快速完成修改,只是需被替换时才写回速度较慢的主存,减少了访问主的次数从而提高了效率。为支持这种策略,每个
缓存行
必须配置一个修改位,以反映该行是否被CPU修改过。当某行被换出时,根据此行修改位为是为0。 -
如果对于cache
写未命中
,写回法的处理是为 要写的内存在缓存中分配一行,将此块整个拷贝到Cache后对其进行修改,之后对这个内存的读/写访问的可能性很大。拷贝主存块时虽已读访问到主存,但此时并不对主存块修改。因为换出的cache很可能此期间要写回主存,为避免此过程耗时长,写未命中对将新块读入后,只在cache中进行写修改。统一地将主存写修改操作留到换出时才进行
-
-
Write-once(写一次)
- 是结合了上面的两个策略的,即
写命中和写未命中的处理与写回法
基本相同,只是第一次写命中时要同时写入主存。 - 这策略主要用于某些处理器的片内cache,例如Pentium处理器的片内数据cache就采用的是写一次法。因为片内cache写命中时,写操作就在CPU内部完成,若没有 内存地址及其它指示信号送出,就不便于系统中的其它cache监听(snoop)。
- 采用写一次法,在第一次片内cache写命中时,CPU要在线上启动一个存储写周期。其它cache监听到此主存块地址及写信号后,即可把它们各自保存可能有的该块拷贝及时作废(无效处理)。随后若有对片cache此行的再次或多次写命中,则按回写法处理,无需再送出信号了。
- 是结合了上面的两个策略的,即
针对上图中:问题:每个 cpu只能同步更新的数到自身到缓存中,不能同步更新到主内存中
解决
方式一: CPU总线加锁(弃用)
思路:要是某一个cpu 进行读取数据的时候通过一个总线对这个数据进行加锁,就可以导致只有这个cpu 先操作完之后其他的cpu 才能对这个数据进行读取
缺点: 串行化执行
何时使用:1、数据过大,超过了一个缓存行的大小 2、老的处理器不支持MESI
方式二: cpu嗅探机制中的MESI协议
思路:是一种缓存一致性协议,对主内存中的数据的修改之后会被刷新到主内存中,其他的cpu会判断本地缓存中是不是有这个数据,要是有的话,就会让这个cpu中 的数据进行失效,然后去主内存中拿到有一份对应的最新的数据,MESI 使用的是写失效的方式。(另一种总线嗅探机制的方式是写更新)
MESI
MESI 说的就是:Modified 修改 ;Exculsive 独占; Shared 共享; Invalid 失效
- M: 说明cpu 对缓存行
最近有写操作
,也说明此数据不存在其他cpu 对应的cache 中,所以 处于modified状态的cacheline也可以说是被该CPU独占
,此时只有该CPU的cache保存了最新的数据(最终的memory中都没有更新
),该cache需要对该数据负责到底。 - E: 和modified状态非常类似,唯一的区别是对应CPU还
没有修改
cacheline中的数据,也正因为还没有修改数据,因此memory中对应的data也是最新的。在exclusive状态下,cpu也可以不通知
其他CPU cache而直接对cacheline进行操作,可以直接丢弃该缓存行 - S:数据可能在一个或者多个CPU cache中,因此 处于这种状态的cache line,CPU
不能直接修改
cacheline的数据,而是需要首先和其他CPU cache进行沟通
,处于share状态的cacheline对应的memory中的数据也是最新的
,可以直接丢弃该缓存行 - I: 此状态的缓存行是空的,没有数据;当新的数据要进入cache的时候,I是优先状态,否则的话会产生 缓存丢失的现象。
协议状态
状态 | 描述 | 监听任务 | 状态转换 |
---|---|---|---|
M 修改 (Modified) | 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 | 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。 | 当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。 |
E 独享、互斥 (Exclusive) | 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 | 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 | 当CPU修改该缓存行中内容时,该状态可以变成Modified状态 |
S 共享 (Shared) | 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 | 当有一个CPU修改该缓存行时,其它CPU中该缓存行可以被作废(变成无效状态 Invalid)。 | |
I 无效 (Invalid) | 该Cache line无效。 | 无 | 无 |
协议消息
消息 | 说明 | 收到回复消息 |
---|---|---|
Read | read message用来获取指定物理地址上的cacheline数据 | Read Response |
Read Response | 该消息携带了read message请求的数据。read response可能来自memory,也可能来自其他的cache。例如:如果一个cache有read message请求的数据并且该cacheline的状态是modified,那么该cache必须以read response回应这个read message,因为该cache中保存了最新的数据。 | |
Read Invalidate | 该命令用来将其他cpu cache中的数据设定为无效。该命令携带物理地址的参数,其他CPU cache在收到该命令后,必须进行匹配,发现自己的cacheline中有该物理地址的数据,那么就将其移除并用Invalidate Acknowledge回应。 | Invalidate Acknowledge |
Invalidate Acknowledge | 收到invalidate message的cpu cache,在移除了其cache line中的特定数据之后,必须发送invalidate acknowledge消息 | |
Read Invalidate | 该message中也包括了物理地址这个参数,以便说明其想要读取哪一个cacheline数据。此外,该message还同时有invalidate message的功效,即其他的cache在收到该命令后,移除自己cacheline中的数据。因此,Read Invalidate message实际上就是read + invalidate。发送Read Invalidate之后,cache期望收到一个read response以及多个invalidate acknowledge。 | read response、invalidate acknowledge |
Writeback | 该message包括两个参数,一个是地址,另外一个是写回的数据。该消息用在modified状态的cacheline被驱逐出境(给其他数据腾出地方)的时候发出,该命名用来将最新的数据写回到memory(或者其他的CPU cache中)。 |
监听(嗅探)机制:
- 当缓存行处于Modified状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行写回内存,并标记为Shared状态
- 当缓存行处于Exclusive状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行标记为Shared状态
- 当缓存行处于Shared状态时,会时刻监听其他cpu对使缓存行失效的指令(即其他cpu的写入操作),一旦监听到,将本cpu的缓存行标记为Invalid状态(其他cpu进入Modified状态)
- 当缓存行处于Invalid状态时,从内存中读取,否则直接从缓存读取
总结:当某个cpu修改缓存行数据时,其他的cpu通过监听机制获悉共享缓存行的数据被修改,会使其共享缓存行失效。本cpu会将修改后的缓存行写回到主内存中。此时其他的cpu如果需要此缓存行共享数据,则从主内存中重新加载,并放入缓存,以此完成了缓存一致性。
CPU缓存存在的问题和解决
问题一:出现忙等
问题描述
由于CPU缓存一致性的问题,当一个CPU对(缓存行)数据进行修改之后,会同步给其他CPU该条数据无效,当收到其他所有的CPU的回应之后,才会将数据写入到 本地缓存 中,这个等待其他CPU做回应的过程是比较浪费CPU资源的。
解决:
引入 store buffer
增加了store buffer,那么cpu0无需等待其他CPU的相应,只需要将要修改的内容放入store buffer,然后继续执行就OK了。当 缓存行 完成了bus transaction,并更新了缓存行 的状态后,要修改的数据将从store buffer进入缓存行。
store buffer对于cpu而言是
私有
的的,多核CPU的情况下,每一个cpu拥有自己私有的stroe buffer,一个cpu只能访问自己私有
的那个store buffer。cpu 0不能访问cpu1的
store buffer
,store buffer增加了cpu连续写的性能
,同时把各个CPU之间的通信的任务交给维护 缓存一致性的协议。
store buffer
作用:
1、当处理器执行存储操作(store)时,存储缓冲区可以将存储操作暂时缓存起来,不需要立即写入主存。这样可以提高存储操作的执行效率,因为将数据写入主存需要额外的时间和资源。缓存存储操作还可以通过合并相邻的存储操作,减少实际写入主存的次数,从而提高整体的存储性能。
2、在乱序执行的处理器中,指令的执行顺序可能与它们在代码中的顺序不一致。存储缓冲区可以帮助处理器正确地处理异常情况。当发生异常(如错误、中断或异常事件)时,存储缓冲区可以保留存储操作的结果,直到异常被处理完毕。这样可以确保在异常处理期间,存储操作的结果不会被丢失或不一致。
问题二:违反程序顺序原则
问题描述
设计上的缺陷,违反了一个基本的原则——每个CPU按照其视角来观察自己的行为的时候必须是符合程序顺序的。一旦违背这个原则,会导致一些非常不直观的软件行为,对软件工程师而言就是灾难
解决:
使用store forwarding 的设计,当CPU执行load操作的时候,不但要看cache,还有看store buffer是否有内容,如果store buffer有该数据,那么就采用store buffer中的值。
优化前后的对比
问题三: buffer满,继续等
问题描述
每个cpu的store buffer不能实现的太大,其entry的数目不会太多。当cpu以中等的频率执行store操作的时候(假设所有的store操作导致了cache miss),store buffer会很快的被填满。在这种状况下,CPU只能又进入等待状态,直到cache line完成invalidation和ack的交互之后,可以将store buffer的entry写入cacheline,从而为新的store让出空间之后,CPU才可以继续执行
上述的状况也可能发生在调用了memory barrier指令之后,因为一旦store buffer中的某个entry被标记了,那么随后的store都必须等待invalidation完成,因此不管是否cache miss,这些store都必须进入store buffer。
解决
引入 invalidate queues
store buffer之所以很容易被填充满,主要是因为 其他CPU回应invalidate acknowledge比较慢,如果能够加快这个过程,让store buffer尽快进入cacheline,那么也就不会那么容易填满了。
但是 invalidate acknowledge不能尽快回复;主要原因就是:invalidate cacheline的操作很慢,尤其在cache 繁忙的时候。
实际上,CPU不需要完成invalidate操作就可以回送acknowledgement消息,这样,就不会阻止发生invalidate请求的那个CPU进入等待状态。CPU可以使用buffer将这些invalidate message放入Invalidate Queues,然后直接回应acknowledgement,表示自己已经收到请求,进行异步处理。
引入
Invalidate Queue
之后的图有了Invalidate Queue的CPU,在收到invalidate消息的时候首先把它放入Invalidate Queue,同时立刻回送acknowledge 消息,无需等到该cacheline被真正invalidate之后再回应。
如果一个 CPU想要针对某个cacheline向总线发送invalidate消息的时候,那么CPU必须首先去Invalidate Queue中看看是否有相关的cacheline,如果有,那么不能立刻发送,需要等到Invalidate Queue中的cacheline被处理完之后再发送。
一旦将一个invalidate(例如针对变量a的cacheline)消息放入CPU的Invalidate Queue,实际上该CPU就等于作出这样的承诺:在处理完该invalidate消息之前,不会发送任何相关(即针对变量a的cacheline)的MESI协议消息。
问题四:脏读
问题描述
当数据存在于store buffer中,还未收到全部CPU的响应,此时,有其他CPU要读取这部分信息,这时如果读取 本地缓存 或者内存中的数据,则数据都是老数据;
解决:
增加读取时候的限制
所以为了保证数据的一致性,给CPU增加了一个限制,当有数据写入的过程中,且有针对这个 缓存行 的读取操作,那么会同时读取store Buffer 跟 本地缓存中的数据,如果store Buffer中有数据,则优先使用store buffer中的数据,这样就解决了写数据过程中,使用该部分数据的数据不一致问题。
store buffer 是FIFO队列
load load (会产生问题,失效queue 中的队列没有及时消费)
store load(产生问题,自己加屏障)
load store(没有问题)
sotre store(没有问题)
注意:在 x86下TSO模型是只有 StoreLoad 是允许重排序的,也就是说 MESI协议的StoreBuffer的引入,使得StoreLoad 有乱序执行的可能,因此,我们需要 禁止 StoreLoad的排序
CPU屏障类型
- 内存读屏障(Load Barrier):内存读屏障用于确保在屏障之前的
读
操作完成后
,才能执行屏障之后
的读
操作。这样可以防止指令重排引起的读操作顺序错误。 - 内存写屏障(Store Barrier):内存写屏障用于确保在屏障之前的
写
操作完成后
,才能执行屏障之后
的写
操作。这样可以防止指令重排引起的写操作顺序错误。 - 全内存屏障(Full Memory Barrier):全内存屏障是最强的屏障类型,它保证在屏障之前的所有
读写
操作都完成后,才能执行屏障之后的读写
操作。全内存屏障可以防止指令重排和内存可见性问题。 - Acquire屏障:Acquire屏障用于确保屏障之前的
读
操作完成后
,才能执行屏障之后的读写
操作。它主要用于保证读操作的顺序一致性,防止指令重排引起的读
操作顺序错误。 - Release屏障:Release屏障用于确保屏障之前的
写
操作完成后
,才能执行屏障之后的读写
操作。它主要用于保证写操作的顺序一致性,防止指令重排引起的写
操作顺序错误。
Java内存模型
目的/作用:
定义各个变量在共享数据区域和私有数据区域的访问方式,关注于虚拟机是如何从内存中读取数据(共享变量)的底层细节【java中定义了8种操作完成】,屏蔽掉内存访问的差异,可以让java 程序在各种操作系统和硬件环境下都达到一致的内存访问效果。
两种内存:
- 主内存:(多个线程共享的)
- 可以存储所有的变量,物理上属于虚拟机的一部分
- 线程之间变量值的传递需要经过主内存才能完成
- 工作内存:(单个线程私有的)
- 各个线程的工作内存保留主内存的副本,各个线程对各变量的所有操作只能在工作内存中执行,不能直接读写主内存的数据
- 不同线程之间无法直接访问对方内存的变量
内存之间的交互操作
JMM定义了8种主内存与工作内存交互的基本操作,如下:
- lock(锁定):作用于
主内存
的变量,它把一个变量标识为一条线程独占
状态。 - unlock(解锁):作用于
主内存
变量,它把一个处于锁定状态的变量释放
出来,释放后的变量才可以被其他线程锁定。 - read(读取):作用于
主内存
变量,它把一个变量值从主内存传输到线程的工作内存
中,以便随后的load动作使用。 - load(载入):作用于
工作内存
的变量,它把read操作从主内存中得到的变量值放入工作内存
的变量副本中。 - use(使用):作用于
工作内存
的变量,它把工作内存中的一个变量值传递给执行引擎
,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。 - assign(赋值):作用于
工作内存
的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量
,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 - store(存储):作用于
工作内存
的变量,它把工作内存中的一个变量的值传送到主内存
中,以便随后的wite的操作。 - write(写入):作用于
主内存
的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量
中。
当一个变量从主内存拷贝到工作内存
时,执行顺序:先read后load。
当一个变量从工作内存同步到主内存
时,执行顺序:先store后write。.
JMM规定上面的操作只要求执行顺序,而不要求是否连续执行。也就是说,read与load之间,store 和 write 之间 可以插入其他指令,如read a、read b、load b、load a。
先行发生原则
happens-before规则:
- 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
- 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
- volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
- 线程启动规则(Thread Start Rule):Thread 对象 start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法和 Thread.isAlive()的返回值等手段检测线程是否已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule) :一个对象的初始化完成(构造函数结束)先行发生于它的 finalize()方法的开始。
- 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
解读:
- 程序次序规则:在一个线程内,按照控制流顺序,如果操作 A 先行发生于操作 B,那么操作 A 所产生的影响对于操作 B 是可见的。
- 管程锁定规则:对于同一个锁,如果一个 unlock 操作先行发生于一个 lock 操作,那么该 unlock 操作所产生的影响对于该 lock 操作是可见的。
- volatile 变量规则:对于同一个 volatile 变量,如果对于这个变量的写操作先行发生于这个变量的读操作,那么对于这个变量的写操作所产的影响对于这个变量的读操作是可见的。
- 线程启动规则:对于同一个 Thread 对象,该 Thread 对象的 start()方法先行发生于此线程的每一个动作,也就是说对线程 start()方法调用所产生的影响对于该该线程的每一个动作都是可见的。
- 线程终止规则:对于一个线程,线程中发生的所有操作先行发生于对此线程的终止检测,也就是说线程中的所有操作所产生的影响对于调用线程 Thread.join()方法或者 Thread.isAlive()方法都是可见的。
- 线程中断规则:对于同一个线程,对线程 interrupt()方法的调用先行发生于该线程检测到中断事件的发生,也就是说线程 interrupt()方法调用所产生的影响对于该线程检测到中断事件是可见的。
- 对象终结规则:对于同一个对象,它的构造方法执行结束先行发生于它的 finalize()方法的开始,也就是说一个对象的构造方法结束所产生的影响,对于它的 finalize()方法开始执行是可见的。
- 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,则操作 A 先行发生于操作 C,也就说操作 A 所产生的所有影响对于操作 C 是可见的。
注意:
时间上的先后原则
和先行发生
没有任何关系!!!
小结
Java内存模型的原子性、可见性、有序性的保障
原子性
- 保证原子性的操作:
store、read、writer、use、assign、load
;所以我们大致认为基本数据类型的访问、读写
是具备原子性的(long、double存在例外) - 要是范围更大的话那就可以使用
lock、unlock
来保证原子性;也可以使用synchronized
保证原子性【synchronized中的monitorenter
和monitorexit
】
可见性
- 无论是普通变量还是
volatile
修饰的变量都是 将对变量的值修改之后将新值同步回 主内存,在变量读取前将从主内存刷新变量值,这种依赖主内存作为传递媒介的方式实现可见性;两者的区别就是
:volatile 可以保证修改之后的值可以立即同步到主内存中,每次使用之前也立即从主内存中刷新,所以称 volatile 修饰的值可以保证多线程操作的时候变量的可见性。 synchronized
可见性实现: 在对一个变量执行unlock
操作之前,必须把此变量同步回主内存中final
修饰的字段在构造器中一旦被初始化完成,并且构造器中没有把 “this”的引用传递出去(没有发生内存逃逸的情况),那么被其修饰的变量在其他线程中也是可见的
有序性
volatile
:本身就含有禁止指令重排序的语义synchronized
:一个时刻只有一个线程对其进行 lock 操作
在本线程中观察所有操作都是有序的【指的是:线程内 表现为串行的语义】,但是观察另一个线程的时候所有操作都是无序的【指的是:指令重排序 和 工作内存与主内存同步延迟现象】
伪共享问题
基本介绍
缓存一致性协议针对的是最小的存储单元:缓存行。
如果两个变量 x,y 都存储在同一个缓存行中,那么对于 两个不同的cpu 的分别操作两个变量的时候流程如下:
1、cpu1 修改缓存行内的变量 x 之后,按照缓存一致性协议 cpu2 将该缓存行置为失效, cpu1 将新的缓存行写回内存
2、cpu2 需重新从内存中加载这个缓存行数据,并放置缓存。
3、如果cpu2修改变量y,需要 cpu1将缓存行置为失效,cpu2将最新缓存写回内存。
4、cpu1或其他处理器如需操作同一缓存行内的其他数据,重复上述步骤
这个其实就是一个
伪共享
的例子
所谓的缓存行的伪共享问题说的就是:在多核多线程的并发场景下,多核操作的不同变量在同一个缓存行,某cpu更新缓存行中数据,并写回缓存,同时其他处理器会使该缓存行失效,要是需要使用,还要从缓存行中重新加载数据。
大大大大大….大大大大的影响性能!
解决方案
缓存填充(对齐) 经典的 空间换时间
就是分开存数据:把缓存行中仅存储目标变量,其余空间采用“无用”数据填充补齐64字节,就不会产生伪共享问题。
应用举例
1、Disruptor中是怎么解决的 Disruptor框架介绍
public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
private volatile long cursor = INITIAL_CURSOR_VALUE;
public long p8, p9, p10, p11, p12, p13, p14; // cache line padding
在目标变量前后定义多个"无实际含义的"变量进行缓存行填充(cache line padding)
基础类型long在java中占用8字节,在额外填充7个long类型的变量,这样在从内存中获取目标变量放入缓存行时,可以达到缓存行中除了目标变量,剩下都是填充变量(由于无业务含义,其他cpu不会对其进行修改)
佐证:
public class Test1234{
private static class Entity{
public volatile long x = 1L;
}
public static Entity[] entities = new Entity[2];
static {
entities[0] = new Entity();
entities[1] = new Entity();
}
public static void main(String[] args) throws InterruptedException {
Thread threada = new Thread(()->{
for (int i = 0; i < 10000000; i++) {
entities[0].x=i;
}
});
Thread threadb = new Thread(()->{
for (int i = 0; i < 10000000; i++) {
entities[1].x=i;
}
});
long start = System.currentTimeMillis();
threada.start();
threadb.start();
threada.join();
threadb.join();
long end = System.currentTimeMillis();
System.out.println("耗时:"+(end - start)); // 耗时:36
}
}
// 定义一个包含7个long类型的“无实际意义”字段的填充对象;
private static class padding{
public volatile long p1,p2,p3,p4,p5,p6,p7;
}
private static class Entity extends padding{
public volatile long x = 0L;
}
//耗时:11
2、@Contended
Jdk8中引入了@sun.misc.Contended
这个注解来解决缓存伪共享问题。使用此注解有一个前提,必须开启JVM参数-XX:-RestrictContended
,此注解才会生效
此注解在一定程度上同样解决了缓存伪共享
问题。但底层原理并非缓存行填充
,而是通过对对象头内存布局的优化
,将那些可能会被同一个线程几乎同时写的字段分组到一起
,避免形成竞争
,来达到避免伪共享的目的。
@Contended
private static class Entity {
public volatile long x = 0L;
}
//耗时:21