什么是伪共享
为了理解“伪共享”,在上一文CPU高速缓存那些事儿中我们主要对CPU高速缓存的原理构造进行大致的了解,其实通过CPU高速缓存的理解以及在文末提到的缓存一致性协议,我们已经能够很容易的理解所谓的“伪共享”的问题。
在这里,我们在来回顾一下CPU高速缓存的知识,在现代计算机中,CPU缓存是分层次结构的,例如:L1,L2,L3,当CPU发起一个读取内存指令的时候,首先在一级缓存L1中查找,如果不命中继续到低一层缓存中查找,缓存中没有直到最后到内存或硬盘上读取,读取到想要的内存地址之后,将由低一层的存储结构返回包含该内存地址的一个缓存行至上一层缓存,最上一层缓存获得该缓存行之后,将该缓存行放到自己的缓存中,然后抽取出CPU真正想要读取的内存地址数据返回给CPU。
在这里面有个很重要的概念那就是cache line缓存行,CPU高速缓存是以一个缓存行为单位进行读写的,并不是单单只存储CPU想要的数据,至于为什么请看上一文中的CPU高速缓存的逻辑原理,即局部性原理。
在上一章文末,我们还提到了一个术语“缓存一致性协议”,当一个CPU高速缓存行发生改变,为了不影响其他CPU的相关数据的正确性,必须通过某些手段让其他拥有该缓存行数据的CPU高速缓存进行数据一致性同步,这就是造成“伪共享”的所在,试想运行在两个不同CPU核心上的两个线程,如果他们操作的内存数据在物理上处于相同或相邻的内存块区域(或者干脆就是操作的相同的数据),那么在将它们各自操作的数据加载到各自的一级缓存L1的时候,在很大概率上它们刚好位于一个缓存行(这是很有可能的,毕竟一个缓存行的大小一般有64个字节),即共享一个缓存行,这时候只要其中一个CPU核心更改了这个缓存行,都会导致另一个CPU核心的相应缓存行失效,如果它们确实操作的是同一个数据变量(即共享变量)这无可厚非,但如果它们操作的是不同的数据变量呢,依然会因为共享同一个缓存行导致整个缓存行失效,不得不重新进行缓存一致性同步,出现了类似串行化的运行结果,严重影响性能,这就是所谓的“伪共享”,即从逻辑层面上讲这两个处理器核心并没有共享内存,因为他们访问的是不同的内容(变量)。但是因为cache line缓存行的存在,这两个CPU核心要访问这两个不同的内存数据时,却一定要访问同一个cache line缓存行,产生了事实上的“共享”。显然,由于cache line大小限制带来的这种“伪共享”是我们不想要的,会浪费系统资源。
缓存行上的写竞争是运行在SMP系统中并行线程实现可伸缩性最重要的限制因素。有人将伪共享描述成无声的性能杀手,因为从代码中很难知道两个不同的变量是否会出现伪共享。
伪共享图例
在上图中,展示了伪共享的种表现形式,数据X、Y、Z被加载到同一Cache Line中,线程A在Core1修改X,线程B在Core2上修改Y。根据MESI缓存一致性协议,假设是Core1是第一个发起写操作的CPU核,Core1上的L1 Cache Line由S(共享)状态变成M(修改,脏数据)状态,然后告知其他的CPU核,图例则是Core2,引用同一地址的Cache Line已经无效了;当Core2发起写操作时,首先导致Core1将X写回主存,而后才是Core2从主存重新读取该地址内容以便后面的修改。
可见多个线程操作在同一Cache Line上的不同数据,相互竞争同一Cache Line,导致线程彼此牵制影响,变成了串行程序,降低了并发性。此时我们则需要将共享在多线程间的数据进行隔离,使他们不在同一个Cache Line上,从而提升多线程的性能。当然上图只是数据在同一个CPU的多个核心直接产生的伪共享,数据仅仅通过共享的三级缓存L3就能得到同步,如果是多CPU或者处理器核心位于不同的插槽上,带来的性能问题才更糟。
关于缓存行对程序性能的影响,可以参考7个示例科普CPU CACHE 一文中的相关示例。
伪共享的解决方案
既然知道了伪共享产生的原因是不同的数据变量由于在物理地址的连续性导致被一起加载到同一个缓存行,所以解决的办法就是:通过数据填充的方式将不同的变量在物理地址上隔离开来,失去了地址连续性被加载到同一个缓存行的概率将大大减小。 当然填充是需要技巧的,你的对缓存行的大小以及操作的数据在内存中的布局有个了解。下面以Java对伪共享的解决为例进行举例。
以下内容来自Martin Thompson的博文(中文翻译),Martin Thompson的博文中指出每个对象的起始地址都对齐于8字节以提高性能。因此当封装对象的时候为了高效率,对象字段声明的顺序会被重排序成下列基于字节大小的顺序:
- doubles (8) 和 longs (8)
- ints (4) 和 floats (4)
- shorts (2) 和 chars (2)
- booleans (1) 和 bytes (1)
- references (4/8)
- <子类字段重复上述顺序>
了解到这些信息之后,我们就可以在任意字段间利用long类型的无用变量来隔离真正有意义的变量,使它们位于不同的缓存行。如以下这个示例:
public final class FalseSharing implements Runnable{ public final static int NUM_THREADS = Runtime.getRuntime().availableProcessors(); //获得CPU核心个数 public final static long ITERATIONS = 500L * 1000L * 1000L; private final int arrayIndex; private static VolatileLong[] longs = new VolatileLong[NUM_THREADS]; static{ for (int i = 0; i < longs.length; i++){ longs[i] = new VolatileLong(); } } public FalseSharing(final int arrayIndex){ this.arrayIndex = arrayIndex; } public static void main(final String[] args) throws Exception{ final long start = System.nanoTime(); runTest(); System.out.println("duration = " + (System.nanoTime() - start)); } private static void runTest() throws InterruptedException{ Thread[] threads = new Thread[NUM_THREADS]; for (int i = 0; i < threads.length; i++){ threads[i] = new Thread(new FalseSharing(i)); } for (Thread t : threads){ t.start(); } for (Thread t : threads){ t.join(); } } public void run(){ long i = ITERATIONS + 1; while (0 != --i){ longs[arrayIndex].value = i; } } public final static class VolatileLong{ public volatile long value = 0L; public long p1, p2, p3, p4, p5, p6; //缓存行填充代码 } }
以上示例根据当前计算机CPU核心个数分别创建了对应个数的线程(我的i5双核四线程处理器的个数就是4),并且创建了对应个数的VolatileLong对象,每个线程更改一个VolatileLong对象。VolatileLong对象只有一个有意义的字段value,JVM在同一时间创建这四个VolatileLong对象的时候,理论上将会分配相邻的内存地址,所以如果不进行缓存行填充,那么这四个线程修改各自的VolatileLong对象很可能会由于伪共享的影响导致性能低下,下图是得出的线程数和执行花费的时间的线性对比图。
上图的蓝色柱形图是在有缓存行填充代码的情况下执行下的时间,可见随着线程数的不同对性能几乎没有影响,而代表没有缓存行填充代码时的棕色的柱形图显示出了伪共享带来的严重性能问题。
当然作者也提出了由于不能确定这些独立的VolatileLong对象到达会布局在内存的什么位置,而只是从经验的角度认为同一时间分配的对象趋向集中于一块。另一方面,由于对JVM在布局对象的时候对是否会将两个字长(32位机一个字长4个字节,2个字即8字节,64位机则一个字就是8字节)的对象头(如果是数组还有一个字长的数组长度)与对象的字段放置到一起还是单独存储的不确定,所以用于缓存行填充的long型变量的个数也无法具体确定,所以只能进行大致的估计。作者的这个示例是以32位机上,将对象头占用的8个字节也认为是和字段一起存储在一个缓存行为假设的,所以你会看到除了一个8字节的value,另外填充了6个long型字段,加上对象头的8个字节,刚好一个缓存行的大小64字节。
这种缓存行填充方法在早期是比较流行的一种解决办法,比较有名的Disruptor框架就采用了这种解决办法提高性能,Disruptor是一个线程内通信框架,用于线程里共享数据。与LinkedBlockingQueue类似,提供了一个高速的生产者消费者模型,广泛用于批量IO读写,在硬盘读写相关的程序中应用的十分广泛,Apache旗下的HBase、Hive、Storm等框架都有在使用Disruptor。
Java7 对伪共享的解决
同样出自的Martin Thompson博文(中文译文) ,由于Java7会淘汰或者是重新排列无用的字段,所以原来的填充long类型无用字段的办法在Java 7下就不奏效了(也有人说依然可行,可能与具体的JDK版本有关),但是伪共享依然会发生,为此,Martin Thompson也给出了新的解决办法,那就是将原来的VolatileLong对象修改为继承AtomicLong,或者将用于填充的long字段写到父类,让VolatileLong继承父类:
方案一: public static class VolatileLong extends AtomicLong{ public volatile long p1, p2, p3, p4, p5, p6 = 7L; } 方案二: abstract class AbstractPaddingObject{ protected long p1, p2, p3, p4, p5, p6; } public class VolatileLong extends AbstractPaddingObject{ public volatile long value =0L; }
Java8 对伪共享的解决
时间进入到Java8时代后,Java官方已经提供了对伪共享的解决办法,那就是sun.misc.Contended注解。 有了这个注解解决伪共享就变得简单多了:
@sun.misc.Contended public class VolatileLong { volatile long v = 0L; }
要注意的是user classpath使用此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended 。其实@Contended注解还能用在字段上,具体下文分解。
参考文献
https://blog.csdn.net/qq_27680317/article/details/78486220
http://ifeve.com/falsesharing/
http://ifeve.com/false-sharing/
http://ifeve.com/false-shareing-java-7-cn/
http://ifeve.com/false-sharing-java-7/
http://www.cnblogs.com/liloke/archive/2011/11/20/2255737.html
相关推荐
文章首先解释了CPU高速缓存的必要性,揭示了其对提高处理速度的关键作用,特别是在处理器和内存之间速度不匹配的情况下。接着,文章详细介绍了MESI协议的每个状态及其在缓存行数据一致性中的作用。通过对单核读取、...
然而,对于非连续分配的变量,可能会出现“缓存伪共享”问题。在多核心系统中,如果两个不同核心的线程分别修改物理内存中连续的变量,它们可能会共享同一个缓存行。例如,两个线程分别修改两个连续的long变量A和B,...
- **存储层次图**:现代计算机系统中的存储层次由高速缓存(Cache)、主内存(RAM)和辅助存储设备(如硬盘驱动器或固态驱动器)组成。 - **CPU与缓存**:CPU访问L1缓存的速度比访问主内存快大约100倍,因此在CPU...
5. **缓存行与伪共享(False Sharing)**:由于缓存行的存在,当多个线程同时访问同一缓存行中的不同变量时,可能会出现伪共享问题,即使这些变量在内存中是分开的。这种情况下,可以通过调整数据结构布局来避免。 ...
内存是计算机中用于存储数据和程序的物理介质,而缓存则是为了减少CPU访问内存的时间而设置的一种高速存储器。通常,缓存位于CPU与主存之间,可以显著提升数据读取速度。 #### 2.2 缓存块操作 缓存中的数据是以块...
在CPU的高速缓存(如L1、L2或L3缓存)中,数据访问速度远高于主内存,因此尽量保持数据在缓存中能提高性能。此项目可能通过以下方式来优化缓存: 1. **节点大小调整**:为了最大化缓存行利用率,可能会调整节点的...
2. **高速**(选项B):高速缓存存储器的存取速度远高于主存储器。 3. **容量大**(选项C):与主存相比,高速缓存的容量相对较小。 #### 八、存储器芯片的主要技术指标 1. **存储容量**(选项A):指存储器能够...
伪共享发生在多线程环境中,当多个线程频繁修改位于同一CPU高速缓存行上的不同变量时,可能导致整个缓存行被频繁地在不同的CPU之间传递,从而降低程序性能。可以通过将数据结构设计得更加合理来避免伪共享。 #### ...
- **采用内存分级体系**:将经常访问的数据存储在高速缓存或更接近处理器的内存层中,可以显著减少等待时间。 ##### 3. 算法和数据结构选择 - **使用高效的算法和数据结构**:选择合适的算法和数据结构对于提高...
2. Cache 或高速缓存:CPU 内核与主存速度差异导致引入 Cache,作为高速缓冲存储器,提高数据读取速度。 3. 正数原码与反码:在二进制表示中,正数的原码和反码是相同的。 4. 串行通信校验方法:常见的校验方法有...
- **缓存(cache)**:高速缓存能够减少数据访问延迟,提高CPU的工作效率。 - **TLB(Translation Lookaside Buffer)**:用于加速虚拟地址到物理地址的转换过程。 - **协处理器(Co-processor)**:可以辅助主CPU完成特定...
内存主要分为寄存器、高速缓存(Cache)、主存储器,讲述了它们的工作原理和访问速度差异。外存则涉及硬盘、U盘等,讨论了磁盘的读写过程和文件系统的管理。 第四章:指令系统 这一章讲解了指令集的概念,分析了...
- 存储层次结构:高速缓存(Cache)、主存、硬盘等,以及它们之间的交互和性能影响。 - Cache的工作原理:替换策略(LRU、LFU等)、地址映射和写策略。 - 主存技术:DRAM和SRAM的特性比较,以及动态刷新机制。 6...
在多处理器系统中,多个处理器可能共享同一Cache,这就需要保证缓存的一致性。常见的协议有MSI(Modified, Shared, Invalid)、MESI(Modified, Exclusive, Shared, Invalid)和MOESI(Modified, Owned, Exclusive,...
Pentium 4微处理器的指令集通常包含上百条指令,且内部包含高速缓存(Cache)。 4. 主板与内存:主板上的BIOS芯片确为只读存储器,内容不能在线改写。多数主板支持多根内存条,而非仅能安装一根。内存条上的存储器...
Pentium 4中包含的高速缓存(Cache)也是其性能提升的关键因素。 4. 主板上的BIOS芯片存储基本输入输出系统,内容通常不可在线改写;现代主板通常有多个内存插槽,可以安装多根内存条;内存条上的存储器芯片通常为...