问题

  • RAM与ROM分别是什么?
  • RAM为什么不能持久化?
  • 内存管理的最小单位是什么?
  • 什么是虚拟内存?
  • cache line的作用?
  • volatile的具体实现?

RAM

RAM作用

内存的存储单元采用了随机读取存储器(RAM, Random Access Memory)。所谓的“随机读取”,是指存储器的读取时间和数据所在位置无关。与之相对,很多存储器的读取时间和数据所在位置有关。就拿磁带来说,我们想听其中的一首歌,必须转动带子。如果那首歌是第一首,那么立即就可以播放。如果那首歌恰巧是最后一首,我们快进到可以播放的位置就需要花很长时间。

RAM的存储

RAM依赖电容器存储数据。电容器充满电后代表1(二进制),未充电的代表0。

这样的话,就依靠电容的刷新来储存数据,比如内存条,这样的话,速度就非常快,只要满电荷和无电荷就能代表计算机二进制里面的0和1。

所以,一停止通电后,RAM里的数据就没了,电就是用来表示数据的。

虚拟内存

在Linux下,进程不能直接读写内存中地址为0x1位置的数据。进程中能访问的地址,只能是虚拟内存地址(virtual memory address)。操作系统会把虚拟内存地址翻译成真实的内存地址。这种内存管理方式,称为虚拟内存(virtual memory)。

每个进程都有自己的一套虚拟内存地址,用来给自己的进程空间编号。进程空间的数据同样以字节为单位,依次增加。从功能上说,虚拟内存地址和物理内存地址类似,都是为数据提供位置索引。进程的虚拟内存地址相互独立。因此,两个进程空间可以有相同的虚拟内存地址,如0x10001000。虚拟内存地址和物理内存地址又有一定的对应关系,如图1所示。对进程某个虚拟内存地址的操作,会被CPU翻译成对某个具体内存地址的操作。

内存页

记录虚拟内存映射关系最简单的办法,就是把对应关系记录在一张表中。为了让翻译速度足够地快,这个表必须加载在内存中。不过,这种记录方式惊人地浪费。如果1GB物理内存的每个字节都有一个对应记录的话,那么光是对应关系就要远远超过内存的空间。由于对应关系的条目众多,搜索到一个对应关系所需的时间也很长。这样的话,会让内存陷入瘫痪。

因此,Linux采用了分页(paging)的方式来记录对应关系。所谓的分页,就是以更大尺寸的单位页(page)来管理内存。在Linux中,通常每页大小为4KB。如果想要获取当前的内存页大小,可以使用命令:

$getconf PAGE_SIZE
4096

内存分页,可以极大地减少所要记录的内存对应关系。我们已经看到,以字节为单位的对应记录实在太多。如果把物理内存和进程空间的地址都分成页,内核只需要记录页的对应关系,相关的工作量就会大为减少。由于每页的大小是每个字节的4000倍。因此,内存中的总页数只是总字节数的四千分之一。对应关系也缩减为原始策略的四千分之一。分页让虚拟内存地址的设计有了实现的可能。

无论是虚拟页,还是物理页,一页之内的地址都是连续的。这样的话,一个虚拟页和一个物理页对应起来,页内的数据就可以按顺序一一对应。这意味着,虚拟内存地址和物理内存地址的末尾部分应该完全相同。大多数情况下,每一页有4096个字节。由于4096是2的12次方,所以地址最后12位的对应关系天然成立。我们把地址的这一部分称为偏移量(offset)。偏移量实际上表达了该字节在页内的位置。地址的前一部分则是页编号。

ROM是什么

ROM全称Read Only Memory,顾名思义,它是一种只能读出事先所存的数据的固态半导体存储器。ROM中所存数据稳定,一旦存储数据就再也无法将之改变或者删除,断电后所存数据也不会消失。其结构简单,因而常用于存储各种固化程序和数据。

CPU

cpu结构

CPU结构

  • 运算单元
    运算单元只管算,例如做加法、做位移等等。但是,它不知道应该算哪些数据,运算结果应该放在哪里

  • 数据单元
    数据单元包括 CPU 内部的缓存和寄存器组,空间很小,但是速度飞快,可以暂时存放数据和运算结果。

  • 控制单元
    控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。

    • 栈寄存器(ss): 栈是程序运行中一个特殊的数据结构,数据的存取只能从一端进行,秉承后进先出的原则,push 就是入栈,pop 就是出栈
    • 指令起始地址寄存器(ip): 指向代码段中下一条指令的位置。CPU 会根据它来不断地将指令从内存的代码段中,加载到 CPU 的指令队列中,然后交给运算单元去执行
    • 数据起始地址寄存器(ds): 通过它可以找到数据在内存中的位置
    • 指令指针寄存器(cs): 通过它可以找到代码在内存中的位置

每个进程在内存中均有自己的代码段及数据段,上述的地址寄存器即指向该进程在内存中对应的地址,当切换进程时,即切换寄存器的值

缓存(cache)

缓存不属于CPU的核心功能,是cpu为了提升效率添加的内存缓存,分为一级缓存 二级缓存 和 三级缓存。其中三级缓存是不同核心共享的。通过缓存中使用的是内存物理地址,而非虚拟地址

cache line

cpu从内存中一次性读取的数据大小,cpu中以cache line为缓存单元,cache line的大小通常为64B

cache line 存储结构

  • tag: cache真的的物理内存地址
  • flag: 指令cache 1bit 用于标识是否已存在该cache;数据cache 2bit用于标识是否存在及是否为脏数据
  • Data: 存储真实数据

cpu cache的存储结构

cpu cache 采用n-ways associative cache的形式存储

如,Cache总大小为32KB,8路组相连(每个set有8个cache line),每个line的大小linesize为64Byte,可以算出一共有32K/8/64=64 个组

该结构相当于Hashmap结构,首先通过物理地址前n位获取该cache 应该所在的位置,然后与cache line中的tag比对物理地址的中间m位,如果存在一致的则cache hit,然后通过物理地址的最后几位获取到对应的数据

如果不存在一致的,则cache miss,cache miss时会将该物理地址所对应的数据加载到cache中,如果cache已满则会通过lru策略淘汰某cache line后缓存新的cache line

cpu cache状态

cpu中的数据缓存中的flag共2bit,可表示四个状态分别为M/E/S/I

  • M-修改(Modified): 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
  • E-独享: 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。
  • S-共享: 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。
  • I-无效: 该Cache line无效。

当同一物理地址对应的数据cache line 在某cpu核心中cache的同时,会调整该cache line 在其它cpu核心cache中的状态

store buffer

当cpu0 cacheline 出现修改时,如果同步通知其它cpu 该cacheline 无效,该时间段内CPU都会等待所有缓存通知响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。

为了解决上述问题,每个CPU内核引入了Store Buffers(仅限于x64架构。x86架构是强一致性,无需Store Buffers)。导致了指令重排序:涉及到缓存一致性问题的数据更新指令暂存到StoreBufferes中,其他不涉及缓存一致性问题的指令先执行。涉及到缓存一致性问题的数据更新暂存到StoreBufferes后,使其他内核的高速缓存中相同数据失效,然后StoreBuffers中的数据同步到主内存,其他CPU内核的高速缓存中相同数据从主内存重新复制。

store buffer的使用导致在程序中先后对a/b进行赋值时,当a/b cache line 状态不同时,a/b内存中值修改的顺序不一定为cpu真实修改的顺序

失效队列

Store Buffere 对写入缓存的操作进行了优化,不过Store Buffere是一块很小的空间,当指令过多Store Buffere满了之后,那么新来的指令还是得同步发送指令给其他CPU,而CPU总是很忙的,如果此时收到消息的CPU在干其它的事情,没法即时给发消息的人回复,那么这个发消息的人就会陷入等待了,只有所有CPU都回复之后,再把最新的数据同步到缓存和主存里去。

所以为了避免因为接收消息的CPU繁忙无法即时处理Invalid失效数据的消息,而造成CPU指令的等待,所以就在接收CPU的那方也加上了一个异步消息队列,消息发送方把数据失效消息发送到这个“失效队列里面”,然后就返回认为接收方已经恢复了,所以就可以继续执行下面的流程了,而消息接收方也可以在自己有时间的时候再来处理“失效队列”的消息。

内存屏障

cpu通过“Store Forward”解决了当前CPU读取数据的问题,但是并没有解决其他CPU读取数据的问题。当CPU-1修改数据的时候,在写入store buffer完成之后,此时其它CPU接到了读取共享变量a数据的指令,就会从自己的缓存读取数据,但是很显然此时其他CPU缓存的共享变量a可能还是旧值,或者此时共享变量a的数据可能已经收到invalid通知调整为Invalid的,那么就会从主存里面去查询的数据,但是不幸的是主存里面的数据也还没有更新,所以这个时候读到的数据a还是旧值。

那么又如何解决呢?cpu引入了内存屏障机制。使用了内存屏障后,写入数据时候会保证所有的指令都执行完毕,这样就能保证修改过的数据能即时的暴露给其他的CPU。在读取数据的时候保证所有的“无效队列”消息都已经被读取完毕,这样就保证了其他CPU修改的数据消息都能被当前CPU知道,然后根据Invalid消息判断自己的缓存是否处于无效状态,这样就读取数据的时候就能正确的读取到最新的数据。

  • Store Barrier(写屏障)
    这个屏障的意思就是只要看到Store Barrier指令了,那么就必须把store buffer中的所有写入指令执行完毕(即写入主存)才可以往下执行,通过这种方式就可以让CPU修改的数据可以马上暴露给其他CPU。
  • Load Barrier(读屏障)
    这个指令的意思是,保证该指令前的load指令已经完成,并且已应用所有已经在失效队列中的失效操作的指令,在Load屏障指令执行后就能保证后面的读取数据指令一定能读取到最新的数据。

读写屏障只能解决cpu0与cpu1不同变量修改可见顺序问题,并不能解决cpu0修改后cpu1立刻就能获取到新值的问题,即即使使用了内存屏障,cpu0修改了变量,cpu1也可能获取到旧值,如cpu1还未收到invalid通知,或收到并已应用invalid,但是内存中仍为旧值。但是解决了cpu0先后修改了a和b,在cpu1中查看b为新值,a为旧值的问题

内存屏障有两个作用

  • 阻止屏障两侧的指令重排序
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

volatile可见性的实现

volatile通过内存屏障解决了变量的可见性问题。

java内存屏障

JVM Load和Store指令

  • Store:将处理器缓存的数据刷新到内存中。
  • Load:将内存存储的数据拷贝到处理器的缓存中。

对于Load和Store,JVM内存屏障在实际使用中,又分为以下四种:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作已写入主内存。即在store2的store指令前加入Store Barrier。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。即在store2的store指令前加入Load Barrier。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。即在load2的load指令前加入Store Barrier,这样保证数据及时写入主存,读取数据能够读取主存中有效的数据

jvm的上述四种内存屏障主要用于jit,只有StoreLoad会被替换成具体指令(mfence),其它三种内存屏障是空操作(no-op),用于保证即时编译的的机器码顺序。当写入某个volatile变量时,会强制刷新缓存至内存中,由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该 volatile 字段的最新值。

volatile的实现

public static final int JMM_PRE_VOLATILE_READ = 0;
public static final int JMM_POST_VOLATILE_READ = LOAD_LOAD | LOAD_STORE;
public static final int JMM_PRE_VOLATILE_WRITE = LOAD_STORE | STORE_STORE;
public static final int JMM_POST_VOLATILE_WRITE = STORE_LOAD | STORE_STORE;

MemoryBarriers

volatileJMM内存屏障插入策略

  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障;
  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;

volatile读

WX20201223-113710@2x

  • LoadLoad屏障保证了volatile读不会与下面的普通读发生重排
  • LoadStore屏障保证了volatile读不回与下面的普通写发生重排。

volatile写

WX20201223-113650@2x

  • StoreStore屏障可以保证在volatile写之前,所有的普通写操作已经对所有处理器可见,StoreStore屏障保障了在volatile写之前所有的普通写操作已经刷新到主存。
  • StoreLoad屏障避免volatile写与下面有可能出现的volatile读/写操作重排。因为编译器无法准确判断一个volatile写后面是否需要插入一个StoreLoad屏障(写之后直接就return了,这时其实没必要加StoreLoad屏障),为了能实现volatile的正确内存语意,JVM采取了保守的策略。在每个volatile写之后或每个volatile读之前加上一个StoreLoad屏障,而大多数场景是一个线程写volatile变量多个线程去读volatile变量,同一时刻读的线程数量其实远大于写的线程数量。选择在volatile写后面加入StoreLoad屏障将大大提升执行效率(上面已经说了StoreLoad屏障的开销是很大的)。

参考1