内存对齐与CPU缓存

公司有小伙伴提出了类似的问题, 根据自己的思路,整理了一下相关的内容,做了一期分享。

目录

一、内存分页/分段管理、内存对齐

1、前置知识点

2、内存分页、分段

4、何为内存对齐

5、为何要有内存对齐

6、内存对齐

二、JVM内存模型

三、CPU缓存

1、缓存分级

2、缓存结构

3、直接映射缓存

4、N路组相联

5、缓存分配策略和更新策略

四、缓存一致性

1、总线锁和缓存锁

2、缓存行

3、MESI

4、伪共享问题

5、 伪共享解决方案


一、内存分页/分段管理、内存对齐

1、前置知识点

  1. 内存的一些概念
    1. 机器字长:是指cpu一次能处理数据的位数,通常与cpu的寄存位数有关字长越长,数的表示范围越大,精度越高

    2. 存储单元:存储器中可存放一个字或若干字节的基本单位。为区别存储器中的存储单元,每个存储单元都有唯一的一个地址编码。存储单元一般应具有存储数据和读写数据的功能,以8位二进制作为一个存储单元,也就是一个字节。每个单元有一个地址,是一个整数编码,可以表示为二进制整数。

    3. 存储单元个数:存储器中存储单元的个数。存储单元个数=2的MAR次幂

    4. 存储字长:一个存储单元存储一串二进制代码(存储字长),这串二进制代码的位数称为存储字长。

    5. 存储容量:包括主存储量和辅存储量。存储容量=存储单元个数 * 存储字长(单位是b)

    6. CPI:每条指令执行所用的时钟周期数。

    7. MAR为下一次的读写数据指定位置,

    8. MDR存储从内存交换的数据。

  2. 虚拟内存与物理内存
    1. 没有虚拟内存概念的时候,程序寻址用的都是物理地址。程序能寻址的范围是有限的,这取决于CPU的地址线条数。比如在32位平台下,寻址的范围是2^32也就是4G。并且这是固定的,如果没有虚拟内存,且每次开启一个进程都给4G的物理内存,就可能会出现很多问题。

    2. 因为我的物理内存时有限的,当有多个进程要执行的时候,都要给4G内存,很显然你内存小一点,这很快就分配完了,于是没有得到分配资源的进程就只能等待。当一个进程执行完了以后,再将等待的进程装入内存。这种频繁的装入内存的操作是很没效率的

    3. 由于指令都是直接访问物理内存的,那么我这个进程就可以修改其他进程的数据,甚至会修改内核地址空间的数据,这是我们不想看到的

    4. 因为内存时随机分配的,所以程序运行的地址也是不正确的。

    5. 于是针对上面会出现的各种问题,虚拟内存就出来了。

  3. 进程访问一个地址,它可能会经历下面的过程
    1. 每次我要访问地址空间上的某一个地址,都需要把地址翻译为实际物理内存地址

    2. 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上

    3. 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录

    4. 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)

    5. 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常

    6. 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。

  4. CPU和内存的关系
    1. 当我们执行一个程序时,首先由输入设备向CPU发出操作指令,

    2. CPU接收到操作指令后,硬盘中对应的程序就会被直接加载到内存中,

    3. 此后,CPU 再对内存进行寻址操作,将加载到内存中的指令翻译出来,

    4. 而后发送操作信号给操作控制器,实现程序的运行或数据的处理。

    5. 存在于内存中的目的就是为了CPU能够过总线进行寻址,取指令、译码、执行取数据,内存与寄存器交互,然后CPU运算,再输出数据至内存。

      一个main方法的执行过程

         

      1. 根据JVM内存配置要求,为JVM申请特定大小的内存空间;

      2. 创建一个引导类加载器实例,初步加载系统类到内存方法区区域中;

      3. 创建JVM 启动器实例 Launcher,并取得类加载器ClassLoader;

      4. 使用上述获取的ClassLoader实例加载我们定义的 org.luanlouis.jvm.load.Main类;

      5. 加载完成时候JVM会执行Main类的main方法入口,执行Main类的main方法;

      6. 结束,java程序运行结束,JVM销毁。

        https://blog.csdn.net/m0_45406092/article/details/108976907

2、内存分页、分段

1、分页存储管理

  1. 基本思想

    1. 等分内存

      页式存储管理将内存空间划分成等长的若干物理块,成为物理页面也成为物理块,每个物理块的大小一般取2的整数幂。内存的所有物理块从0开始编号,称作物理页号。

    2. 逻辑地址

      系统将程序的逻辑空间按照同样大小也划分成若干页面,称为逻辑页面也称为页。程序的各个逻辑页面从0开始依次编号,称作逻辑页号或相对页号。每个页面内从0开始编址,称为页内地址。程序中的逻辑地址由两部分组成:页号P和页内位移量W。

      在执行一个程序之前,内存管理器需要的准备工作:

      1) 确定程序的页数

      2) 在主存中留出足够的空闲页面

      3) 将程序的所有页面载入主存里。(静态的分页,页面无需连续)

  2. 分页存储管理的地址

    1. 页号x位,每个作业最多2的x次方页,页内位移量的位数表示页的大小,若页内位移量y位,则2的y次方,即页的大小,页内地址从000000000000开始到2的y次方,若给定一个逻辑地址为A,页面大小为L,则页号P=INT[A/L],页内地址W=A MOD L
  3. 内存分配

    1. 相邻的页面在内存中不一定相邻,即分配给程序的内存块之间不一定连续。对程序地址空间的分页是系统自动进行的,即对用户是透明的。
    2. 由于页面尺寸为2的整数次幂,故相对地址中的高位部分即为页号,低位部分为页内地址。
  4. 页表

    1. 分页系统中,允许将进程的每一页离散地存储在内存的任一物理块中,为了能在内存中找到每个页面对应的物理块,系统为每个进程建立一张页表,用于记录进程逻辑页面与内存物理页面之间的对应关系。
    2. 页表的作用是实现从页号到物理块号的地址映射,地址空间有多少页,该页表里就登记多少行,且按逻辑页的顺序排列

     

  5. 地址变换

    1. 页式虚拟存储系统的逻辑地址是由页号和页内地址两部分组成,地址变换过程如图所示。假定页面的大小为4K,图中所示的十进制逻辑地址8203经过地址变换后,形成的物理地址a应为十进制。

     

  6. 具有快表的地址变换机构

    1. 分页系统中,CPU每次要存取一个数据,都要两次访问内存(访问页表、访问实际物理地址)。为提高地址变换速度,增设一个具有并行查询能力的特殊高速缓冲存储器,称为“联想存储器”或“快表”,存放当前访问的页表项。
  7. 页面的共享与保护

    1. 当多个不同进程中需要有相同页面信息时,可以在主存中只保留一个副本,只要让这些进程各自的有关项中指向内存同一块号即可。同时在页表中设置相应的“存取权限”,对不同进程的访问权限进行各种必要的限制。

2、分段存储管理

  1. 基本思想

    1. 页面是主存物理空间中划分出来的等长的固定区域。分页方式的优点是页长固定,因而便于构造页表、易于管理,且不存在外碎片。但分页方式的缺点是页长与程序的逻辑大小不相关。例如,某个时刻一个子程序可能有一部分在主存中,另一部分则在辅存中。这不利于编程时的独立性,并给换入换出处理、存储保护和存储共享等操作造成麻烦。

    2. 另一种划分可寻址的存储空间的方法称为分段。段是按照程序的自然分界划分的长度可以动态改变的区域。通常,程序员把子程序、操作数和常数等不同类型的数据划分到不同的段中,并且每个程序可以有多个相同类型的段。

    3. 段表本身也是一个段,可以存在辅存中,但一般是驻留在主存中。

    4. 将用户程序地址空间分成若干个大小不等的段,每段可以定义一组相对完整的逻辑信息。存储分配时,以段为单位,段与段在内存中可以不相邻接,也实现了离散分配。

  2. 分段地址结构

    1. 作业的地址空间被划分为若干个段,每个段定义了一组逻辑信息。例程序段、数据段等。每个段都从0开始编址,并采用一段连续的地址空间。段的长度由相应的逻辑信息组的长度决定,因而各段长度不等。整个作业的地址空间是二维的。

    2. 在段式虚拟存储系统中,虚拟地址由段号和段内地址组成,虚拟地址到实存地址的变换通过段表来实现。每个程序设置一个段表,段表的每一个表项对应一个段,每个表项至少包括三个字段:有效位(指明该段是否已经调入主存)、段起址(该段在实存中的首地址)和段长(记录该段的实际长度)。

  3. 地址变换

    1. 针对每一个虚拟地址,存储管理部件首先以段号S为索引访问段表的第S个表项。若该表项的有效位为1,则将虚拟地址的段内地址D与该表项的段长字段比较;若段内地址较大则说明地址越界,将产生地址越界中断;

    2. 否则,将该表项的段起址与段内地址相加,求得主存实地址并访存。如果该表项的有效位为0,则产生缺页中断,从辅存中调入该页,并修改段表。段式虚拟存储器虚实地址变换过程如图所示。

    3. 绝对地址=根据段号找到段表中的起始地址+段内地址 (如果段内地址超过限长则产生“地址越界”程序性中断事件达到存储保护)

     

  4. 分段存储方式的优缺点

    1. 分页对程序员而言是不可见的,而分段通常对程序员而言是可见的,因而分段为组织程序和数据提供了方便。与页式虚拟存储器相比,段式虚拟存储器有许多优点:

      (1) 段的逻辑独立性使其易于编译、管理、修改和保护,也便于多道程序共享。

      (2) 段长可以根据需要动态改变,允许自由调度,以便有效利用主存空间。

      (3) 方便编程,分段共享,分段保护,动态链接,动态增长

    2. 因为段的长度不固定,段式虚拟存储器也有一些缺点:

      (1) 主存空间分配比较麻烦。

      (2) 容易在段间留下许多碎片,造成存储空间利用率降低。

      (3) 由于段长不一定是2的整数次幂,因而不能简单地像分页方式那样用虚拟地址和实存地址的最低若干二进制位作为段内地址,并与段号进行直接拼接,必须用加法操作通过段起址与段内地址的求和运算得到物理地址。因此,段式存储管理比页式存储管理方式需要更多的硬件支持。

3、段页式存储

  1. 段页式存储管理的基本思想

    1. 段页式存储组织是分段式和分页式结合的存储组织方法,这样可充分利用分段管理和分页管理的优点。
      用分段方法来分配和管理虚拟存储器。程序的地址空间按逻辑单位分成基本独立的段,而每一段有自己的段名,再把每段分成固定大小的若干页。
      用分页方法来分配和管理实存。即把整个主存分成与上述页大小相等的存储块,可装入作业的任何一页。程序对内存的调入或调出是按页进行的。但它又可按段实现共享和保护。
    2. 逻辑地址结构。一个逻辑地址用三个参数表示:段号S;页号P;页内地址d。
    3. 段表、页表、段表地址寄存器。为了进行地址转换,系统为每个作业建立一个段表,并且要为该作业段表中的每一个段建立一个页表。系统中有一个段表地址寄存器来指出作业的段表起始地址和段表长度。

     

     

     

  1. 地址变换过程

    1. 慢速地址转换过程

      一个逻辑地址为:基地址x、段号s、页号p和页内地址d,求物理地址(((x)+s)+p)*2^(11)+d

    2. 在段页式系统中,为了便于实现地址变换,须配置一个段表寄存器,其中存放段表始址和段表长TL。

      • 1) 进行地址变换时,首先利用段号S,将它与段表长TL进行比较。若S<TL,表示未越界

      • 2) 于是利用段表始址和段号来求出该段所对应的段表项在段表中的位置,从中得到该段的页表始址

      • 3) 利用逻辑地址中的段内页号P来获得对应页的页表项位置,从中读出该页所在的物理块号b

      • 4) 再利用块号b和页内地址来构成物理地址。

      上图示出了段页式系统中的地址变换机构。在段页式系统中,为了获得一条指令或数据,须三次访问内存。第一次访问是访问内存中的段表,从中取得页表始址;第二次访问是访问内存中的页表,从中取出该页所在的物理块号,并将该块号与页内地址一起形成指令或数据的物理地址;第三次访问才是真正从第二次访问所得的地址中,取出指令或数据。

      显然,这使访问内存的次数增加了近两倍。为了提高执行速度,在地址变换机构中增设一个高速缓冲寄存器。每次访问它时,都须同时利用段号和页号去检索高速缓存,若找到匹配的表项,便可从中得到相应页的物理块号,用来与页内地址一起形成物理地址;若未找到匹配表项,则仍须再三次访问内存。

     

  2. 段页式存储管理的优缺点

    1. 优点:

      • (1) 它提供了大量的虚拟存储空间。

      • (2) 能有效地利用主存,为组织多道程序运行提供了方便。

      缺点:

      • (1) 增加了硬件成本、系统的复杂性和管理上的开消。

      • (2) 存在着系统发生抖动的危险。

      • (3) 存在着内碎片。

      • (4) 还有各种表格要占用主存空间。

       段页式存储管理技术对当前的大、中型计算机系统来说,算是最通用、最灵活的一种方案。

  3. 大内存分页

    1. 分页管理,TLB(translation lookaside buffer   TLB原理 - 知乎)是有限的,这点毫无疑问。当超出TLB的存储极限时,就会发生 TLB miss,之后,OS就会命令CPU去访问内存上的页表。如果频繁的出现TLB miss,程序的性能会下降地很快。

    2. 为了让TLB可以存储更多的页地址映射关系,我们的做法是调大内存分页大小。

    3. 如果一个页4M,对比一个页4K,前者可以让TLB多存储1000个页地址映射关系,性能的提升是比较可观的。

    4. 弊端:

      因为每页size变大了,导致JVM在计算Heap内部分区(perm, new, old)内存占用比例时,会出现超出正常值的划分。最坏情况下是,某个区会多占用一个页的大小

4、何为内存对齐

计算机中内存空间都是按照字节(byte)进行划分的,所以从理论上讲对于任何类型的变量访问都可以从任意地址开始,

但是在实际情况中,在访问特定类型变量的时候经常在特定的内存地址访问,所以这就需要把各种类型数据按照一定的规则在空间上排列,

而不是按照顺序一个接一个的排放,这种就称为内存对齐,内存对齐是指首地址对齐,而不是说每个变量大小对齐。

5、为何要有内存对齐

  1. 有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时,将分配的内存进行对齐,这就具有平台可以移植性了
  2. CPU每次寻址都是要消费时间的,并且CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问,所以数据结构应该尽可能地在自然边界上对齐,如果访问未对齐的内存,处理器需要做两次内存访问,而对齐的内存访问仅需要一次访问,内存对齐后可以提升性能。举个例子:
  3. 假设当前CPU是32位的,并且没有内存对齐机制,数据可以任意存放
    1. 现在有一个int32变量占4byte,存放地址在0x00000002 - 0x00000005(纯假设地址,莫当真),这种情况下,每次取4字节的CPU第一次取到[0x00000000 - 0x00000003],
    2. 只得到变量1/2的数据,所以还需要取第二次,为了得到一个int32类型的变量,需要访问两次内存并做拼接处理,影响性能。如果有内存对齐了,int32类型数据就会按照对齐规则在内存中,
    3. 上面这个例子就会存在地址0x00000000处开始,那么处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,使用空间换时间,提高了效率。
  4. 没有内存对齐机制:

     

  5. 内存对齐后:

     

6、内存对齐

详解内存对齐-技术圈参考文章:

详解内存对齐-技术圈

JOL查看对象内存结构 - 掘金

每个特定平台上的编译器都有自己的默认"对齐系数",常用平台默认对齐系数如下:

  • 32位系统对齐系数是4

  • 64位系统对齐系数是8

  • Go演示对齐:

    (Go没有设置对齐系数的入口。C里面有【通过预编译指令#pragma pack(n)来修改对齐系数】)

    package main

    import (

      "fmt"

      "unsafe"

    )

    // User 64位平台,对齐参数是8

    type User struct {

      A int32   // 内存占用大小 4

      B []int32 // 内存占用大小 24

      C string  // 内存占用大小 16

      bool    // 内存占用大小 1

      E string   // 内存占用大小 4

    }

    func main() {

      arr := []int32{}

      var user = User{

        1, arr, "str", false, "2",

      }

      fmt.Println("u size is ", unsafe.Sizeof(user))

    }

  • Java对齐

    • Java JVM参数

      • 内存对齐系数 ,-XX:ObjectAlignmentInBytes= 8(default)

      • 压缩指针,-XX:-UseCompressedOops (default:开启。设置后,关闭)

      参考文章:
      //https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
      //https://zhuanlan.zhihu.com/p/344177133
      
      -XX:ObjectAlignmentInBytes=alignment
      Sets the memory alignment of Java objects (in bytes). By default, the value is set to 8 bytes. 
      The specified value should be a power of two, and must be within the range of 8 and 256 (inclusive). 
      This option makes it possible to use compressed pointers with large Java heap sizes.
      ​
      The heap size limit in bytes is calculated as:
      ​
      4GB * ObjectAlignmentInBytes
      ​
      Note: As the alignment value increases, the unused space between objects will also increase. 
      As a result, you may not realize any benefits from using compressed pointers with large Java heap sizes.
      
      

      package com.share.compresspointer;

      import org.openjdk.jol.info.ClassLayout;

      import java.nio.ByteOrder;

      /**

       * @Author: DiaoRuiqing

       * @Date: 2022/1/4 下午2:55

       * @Description:

       **/

      public class CompressPointer {

          String a;

          char b;

          public static void main(String[] args) {

              CompressPointer o = new CompressPointer();

              System.out.println("------After Initialization------\n" + ClassLayout.parseInstance(o).toPrintable());

              System.out.println(ByteOrder.nativeOrder());

          }

      }

二、JVM内存模型

内存分段分页机制理解_深入理解Java虚拟机(自动内存管理机制)_weixin_39958559的博客-CSDN博客

 

三、CPU缓存

程序优化:CPU缓存基础知识 - 知乎

CPU中的缓存、缓存一致性、伪共享和缓存行填充 - 知乎

CPU缓存(CPU Cache)的目的是为了提高访问内存(RAM)的效率,这虽然已经涉及到硬件的领域,但它仍然与我们息息相关,了解了它的一些原理,能让我们写出更高效的程序,另外在多线程程序中,一些不可思议的问题也与缓存有关。

 

1、缓存分级

  1. 现代多核处理器,一个CPU由多个核组成,每个核又可以有多个硬件线程,比如我们说4核8线程,就是指有4个核,每个核2个线程,这在OS看来就像8个并行处理器一样。

  2. CPU缓存有多级缓存,比如L1, L2, L3等:

    • L1容量最小,速度最快,每个核都有L1缓存,L1又专门针对指令和数据分成L1d(数据缓存),L1i(指令缓存)。

    • L2容量比L1大,速度比L1慢,每个核都有L2缓存。

    • L3容量最大,速度最慢,多个核共享一个L3缓存。

  3. 有些CPU可能还有L4缓存,不过不常见;此外还有其他类型的缓存,比如TLB(translation lookaside buffer),用于物理地址和虚拟地址转译,这不是我们关心的缓存。

  4. 下图展示了缓存和CPU的关系:

     

  5. getconf -a | grep CACHE #mac :sysctl -a | grep ".cpu." | grep 'cache'

    LEVEL1_ICACHE_SIZE                 32768

    LEVEL1_ICACHE_ASSOC                8

    LEVEL1_ICACHE_LINESIZE             64

    LEVEL1_DCACHE_SIZE                 32768

    LEVEL1_DCACHE_ASSOC                8

    LEVEL1_DCACHE_LINESIZE             64

    LEVEL2_CACHE_SIZE                  524288

    LEVEL2_CACHE_ASSOC                 8

    LEVEL2_CACHE_LINESIZE              64

    LEVEL3_CACHE_SIZE                  268435456

    LEVEL3_CACHE_ASSOC                 0

    LEVEL3_CACHE_LINESIZE              64

    LEVEL4_CACHE_SIZE                  0

    LEVEL4_CACHE_ASSOC                 0

    LEVEL4_CACHE_LINESIZE              0

    #上面显示CPU只有3级缓存,L4都为0。

    #L1的数据缓存和指令缓存分别是32KB;L2为256KB;L3为30MB。

    #在缓存和主存之间,数据是按固定大小的块传输的 该块称为缓存行(cache line),这里显示每行的大小为64Bytes。

    #ASSOC表示主存地址映射到缓存的策略,这里L1,L2是8路组相联,L3是20路组相联

2、缓存结构

  1. 一块CPU缓存可以看成是一个数组,数组元素是缓存项(cache entry),一个缓存项的内容大概是这样的:

        +-------------------------------------------+   
        |  tag  |   data block(cache line) |  flag  |   
        +-------------------------------------------+
        +-------------------------------------------+   
        |  tag                | index    | offset   |   
        +-------------------------------------------+
    • tag和缓存项中的tag对应,用来验证是否缓存命中的。

    • index 缓存项数组中的索引。

    • offset 缓存块(cache line)中的偏移,因为缓存块是64字节,而内存值可能只有4个字节,一个缓存块可以保存多个连续的内存值。这个offset实际上就是指明内存值在cache line中的位置。

  2. 为了解决这个问题,缓存将一个内存地址分成下面几个部分:

  3. 缓存首先要解决的问题是:怎么映射内存地址和缓存地址?比如CPU要检查一个内存值是否已经缓存,那么它首先要能算出这个内存地址对应的缓存地址,然后才能检查。

    • data block就是从内存中拷贝过来的数据,也就是我们说的cache line,从上面信息可知大小是64字节。

    • tag 保存了内存地址的一部分,是用来验证是否缓存命中的。

    • flag 是一些标志位,比如缓存是否失效,写dirty等等。

    • 实际上LEVEL1_ICACHE_SIZE这个数据,是用data block来算的,并不包括tag和flag占用的大小,比如64 x 512 = 32768,表示LEVEL1_ICACHE_SIZE可以缓存512个cache line。

3、直接映射缓存

4、N路组相联

5、缓存分配策略和更新策略

四、缓存一致性

参考文章:CPU中的缓存、缓存一致性、伪共享和缓存行填充 - 知乎

1、总线锁和缓存锁

总线锁 :顾名思义就是,锁住总线。通过处理器发出lock指令,总线接受到指令后,其他处理器的请求就会被阻塞,直到此处理器执行完成。这样,处理器就可以独占共享内存的使用。但是,总线锁存在较大的缺点,一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,多处理器的优势就无法发挥

于是,经过发展、优化,又产生了缓存锁。

缓存锁:不需锁定总线,只需要“锁定”被缓存的共享对象(实际为:缓存行)即可,接受到lock指令,通过缓存一致性协议,维护本处理器内部缓存和其他处理器缓存的一致性。相比总线锁,会提高cpu利用率。

但是缓存锁也不是万能,有些场景和情况依然必须通过总线锁才能完成。

这里又出现了两个新概念:缓存行缓存一致性协议

2、缓存行

缓存锁会“锁定”共享对象,如果仅锁定所用对象,那么有大有小、随用随取,对于CPU来说利用率还达不到最大化。所以采用,一次获取一整块的内存数据,放入缓存。

那么这一块数据,通常称为缓存行(cache line)。缓存行(cache line)是CPU缓存中可分配、操作的最小存储单元。

与CPU架构有关,通常有32字节、64字节、128字节不等。目前64位架构下,64字节最为常用。

3、MESI

  1. MESI为缓存一致协议。

  2. MESI是四个单词的首字母缩写

  3. Modified-修改、Exclusive-独占、Shared-共享、Invalid-无效

协议的目的:

  • 尽量从cpu缓存中读取数据,减少cpu与内存的直接交互。 当然与内存交互肯定是难以避免的,比如发生RW(remote write)

  • 保证所有的cpu核心在读写数据过程中数据一致,也就是在读/写的时候,检查状态。同时通过监听总线的RW事件,修改缓存数据的状态。

MESI协议下,cpu缓存的状态变化随着cpu核心对缓存的读写进行改变。

L1L2L3的划分,速度逐渐减慢,容量逐渐变大。 从物理上来说, L1、L2是每个核都有自己的缓存, L3 为所有核心共享的缓存空间。但同一份数据并不是所有的核心都公用一个缓存(哪怕是在L3),他们都会存储/读取自己的数据。 只是在读写的时候的状态有变化。 从内存中过来的数据, 都根据核心(核心ID)进行划分。

状态流转情况

  1. 下图为,Cpu 核心里的缓存数据在不同的状态下,自己做读写,或者从总线监控到其他核心对相同的数据读写的时候针对自己的缓存数据做的状态状态改变的事件。

 

 

4、伪共享问题

缓存一致性协议针对的是最小存取单元:缓存行。依照64字节的缓存行为例,内存中连续的64字节都会被加载到缓存行中,除了目标数据还会有其他数据。

如下图所示,假如变量x和变量y共处在同一缓存行中,core1需要操作变量x,core2需要操作变量y。

    • core1修改缓存行内的变量x后,按照缓存一致性协议,core2需将缓存行置为失效,core1将最新缓存行数据写回内存。

    • core2需重新从内存中加载包含变量y的缓存行数据,并放置缓存。如果core2修改变量y,需要core1将缓存行置为失效,core2将最新缓存写回内存。

    • core1或其他处理器如需操作同一缓存行内的其他数据,同上述步骤。

上述例子,就是缓存行的伪共享问题。总结来说,就是多核多线程并发场景下,多核要操作的不同变量处于同一缓存行,某cpu更新缓存行中数据,并将其写回缓存,同时其他处理器会使该缓存行失效,如需使用,还需从内存中重新加载。这对效率产生了较大的影响。

 

5、 伪共享解决方案

伪共享问题的解决思路有也很典型:空间换时间

以64字节的缓存行为例,伪共享问题产生的前提是,并发情况下,不同cpu对缓存行中不同变量的操作引起的。

那么,如果把缓存行中仅存储目标变量,其余空间采用“无用”数据填充补齐64字节,就不会才产生伪共享问题。这种方式就是:缓存行填充(也称缓存行对齐)

Talk is cheap,show me the code.

package com.share.cacheline;

/**

 * @Author: DiaoRuiqing

 * @Date: 2022/1/5 上午10:44

 * @Description:

 * 1、定义一个长度为2的数组arr,数组中是一个仅有一个long类型变量的对象;

 *

 * 2、定义两个线程A和B,线程A修改arr[0],线程B修改arr[1]。线程A和线程B并发修改1千万次;

 *

 * 3、此处定义数组的目的是:保证线程A和线程B修改的变量尽可能是连续的,即两个变量在同一缓存行中,以模拟伪共享问题。

 *

 * 测试结果:多次运行上述demo,平均耗时 300ms

 **/

public class CacheLinePaddingBefore {

    private static class Entity {

        public volatile long x = 1L;

    }

    public static Entity[] arr = new Entity[2];

    static {

        arr[0] = new Entity();

        arr[1] = new Entity();

    }

    public static void main(String[] args) throws InterruptedException {

        Thread threadA = new Thread(() -> {

            for (long i = 0; i < 1000_0000; i++) {

                arr[0].x = i;

            }

        }, "ThreadA");

        Thread threadB = new Thread(() -> {

            for (long i = 0; i < 1000_0000; i++) {

                arr[1].x = i;

            }

        }, "ThreadB");

        final long start = System.nanoTime();

        threadA.start();

        threadB.start();

        threadA.join();

        threadB.join();

        final long end = System.nanoTime();

        System.out.println("耗时:" + (end - start) / 100_0000);

    }

}

package com.share.cacheline;

/**

 * @Author: DiaoRuiqing

 * @Date: 2022/1/5 上午10:58

 * @Description:

 * ​ 1、定义一个包含7个long类型的“无实际意义”字段的填充对象;

 *

 * ​ 2、实际对象Entity继承填充对象,达到7+1=8个long类型字段,可以填充一整个64字节的缓存行;

 *

 * ​ 3、重复例1中的动作。

 *

 * 测试结果:多次运行上述demo,平均耗时:70ms左右

 **/

public class CacheLinePaddingAfter {

    // 定义7个long类型变量,进行缓存行填充

    private static class Padding{

        public volatile long p1, p2, p3, p4, p5, p6, p7;

    }

    private static class Entity extends Padding{

        // 使用@sun.misc.Contended注解,必须添加此参数:-XX:-RestrictContended

        // @sun.misc.Contended

        public volatile long x = 0L;

    }

    public static Entity[] arr = new Entity[2];

    static {

        arr[0] = new Entity();

        arr[1] = new Entity();

    }

    public static void main(String[] args) throws InterruptedException {

        Thread threadA = new Thread(() -> {

            for (int i = 0; i < 1000_0000; i++) {

                arr[0].x = i;

            }

        }, "ThreadA");

        Thread threadB = new Thread(() -> {

            for (int i = 0; i < 1000_0000; i++) {

                arr[1].x = i;

            }

        }, "ThreadB");

        final long start = System.nanoTime();

        threadA.start();

        threadB.start();

        threadA.join();

        threadB.join();

        final long end = System.nanoTime();

        System.out.println("耗时:" + (end - start)/100_0000);

    }

}

package com.share.cacheline;

/**

 * @Author: DiaoRuiqing

 * @Date: 2022/1/5 上午10:59

 * @Description:

 * Jdk8中引入了@sun.misc.Contended这个注解来解决缓存伪共享问题。使用此注解有一个前提,必须开启JVM参数-XX:-RestrictContended,此注解才会生效。

 *

 * 此注解在一定程度上同样解决了缓存伪共享问题。但底层原理并非缓存行填充,而是通过对对象头内存布局的优化,

 * 将那些可能会被同一个线程几乎同时写的字段分组到一起,避免形成竞争,来达到避免伪共享的目的。此处不再铺开讲述.

 *

 *

 * Jdk内部也大量使用了此注解

 **/

public class CacheLinePaddingAfter2 {

    private static class Entity{

        // 使用@sun.misc.Contended注解,必须添加此参数:-XX:-RestrictContended

        @sun.misc.Contended

        public volatile long x = 0L;

    }

    public static Entity[] arr = new Entity[2];

    static {

        arr[0] = new Entity();

        arr[1] = new Entity();

    }

    public static void main(String[] args) throws InterruptedException {

        Thread threadA = new Thread(() -> {

            for (int i = 0; i < 1000_0000; i++) {

                arr[0].x = i;

            }

        }, "ThreadA");

        Thread threadB = new Thread(() -> {

            for (int i = 0; i < 1000_0000; i++) {

                arr[1].x = i;

            }

        }, "ThreadB");

        final long start = System.nanoTime();

        threadA.start();

        threadB.start();

        threadA.join();

        threadB.join();

        final long end = System.nanoTime();

        System.out.println("耗时:" + (end - start)/100_0000);

    }

}

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值