最近在一本书上看到的虚假分享问题直接触及了知识的盲点。我以前从未听说过这件事。打开百度就像吃饭一样自然。虽然面经的次数不多,但我觉得还是很重要的一道题,不难。五分钟搞定~老规矩,背诵版在文末。点击阅读原文直达我收集整理的各大厂面试题三级缓存架构。众所周知,为了缓解内存和CPU速度不匹配的矛盾,引入了缓存。它的容量比内存小得多,但交换速度比内存快得多。我们之前画过这样一个分层存储架构:其实缓存还是有细分的,也就是所谓的三级缓存结构:一级(L1)缓存,二级(L2)缓存,三级(L3)缓存越近对于CPU缓存,速度越快,容量越小。因此,一级缓存容量最小,速度最快;L3缓存容量最大,速度最慢。CPU在进行计算时,首先会去L1缓存中寻找需要的数据,如果没有找到,就会去L2缓存中,然后是L3缓存中,如果最后三级都没有命中缓存,那么CPU将不得不访问内存。显然,CPU走得越远,计算时间就越长。所以在大量计算的情况下,尽量保证数据存放在L1缓存中,以提高运行速度。需要注意的是CPU、L3缓存和内存的对应关系:L1和L2只能被单个CPU核心使用,L3可以被单个socket上的所有CPU核心共享。所有的CPU核心都是共享的,如下图所示:另外,这个三级缓存空间里的数据是怎么组织的呢?也就是说,数据在这个三级缓存中的存储形式是什么?CacheLine(缓存线)!缓存中的基本存储单元是CacheLine。每个CacheLine通常为64字节,即一个Javalong类型变量为8个字节,一个CacheLine可以存储8个long类型变量。那么朋友们,你们看到了吗?缓存中的数据并不是按照单个变量来存储和组织的,而是多个变量会放在一行中。虚假分享的问题FalseSharing虚假分享的问题说了这么多好像也没怎么涉及。不用担心,我们已经很接近真相了~在程序运行过程中,由于缓存的基本单位是64字节,所以缓存行是每次update从内存中加载连续的64字节。如果访问的是long类型的数组,当数组中的一个值如v1被加载到缓存中时,与该地址相邻的接下来的7个元素也会被加载到缓存中。(这也可以解释为什么我们的数组总是这么快,像链表这样的离散存储数据结构无法享受这个红利)。但是,这波红利很可能会带来莫须有的灾难。比如我们定义了两个long类型的变量a和b,它们在内存中的地址是挨着的,会怎么样呢?如果我们要访问a,那么b也会存储在缓存中。我很困惑,这有什么问题吗?似乎并没有什么不妥。多么好的功能!来,我们直接上前面的例子,回忆一下上面提到的CPU、三级缓存、内存的对应关系。想象一下这种情况,如果一个CPU核心的线程T1正在修改a,但是另一个CPU核心的线程T2正在读取b。T1在修改a的时候,除了将a加载到CacheLine中,还会享受一波红利,同时将b加载到T1所在CPU核心的CacheLine中,对吧?根据MESI缓存一致性协议,修改a后,这个CacheLine的状态为M(Modified,modified),而其他所有包含a的CacheLine中的a都不是最新值,所以会变成I状态(Invalid,invalidstate)这样,当T2读取b的时候,哎,发现它所在的CPU核心对应的CacheLine已经过期了,mmp,从内存重新加载需要很长时间。问题已经很明显了,b和a没有关系,但是a每次更新都需要重新从内存中读取,拖慢了速度。这是虚假分享。从表面上看,a和b都是独立的线程操作,两个操作之间没有任何关系。只是他们共享一个cacheline,但是所有的争用冲突都来自于共享。使用更书面的解释来定义falsesharing:当多个线程修改相互独立的变量时,如果这些变量共享同一个缓存行,会在不经意间影响彼此的性能,导致无法充分利用缓存线路特性。这是虚假分享。伪分享解决方案下面举个例子看看一段伪分享代码的耗时。如下图,我们定义了一个Test类,里面包含两个long变量,用两个线程来自动化这两个变量。加1亿次,这段代码耗时5625ms。对于虚假分享,一般有两种方法。其实思路是一样的:1)padding:就是增加数组元素之间的间隔,让不同线程访问的元素位于不同的cacheline上,用空间换取时间。上面说了一个64字节的CacheLine可以存放8个long型变量,我们在a和b这两个long型变量之间又加了7个long型,这样a和b就在不同的CacheLine上:classPointer{挥发性长;长v1、v2、v3、v4、v5、v6、v7;volatilelongb;}再次运行程序,你会发现输出时间神奇的缩短了955ms2)JDK1.8提供了@Contended注解:就是将我们手动的padding操作封装到这个注解中。这个注解可以放在类上,也可以放在字段上,这里就不解释了classTest{@Contendedvolatilelonga;//fillavolatilelongb;}需要注意的是默认使用该注解是无效的,需要添加到JVM启动参数中才能生效。最后放这个背诵版的题:面试官:说说虚假共享问题Veal:虚假共享问题其实是缓存的特性造成的。单位是CacheLine。通常,一个CacheLine的大小为64字节。也就是说,一个CacheLine可以存储8个8字节长的类型变量。由于缓存的基本单位是一个64字节的CacheLine,所以,缓存一次从内存中读取的数据就是64字节。也就是说,如果cpu要读取一个long类型的数组,在读取一个元素的同时,还会将其他相邻地址的接下来的7个元素加载到CacheLine中。这会导致一个问题。例如,我们定义了两个long类型的变量a和b。它们之间没有任何关系,但是它们在内存中的地址是相邻的。如果一个CPU核的一个线程T1在a上被修改了,但是另一个CPU核的线程T2正在读b。那么T1在修改a的时候,除了会把a加载到CacheLine中,还会把b加载到T1所在CPU核心的CacheLine中,对吧?根据MESI缓存一致性协议,修改a后,这个CacheLine的状态为M(Modified,modified),而其他所有包含a的CacheLine中的a都不是最新值,所以会变成I状态(Invalid,invalidstate)这样,当T2读取b时,会发现他所在的CPU核心对应的CacheLine已经过期,因此他需要花很长时间从内存中重新加载。也就是说,b和a没有任何关系,但是每次a更新,都需要重新从内存中读取,这样就拖慢了速度。这是虚假分享。对于虚假分享,一般有两种方法。其实思路是一样的:1)padding:就是增加数组元素之间的间隔,使得不同线程访问的元素位于不同的缓存行。空间换时间。比如在a和b之间定义7个long型变量,让a和b不在同一个CacheLine上,这样修改a时,b所在的CacheLine不会受到影响。2)JDK1.8提供了@Contended注解,就是将我们手动的padding操作封装到这个注解中,给字段a或者b加上这个注解。
