内存屏障 又称内存栅栏 是一组处理器指令 用于实现对内存 *** 作的顺序限制 本文假定读者已经充分掌握了相关概念和Java内存模型 不讨论并发互斥 并行机制和原子性 内存屏障用来实现并发编程中称为可见性(visibility)的同样重要的作用
内存屏障为何重要?
对主存的一次访问一般花费硬件的数百次时钟周期 处理器通过缓存(caching)能够从数量级上降低内存延迟的成本这些缓存为了性能重新排列待定内存 *** 作的顺序 也就是说 程序的读写 *** 作不一定会按照它要求处理器的顺序执行 当数据是不可变的 同时/或者数据限制在线程范围内 这些优化是无害的
如果把这些优化与对称多处理(symmetric multi processing)和共享可变状态(shared mutable state)结合 那么就是一场噩梦 当基于共享可变状态的内存 *** 作被重新排序时 程序可能行为不定 一个线程写入的数据可能被其他线程可见 原因是数据 写入的顺序不一致 适当的放置内存屏障通过强制处理器顺序执行待定的内存 *** 作来避免这个问题
内存屏障的协调作用
内存屏障不直接由JVM暴露 相反它们被JVM插入到指令序列中以维持语言层并发原语的语义 我们研究几个简单Java程序的源代码和汇编指令 首先快速看一下Dekker算法中的内存屏障 该算法利用volatile变量协调两个线程之间的共享资源访问
请不要关注该算法的出色细节 哪些部分是相关的?每个线程通过发信号试图进入代码第一行的关键区域 如果线程在第三行意识到冲突(两个线程都要访问) 通 过turn变量的 *** 作来解决 在任何时刻只有一个线程可以访问关键区域
// code run by first thread // code run by second thread
intentFirst = true; intentSecond = true;
while (intentSecond) while (intentFirst) // volatile read
if (turn != ) { if (turn != ) { // volatile read
intentFirst = false; intentSecond = false;
while (turn != ) {} while (turn != ) {}
intentFirst = true; intentSecond = true;
} }
criticalSection(); criticalSection();
turn = ; turn = ; // volatile write
intentFirst = false; intentSecond = false; // volatile write
硬件优化可以在没有内存屏障的情况下打乱这段代码 即使编译器按照程序员的想法顺序列出所有的内存 *** 作 考虑第三 四行的两次顺序volatile读 *** 作 每一个线程检查其他线程是否发信号想进入关键区域 然后检查轮到谁 *** 作了 考虑第 行的两次顺序写 *** 作 每一个线程把访问权释放给其他线程 然后撤销自己访问关键区域的意图 读线程应该从不期望在其他线程撤销访问意愿后观察到其他线程对turn变量的写 *** 作 这是个灾难
但是如果这些变量没有 volatile修饰符 这的确会发生!例如 没有volatile修饰符 第二个线程在第一个线程对turn执行写 *** 作(倒数第二行)之前可能会观察到 第一个线程对intentFirst(倒数第一行)的写 *** 作 关键词volatile避免了这种情况 因为它在对turn变量的写 *** 作和对 intentFirst变量的写 *** 作之间创建了一个先后关系 编译器无法重新排序这些写 *** 作 如果必要 它会利用一个内存屏障禁止处理器重排序 让我们来 看看一些实现细节
PrintAssembly HotSpot选项是JVM的一个诊断标志 允许我们获取JIT编译器生成的汇编指令 这需要最新的OpenJDK版本或者新HotSpot update 或者更高版本 通过需要一个反编译插件 Kenai项目提供了用于Solaris Linux和BSD的插件二进制文件 hsdis是另 一款可以在Windows通过源码构建的插件
两次顺序读 *** 作的第一次(第三行)的汇编指令如下 指令流基于Itanium 多处理硬件 JDK update 本文的所有指令流都在左手边以行号标记 相关的读 *** 作 写 *** 作和内存屏障指令都以粗体标记 建议读者不要沉迷于每一行指令
x de c: adds r = r ;; ;
x de a : ld acq r =[r ];; ; b a a
x de a : nop m x ; c
x de ac: sxt r r =r ;; ;
x de b : cmp eq p p = r ; c
x de b : nop i x ;
x de bc: nd dpnt many x de ;
简短的指令流其实内容丰富 第一次volatile位于第二行 Java内存模型确保了JVM会在第二次读 *** 作之前将第一次读 *** 作交给处理器 也就是按照 程序的顺序 但是这单单一行指令是不够的 因为处理器仍然可以自由乱序执行这些 *** 作 为了支持Java内存模型的一致性 JVM在第一次读 *** 作上添加了注解ld acq 也就是 载入获取 (load acquire) 通过使用ld acq 编译器确保第二行的读 *** 作在接下来的读 *** 作之前完成 问题就解决了
请注意这影响了读 *** 作 而不是写 内存屏障强制读或写 *** 作顺序限制不是单向的 强制读和写 *** 作顺序限制的内存屏障是双向的 类似于双向开的栅栏 使用ld acq就是单向内存屏障的例子
一致性具有两面性 如果一个读线程在两次读 *** 作之间插入了内存屏障而另外一个线程没有在两次写 *** 作之间添加内存屏障又有什么用呢?线程为了协调 必须同时 遵守这个协议 就像网络中的节点或者团队中的成员 如果某个线程破坏了这个约定 那么其他所有线程的努力都白费 Dekker算法的最后两行代码的汇编指令应该插入一个内存屏障 两次volatile写之间
$ java XX:+UnlockDiagnosticVMOptions XX:PrintAssemblyOptions=hsdis print bytes
XX:CompileCommand=print WriterReader write WriterReader
x de c : adds r = r ;; ; b
x de c : st rel [r ]=r ;
x de cc: adds r = r ;; ;
x de d : st rel [r ]=r ; a
x de d : mf ;
x de dc: nop i x ;; ;
x de e : mov r =r ;
x de e : mov ret b =r x de e
x de ec: mov i ar pfs=r ; aa
x de f : mov r =r ;
这里我们可以看到在第四行第二次写 *** 作被注解了一个显式内存屏障 通过使用st rel 即 存储释放 (store release) 编译器确保第一次写 *** 作在第二次写 *** 作之前完成 这就完成了两边的约定 因为第一次写 *** 作在第二次写 *** 作之前发生
st rel屏障是单向的 就像ld acq一样 但是在第五行编译器设置了一个双向内存屏障 mf指令 或者称为 内存栅栏 是Itanium 指令集中的完整栅栏 笔者认为是多余的
内存屏障是特定于硬件的
本文不想针对所有内存屏障做一综述 这将是一件不朽的功绩 但是 重要的是认识到这些指令在不同的硬件体系中迥异 下面的指令是连续写 *** 作在多处理 Intel Xeon硬件上编译的结果 本文后面的所有汇编指令除非特殊声明否则都出自于Intel Xeon
x f c: push %ebp ;
x f d: sub $ x %esp ; ec
x f : mov $ x c %edi ; bf c
x f : movb $ x x a f (%edi) ; c d a af
x f f: mfence ; faef
x f : mov $ x %ebp ; bd
x f : mov $ x d %edx ; ba d
x f c: mov l x a f (%edx) %ebx ; fbe a da af
x f : test %ebx %ebx ; db
x f : jne x f ;
x f : movl $ x x a f (%ebp) ; c d a af
x f : movb $ x x a f (%edi) ; c d a af
x f : mfence ; faef
x f b: add $ x %esp ; c
x f e: pop %ebp ; d
我们可以看到x Xeon在第 行执行两次volatile写 *** 作 第二次写 *** 作后面紧跟着mfence *** 作 显式的双向内存屏障 下面的连续写 *** 作基于SPARC
xfb ecc : ldub [ %l + x ] %l ; e c
xfb ecc : cmp %l ; a e
xfb ecc c: bne pn %icc xfb eccb ;
xfb ecc : nop ;
xfb ecc : st %l [ %l + x ] ; e
xfb ecc : clrb [ %l + x ] ; c c
xfb ecc c: membar #StoreLoad ; e
xfb ecca : sethi %hi( xff fc ) %l ; fcff
xfb ecca : ld [ %l ] %g ; c
xfb ecca : ret ; c e
xfb eccac: restore ; e
我们看到在第五 六行存在两次volatile写 *** 作 第二次写 *** 作后面是一个membar指令 显式的双向内存屏障 x 和SPARC的指令流与Itanium的指令流存在一个重要区别 JVM在x 和SPARC上通过内存屏障跟踪连续写 *** 作 但是在两次写 *** 作之间没有放置内存屏障
另一方面 Itanium的指令流在两次写 *** 作之间存在内存屏障 为何JVM在不同的硬件架构之间表现不一?因为硬件架构都有自己的内 存模型 每一个内存模型有一套一致性保障 某些内存模型 如x 和SPARC等 拥有强大的一致性保障 另一些内存模型 如Itanium PowerPC和Alpha 是一种弱保障
例如 x 和SPARC不会重新排序连续写 *** 作 也就没有必要放置内存屏障 Itanium PowerPC和Alpha将重新排序连续写 *** 作 因此JVM必须在两者之间放置内存屏障 JVM使用内存屏障减少Java内存模型和硬件内存模型之间的距离
隐式内存屏障
显式屏障指令不是序列化内存 *** 作的唯一方式 让我们再看一看Counter类这个例子
class Counter{
static int counter = ;
public static void main(String[] _){
for(int i = ; i < ; i++)
inc();
}
static synchronized void inc(){ counter += ; }
}
Counter类执行了一个典型的读 修改 写的 *** 作 静态counter字段不是volatile的 因为所有三个 *** 作必须要原子可见的 因此 inc 方法是synchronized修饰的 我们可以采用下面的命令编译Counter类并查看生成的汇编指令 Java内存模型确保了synchronized区域的退出和volatile内存 *** 作都是相同的可见性 因此我们应该预料到会有另一个内存屏障
$ java XX:+UnlockDiagnosticVMOptions XX:PrintAssemblyOptions=hsdis print bytes
XX: UseBiasedLocking XX:CompileCommand=print Counter inc Counter
x d eda : push %ebp ;
x d eda : mov %esp %ebp ; bec
x d edaa: sub $ x %esp ; ec
x d edad: mov $ x ba %esi ; be ba
x d edb : lea x (%esp) %edi ; d c
x d edb : mov %esi x (%edi) ;
x d edb : mov (%esi) %eax ; b
x d edbb: or $ x %eax ; c
x d edbe: mov %eax (%edi) ;
x d edc : lock cmpxchg %edi (%esi) ; f fb e
x d edc : je x d edda ; f
x d edca: sub %esp %eax ; bc
x d edcc: and $ xfffff %eax ; e f ffff
x d edd : mov %eax (%edi) ;
x d edd : jne x d ee ; f
x d edda: mov $ x ba b %eax ; b b ba
x d eddf: mov x (%eax) %esi ; bb
x d ede : inc %esi ;
x d ede : mov %esi x (%eax) ; b
x d edec: lea x (%esp) %eax ; d
x d edf : mov (%eax) %esi ; b
x d edf : test %esi %esi ; f
x d edf : je x d ee ; f d
x d edfa: mov x (%eax) %edi ; b
x d edfd: lock cmpxchg %esi (%edi) ; f fb
x d ee : jne x d ee f ; f
x d ee : mov %ebp %esp ; be
x d ee : pop %ebp ; d
不出意外 synchronized生成的指令数量比volatile多 第 行做了一次增 *** 作 但是JVM没有显式插入内存屏障 相反 JVM通过在 第 行和第 行cmpxchg的lock前缀一石二鸟 cmpxchg的语义超越了本文的范畴
lock cmpxchg不仅原子性执行写 *** 作 也会刷新等待的读写 *** 作 写 *** 作现在将在所有后续内存 *** 作之前完成 如果我们通过ncurrent atomic AtomicInteger 重构和运行Counter 将看到同样的手段
import ncurrent atomic AtomicInteger;
class Counter{
static AtomicInteger counter = new AtomicInteger( );
public static void main(String[] args){
for(int i = ; i < ; i++)
counter incrementAndGet();
}
}
$ java XX:+UnlockDiagnosticVMOptions XX:PrintAssemblyOptions=hsdis print bytes
XX:CompileCommand=print AtomicInteger incrementAndGet Counter
x f : push %ebp ;
x f : mov %esp %ebp ; bec
x fa: sub $ x %esp ; ec
x fd: jmp x a ; e
x : xchg %ax %ax ;
x : test %eax xb e ; e b
x a: mov x (%ecx) %eax ; b
x d: mov %eax %esi ; bf
x f: inc %esi ;
x : mov $ x a f d %edi ; bfd f a
x : mov x (%edi) %edi ; bbf
x b: mov %ecx %edi ; bf
x d: add $ x %edi ; c
x : lock cmpxchg %esi (%edi) ; f fb
x : mov $ x %eax ; b
x : je x ; f
x f: mov $ x %eax ; b
x : cmp $ x %eax ; f
x : je x ; cb
x : mov %esi %eax ; bc
x b: mov %ebp %esp ; be
x d: pop %ebp ; d
我们又一次在第 行看到了带有lock前缀的写 *** 作 这确保了变量的新值(写 *** 作)会在其他所有后续内存 *** 作之前完成
内存屏障能够避免
JVM非常擅于消除不必要的内存屏障 通常JVM很幸运 因为硬件内存模型的一致性保障强于或者等于Java内存模型 在这种情况下 JVM只是简单地插 入一个no op语句 而不是真实的内存屏障
例如 x 和SPARC内存模型的一致性保障足够强壮以消除读volatile变量时所需的内存屏障 还记得在 Itanium上两次读 *** 作之间的显式单向内存屏障吗?x 上的Dekker算法中连续volatile读 *** 作的汇编指令之间没有任何内存屏障 x 平台上共享内存的连续读 *** 作
x f : mov $ x %ebp ; bd
x f : mov $ x d %edx ; ba d
x f c: mov l x a f (%edx) %ebx ; fbe a da af
x f : test %ebx %ebx ; db
x f : jne x f ;
x f : movl $ x x a f (%ebp) ; c d a af
x f : movb $ x x a f (%edi) ; c d a af
x f : mfence ; faef
x f b: add $ x %esp ; c
x f e: pop %ebp ; d
x f f: test %eax xb ec ; c eb
x f : ret ; c
x f : nopw x (%eax %eax ) ; f f
x f : mov x a f (%ebp) %ebx ; b d d a af
x f : test %edi xb ec ; d c eb
第三行和第十四行存在volatile读 *** 作 而且都没有伴随内存屏障 也就是说 x 和SPARC上的volatile读 *** 作的性能下降对于代码的优 化影响很小 指令本身和常规读 *** 作一样
单向内存屏障本质上比双向屏障性能要好一些 JVM在确保单向屏障即可的情况下会避免使用双向屏障 本文的第一个例子展示了这点 Itanium平台上的 连续两次读 *** 作 入单向内存屏障 如果读 *** 作插入显式双向内存屏障 程序仍然正确 但是延迟比较长
动态编译
静态编译器在构建阶段决定的一切事情 在动态编译器那里都可以在运行时决定 甚至更多 更多信息意味着存在更多机会可以优化 例如 让我们看看JVM在单 处理器运行时如何对待内存屏障 以下指令流来自于通过Dekker算法实现两次连续volatile写 *** 作的运行时编译 程序运行于 x 硬件上的单处理器模式中的VMWare工作站镜像
x b c: push %ebp ;
x b d: sub $ x %esp ; ec
x b : mov $ x c %edi ; bf c
x b : movb $ x x f (%edi) ; c d aaf
x b f: mov $ x %ebp ; bd
x b : mov $ x d %edx ; ba d
x b : mov l x f (%edx) %ebx ; fbe a d aaf
x b : test %ebx %ebx ; db
x b : jne x b ; c
x b : movl $ x x f (%ebp) ; c d aaf
x b : add $ x %esp ; c
x b : pop %ebp ; d
在单处理器系统上 JVM为所有内存屏障插入了一个no op指令 因为内存 *** 作已经序列化了 每一个写 *** 作(第 行)后面都跟着一个屏障 JVM针对原子条件式做了类似的优化 下面的指令流来自于同一 个VMWare镜像的AtomicInteger incrementAndGet动态编译结果
x f : push %ebp ;
x f : mov %esp %ebp ; bec
x fa: sub $ x %esp ; ec
x fd: jmp x a ; e
x : xchg %ax %ax ;
x : test %eax xb b ; bb
x a: mov x (%ecx) %eax ; b
x d: mov %eax %esi ; bf
x f: inc %esi ;
x : mov $ x a f d %edi ; bfd f a
x : mov x (%edi) %edi ; bbf
x b: mov %ecx %edi ; bf
x d: add $ x %edi ; c
x : cmpxchg %esi (%edi) ; fb
x : mov $ x %eax ; b
x : je x ; f
x e: mov $ x %eax ; b
x : cmp $ x %eax ; f
x : je x ; cc
x : mov %esi %eax ; bc
x a: mov %ebp %esp ; be
x c: pop %ebp ; d
注意第 行的cmpxchg指令 之前我们看到编译器通过lock前缀把该指令提供给处理器 由于缺少SMP JVM决定避免这种成本 与静态编译有些不同
结束语
lishixinzhi/Article/program/Java/hx/201311/25723
接口类型,是根据内存条金手指上导电触片的数量来划分的。金手指上的导电触片,也习惯称为针脚数(Pin)。因为不同的内存采用的接口类型各不相同,而每种接口类型所采用的针脚数各不相同。下面就让我带你去看看关于内存条的基础知识吧,希望能帮助到大家!
内存知识 详解:接口类型
1、金手指
金手指(connecting finger)是内存条上与内存插槽之间的连接部件,所有的信号都是通过金手指进行传送的。金手指由众多金**的导电触片组成,因其表面镀金而且导电触片排列如手指状,所以称为“金手指”。金手指实际上是在覆铜板上通过特殊工艺再覆上一层金,因为金的抗氧化性极强,而且传导性也很强。不过,因为金昂贵的价格,目前较多的内存都采用镀锡来代替。从上个世纪 90 年代开始,锡材料就开始普及,目前主板、内存和显卡等设备的“金手指”,几乎都是采用的锡材料,只有部分高性能服务器/工作站的配件接触点,才会继续采用镀金的做法,价格自然不菲。
内存的金手指
内存处理单元的所有数据流、电子流,正是通过金手指与内存插槽的接触与 PC 系统进行交换,是内存的输出输入端口。因此,其制作工艺,对于内存连接显得相当重要。
2、内存插槽
最初的计算机系统,通过单独的芯片安装内存,那时内存芯片都采用 DIP(Dual ln-line Package,双列直插式封装)封装,DIP 芯片是通过安装在插在总线插槽里的内存卡与系统连接,此时还没有正式的内存插槽。DIP 芯片有个最大的问题,就在于安装起来很麻烦,而且随着时间的增加,由于系统温度的反复变化,它会逐渐从插槽里偏移出来。随着每日频繁的计算机启动和关闭,芯片不断被加热和冷却,慢慢地芯片会偏离出插槽。最终导致接触不好,产生内存错误。
内存插槽
早期还有另外一种 方法 ,是把内存芯片直接焊接在主板或扩展卡里,这样有效避免了 DIP 芯片偏离的问题,但无法再对内存容量进行扩展,而且如果一个芯片发生损坏,整个系统都将不能使用,只能重新焊接一个芯片或更换包含坏芯片的主板。此种方法付出的代价较大,也极为不便。
对于内存存储器,大多数现代的系统,都已采用单列直插内存模块(Single Inline Memory Module,SIMM)或双列直插内存模块(Dual Inline Memory Module,DIMM)来替代单个内存芯片。这些小板卡插入到主板或内存卡上的特殊连接器里。
3、内存模块
1) SIMM
SIMM(Single Inline Memory Module,单列直插内存模块)。内存条通过金手指与主板连接,内存条正反两面都带有金手指。金手指可以在两面提供不同的信号,也可以提供相同的信号。SIMM 就是一种两侧金手指都提供相同信号的内存结构,它多用于早期的 FPM 和 EDD DRAM,最初一次只能传输 8bif 数据,后来逐渐发展出 16bit、32bit 的 SIMM 模组。其中,8bit 和 16bit SIMM 使用 30pin 接口,32bit 的则使用72pin 接口。在内存发展进入 SDRAM 时代后,SIMM 逐渐被 DIMM 技术取代。
2) DIMM
DIMM(Dual Inline Memory Module,双列直插内存模块)。与 SIMM 相当类似,不同的只是 DIMM 的金手指两端,不像 SIMM 那样是互通的,它们各自独立传输信号。因此,可以满足更多数据信号的传送需要。同样采用 DIMM,SDRAM 的接口与 DDR 内存的接口也略有不同,SDRAMDIMM 为 168Pin DIMM 结构,金手指每面为 84Pin,金手指上有两个卡口,用来避免插入插槽时,错误将内存反向插入而导致烧毁;
DDR DIMM则采用 184Pin DIMM 结构,金手指每面有 92Pin,金手指上只有一个卡口。卡口数量的不同,是二者最为明显的区别。DDR2 DIMM 为240pinDIMM 结构,金手指每面有 120Pin,与 DDR DIMM 一样金手指一样,也只有一个卡口,但是卡口的位置与 DDR DIMM 稍微有一些不同。因此,DDR 内存是插不进 DDR2 DIMM 的,同理 DDR2 内存也是插不进 DDR DIMM 的。因此,在一些同时具有 DDR DIMM 和 DDR2 DIMM 的主板上,不会出现将内存插错插槽的问题。
不同针脚 DIMM 接口对比。为了满足 笔记本 电脑对内存尺寸的要求,SO-DIMM(Small Outline DIMM Module)也开发了出来,它的尺寸比标准的 DIMM 要小很多,而且引脚数也不相同。同样 SO-DIMM 也根据 SDRAM 和 DDR 内存规格不同而不同。SDRAM 的 SO-DIMM 只有 144pin引脚,而DDR 的 SO-DIMM 拥有 200pin 引脚。此外,笔记本内存还有 MicroDIMM 和 Mini Registered DIMM 两种接口。MicroDIMM 接口的DDR 为 172pin,DDR2 为 214pin;Mini Registered DIMM 接口为 244pin,主要用于 DDR2 内存。
3) RIMM
RIMM(Rambus Inline Memory Module)是 Rambus 公司生产的 RDRAM 内存所采用的接口类型。RIMM 内存与 DIMM 的外型尺寸差不多,金手指同样也是双面的。RIMM 有也 184 Pin 的针脚,在金手指的中间部分有两个靠的很近的卡口。RIMM 非 ECC 版有 16 位数据宽度,ECC 版则都是 18 位宽。由于 RDRAM 内存较高的价格,此类内存在 DIY 市场很少见到,RIMM 接口也就难得一见了。
基础知识(入门篇)
关于01
我们初学编程时,只知道编写代码,运行程序,却不知道程序是在什么的基础上运行的。只知道声明变量,给变量赋值,数据存储在变量中,却不知道变量是以什么形式存在。
《内存》可以参考《计算机组成原理》和《微机原理》书籍,而作为信息学奥赛的同学们,只需了解《内存》的一些基础知识即可,不必深究。
关于《计算机组成原理》,可参考 文章 :
计算机组成原理(入门篇)
目录02
1、内存的内部结构
2、数据是如何存储在内存中
3、数据在内存中的表现形式
4、存储单元的大小
5、如何从内存中寻找指定的数据(内存地址)
概要03
本篇主要讲解有关《内存》的基础知识,有助于自己在编程上的进一步提升。
为什么学习《内存》的知识可以提升自己对编程进一步的认识呢
其实,我们学习信奥(C/C++)时,一般只是学习C/C++的相关语法。当我们练习多了,可以熟练地运用各种语法。我们也知道如何将1+1赋给一个int类型变量,也知道不能把整数1赋给string类型变量(对象)。但是1+1赋值 *** 作在内存中是如何实现的呢为什么浮点型存在误差为什么int类型与string类型不能直接赋值 *** 作
我们只知道编写的程序在内存中运行,却不知道数据在内存中是如何存储的。就好比只看到书籍的封面,但不知道书中的内容。
要求04
在学习《内存》之前,我们只需掌握C/C++一些基础知识,可以独立解决一些简单的问题即可。
内存的内部结构
对于信息学奥赛的同学们来说,《内存》这一概念比较抽象。不过,经过阅读文章《计算机组成原理(入门篇)》后,相信同学们对内存的概念清晰了不少,至少知道内存是用来存储程序运行的相关数据。
常用数据一般存储在硬盘中,如果对这些数据进行处理(例如使用Word写一篇文章),并不是CPU直接对硬盘的文件进行 *** 作,而是从硬盘相对应的位置把该文件的数据读取到内存中,CPU再对内存中的数据进行处理。简单地说,《内存》是CPU与硬盘进行沟通的“桥梁”。当然,并不一定是硬盘,平时存储数据的设备还有U盘等,统称为外存。
《内存》内部由数以亿计的纳米级电子元件构成。
如上图,内存条由存储芯片、金手指、电路组成。
存储芯片:黑色的方块。每个方块由很多的晶体管组成,可以理解为数据就存储在晶体管中。
金手指:底部的金色金属片。内存条插在主板的内存条插槽中,实际上与插槽接触的部位就是金手指。如此一来,CPU就可以通过主板与内存进行通信。
电路:绿色面板。面板中有许多细微的线路和电阻等电子元件,用于数据的传输。
数据是如何存储在内存中
家里控制电灯的开关,电脑的开关。存储芯片中的晶体管也是如此。程序运行的数据存储在晶体管中。
如上图,每个方格代表一个晶体管。
如下图,每个晶体管都有独立的开关,通电时开,断电时关。此处用白色表示开,黑色表示关。
一个数值并不是只存储在一个晶体管中,是多个晶体管。而多个晶体管构成一个存储单元。
存储单元的大小
存储单元有大小,而一个存储单元的大小是8位(bit)。
内存中常用的存储单位是:位(bit)、字节(Byte)。
1字节=8位
那么一个存储单元也是1字节。
关于《存储单位》的相关知识,会以一篇独立的文章详细讲解。
数据在内存中的表现形式
数据在内存中是以二进制的形式存储。
十进制是由0~9组成,而二进制是由0和1组成。
如上图,这是一个存储单元(8bit),有8个格子,一个格子表示1bit。而每一个格子的值要么0,要么为1。其中,白色表示开,黑色表示关,一般用1和0分别表示开和关。那么用二进制表示是01101001,转换为十进制的值是105,所以该存储单元存储的值就是105。
关于《进制》的相关知识,会以一篇独立的文章详细讲解。
此处只讲解数字数据,其他数据的表现形式很复杂。
如何从内存中寻找指定的数据(内存地址)
先举个例子:
如上图,有几栋楼房,我们都知道,每一栋楼都有一个具体的地址,而一栋楼的每家每户都有各自的房号,从而组成一个完整的地址。而我们的个人居民身份z就有一个详细地址。
居民身份z除了有地址外,还有身份z号码,每一个号码都是唯一。
如何从内存中寻找指定的数据
内存中的存储单元就像人一样,都有着独一无二的“身份z号码”,就是地址。比如警察叔叔根据身份z号码就能查到对应的个人信息。
再举个例子:
如上图,这是一个书柜,又分成若干个小柜子,现对每个小柜子进行分类放置书籍并设置标签。我们要寻找某一本书时,根据标签就可以轻松找到。程序运行时也是一样,知道要在什么地址进行数据的读写 *** 作。
其他疑问
为什么要分十进制而二进制
简单说,十进制是给人用的,而二进制是给机器用的。
数据有数字、字母、符号、声音、图像等等。数据是以二进制的形式存储在内存中。
内存数据输出到 显示器 时,为什么可以显示我们人类能看懂的信息
内存中的数据是经过转换处理后,我们才能看懂。我们所看到的数据(例如一篇文章、一张照片、一部**),它们的本质还是二进制。
你不知道的内存知识
一、CPU与内存
先铺垫几个概念,以免后面混乱:
Socket或Processor: 指一个物理CPU芯片,盒装还是散装的。上面有很多针脚,直接安装在主板上。
Core : 指在Processor里封装一个CPU核心,每个Core都是完全独立的计算单元,我们平时说的4核心CPU,指的就是Processor里面封装了4个Core。
HT超线程:目前Intel与AMD的Processor大多支持在一个Core里并行执行两个线程,此时从 *** 作系统 看就相当于两个逻辑CPU(Logical Processor)。大多数情况下,我们程序里提到的CPU概念就是指的这个Logical Processor。
咱们先来看几个问题:
1、CPU可以直接 *** 作内存吗
可能一大部分老铁肯定会说:肯定的啊,不能 *** 作内存怎么读取数据呢。
其实如果我们用这聪明的大脑想一想,咱们的台式主机大家肯定都玩过。上面CPU和内存条是两个完全独立的硬件啊,而且CPU也没有任何直接插槽用于挂载内存条的。
也就是说,CPU和内存条是物理隔离的,CPU并不能直接的访问内存条,而是需要借助主板上的其他硬件间接的来实现访问。
2、CPU的运算速度和内存条的访问速度差距有多大
呵呵呵,这么说吧,就是一个鸿沟啊,CPU的运算速度与内存访问速度之间的差距是100倍。
而由于CPU与内存之间的速度差存在N个数量级的巨大鸿沟,于是CPU最亲密的小伙伴Cache 闪亮登场了。与DRAM 家族的内存(Memory)不同,Cache来自SRAM家族。
而DRAM与SRAM的最简单区别就是后者特别快,容量特别小,电路结构非常复杂,造价特别高。
而Cache与主内存之间的巨大性能差距主要还是工作原理与结构不同:
DRAM存储一位数据只需要一个电容加一个晶体管,SRAM则需要6个晶体管。
由于DRAM的数据其实是被保存在电容里的,所以每次读写过程中的充放电环节也导致了DRAM读写数据有一个延时的问题,这个延时通常为十几到几十ns。
内存可以被看作一个二维数组,每个存储单元都有其行地址和列地址。
由于SRAM的容量很小,所以存储单元的地址(行与列)比较短,可以被一次性传输到SRAM中。DRAM则需要分别传送行与列的地址。
SRAM的频率基本与CPU的频率保持一致,而DRAM的频率直到DDR4以后才开始接近CPU的频率。
3、Cache 是怎么使用的
其实Cache 是被集成到CPU内部的一个存储单元(平时也被我们称为高速缓存),由于其造价昂贵,并且存储容量远远不能满足CPU大量、高速存取的需求。
所以出于对成本的控制,在现实中往往采用金字塔形的多级Cache体系来实现最佳缓存效果。
于是出现了,一级Cache(L1 Cache)、二级Cache(L2 Cache)及三级Cache(L3 Cache)。每一级都牺牲了部分性能指标来换取更大的容量,目的也是存储更多的 热点 数据。
以Intel家族Intel SandyBridge架构的CPU为例:
L1 Cache容量为64KB,访问速度为1ns左右
L2Cache容量扩大4倍,达到256KB,访问速度则降低到3ns左右
L3 Cache的容量则扩大512倍,达到32MB,访问速度也下降到12ns左右(也比访问主存的105ns(40ns+65ns)快一个数量级)
L3 Cache是被一个Socket上的所有CPU Core共享的,其实最早的L3 Cache被应用在AMD发布的K6-III处理器上,当时的L3 Cache受限于制造工艺,并没有被集成到CPU内部,而是被集成在主板上,如图:
从上图我们也能看出来,CPU如果要访问内存中的数据,则需要经过L1、L2、L3三道关卡,就是这三个Cache中都没有需要的数据,才会从主内存中直接进行读取。
最后我们来看下Intel Sandy Bridge CPU的架构图:
二、多核CPU与内存共享的问题
问题:Cache一致性问题
多核CPU共享内存的问题也被称为Cache一致性问题。
其实就是多个CPU核心看到的Cache数据应该是一致的,在某个数据被某个CPU写入自己的Cache(L1 Cache)以后,其他CPU都应该能看到相同的Cache数据。
如果在自己的Cache中有旧数据,则抛弃旧数据。
考虑到每个CPU都有自己内部独占的Cache,所以这个问题与分布式Cache保持同步的问题是同一类问题
目前业界公认的解决一致性问题的最佳方案就是Intel 的MESI协议了,大多数SMP架构都采用了这一方案。
解决方案:MESI
不知道大家还记得Cache Line 吗,就是我们常说的高速缓存中缓存条目里面的那个缓存行。
其实仔细想想,在进行I/O *** 作从来不以字节为单位,而是以块为单位,有两个原因:
I/O *** 作比较慢,所以读一个字节与读连续N个字节的花费时间基本相同
数据访问一般都具有空间连续的特征
所以CPU针对Memory的读写也采用了类似于I/O块的方式
实际上,CPU Cache(高速缓存)里最小的存储单元就是Cache line(缓存行),Intel CPU 的一个Cache Line存储64个字节。
每一级Cache都被划分为很多组Cache Line,典型的情况就是4条Cache Line为一组。
当Cache从Memory中加载数据时,一次加载一条Cache Line的数据
如图我们可以看到,每个Cache Line 头部都有两个Bit来标识自身状态,总共四种:
M(Modified):修改状态,在其他CPU上没有数据的副本,并且在本CPU上被修改过,与存储器中的数据不一致,最终必然会引发系统总线的写指令,将Cache Line中的数据写回Memory中。
E(E__clusive):独占状态,表示当前Cache Line中的数据与Memory中的数据一致,此外,在其他CPU上没有数据的副本。
S(Shared):共享状态,表示Cache Line中的数据与Memory中的数据一致,而且当前CPU至少在其他某个CPU中有副本。
I(Invalid):无效状态,在当前Cache Line中没有有效数据或者该Cache Line数据已经失效,不能再用;当Cache要加载新数据时,优先选择此状态的Cache Line,此外,Cache Line的初始状态也是I状态
在对Cache(高速缓存)的读写 *** 作引发了Cache Line(缓存行)的状态变化,因而可以将其理解为一种状态机模型。
但MESI的复杂和独特之处在于状态有两种视角:
一种是当前读写 *** 作(Local Read/Write)所在CPU看到的自身的Cache Line状态及其他CPU上对应的Cache Line状态
另一种是一个CPU上的Cache Line状态的变迁会导致其他CPU上对应的Cache Line状态变迁。
如下所示为MESI协议的状态转换图:
具体MESI的实现过程可以看我另一篇文章:看懂这篇,才能说了解并发底层技术
深入理解不一致性内存
MESI协议解决了多核CPU下的Cache一致性问题,因而成为SMP架构的唯一选择,而SMP架构近几年迅速在PC领域(__86)发展。
SMP架构是一种平行的架构,所有CPU Core都被连接到一个内存总线上,它们平等访问内存,同时整个内存是统一结构、统一寻址的。
如下所示给出了SMP架构的示意图:
随着CPU核心数量的不断增加,SMP架构也暴露出天生的短板,其根本瓶颈是共享内存总线的带宽无法满足CPU数量的增加,同时,在一条“马路”上通行的“车”多了,难免会陷入“拥堵模式”。
不知道你是否听说过总线风暴,可以看下:总线风暴
在这种情况下,分布式解决方案应运而生,系统的内存与CPU进行分割并捆绑在一起,形成多个独立的子系统,这些子系统之间高速互联,这就是NUMA(None Uniform Memory Architecture)架构,如下图所示。
可以看出,NUMA架构中的内存被分割为独立的几块,被不同CPU私有化了。
因此在CPU访问自家内存的时候会非常快,在访问其他CPU控制的内存数据时,则需要通过内部互联通道访问。
NUMA架构的优点就是其伸缩性,就算扩展到几百个CPU也不会导致性严重的下降。
NUMA技术的特点
在NUMA架构中引入了一个重要的新名词——Node
一个Node由一个或者多个Socket Socket组成,即物理上的一个或多个CPU芯片组成一个逻辑上的Node
我们来看一个Dell PowerEdge系列服务器的NUMA的架构图:
从上图可以看出其特点:
4个处理器形成4个独立的NUMA Node由于每个Node都为8 Core,支持双线程
每个Node里的Logic CPU数量都为16个,占每个Node分配系统总内存的1/4
每个Node之间都通过Intel QPI(QuickPath Interconnect)技术形成了点到点的全互联处理器系统
NUMA这种基于点到点的全互联处理器系统与传统的基于共享总线的处理器系统的SMP还是有巨大差异的。
在这种情况下无法通过嗅探总线的方式来实现Cache一致性,因此为了实现NUMA架构下的Cache一致性,Intel引入了MESI协议的一个扩展协议——MESIF
针对NUMA的支持
NUMA架构打破了传统的“全局内存”概念,目前还没有任意一种编程语言从内存模型上支持它,当前也很难开发适应NUMA的软件。
Java在支持NUMA的系统里,可以开启基于NUMA的内存分配方案,使得当前线程所需的内存从对应的Node上分配,从而大大加快对象的创建过程
在大数据领域,NUMA系统正发挥着越来越强大的作用,SAP的高端大数据系统HANA被SGI在其UV NUMA Systems上实现了良好的水平扩展
在云计算与虚拟化方面,OpenStack与VMware已经支持基于NUMA技术的虚机分配能力,使得不同的虚机运行在不同的Core上,同时虚机的内存不会跨越多个NUMA Node
如果每道题都这么有趣,那天天看知道也不会无聊了。
这个题相当有趣。而且,几乎没有资料仔细的解释过这个问题。
正确的符合标准C++的输出结果确实应该是4 。
分析一下你的 *** 作:
你取他的地址,你确实取到了,这是真的。
你利用这个地址修改其中的值,你也确实修改了那个地址的值,那个地址位置的内存中所保存的确实是你修改后的值8,这也是真的:原来恒量p所在的地址中的值现在是8。
为什么输出是4的原因,其实打印语句根本没有跑去内存里面读那个地址的值,而是直接把一个常量‘4’扔给cout了。这是编译器做的手脚。目的是为了符合C++标准:恒量不许被修改,即便是利用一个可以进行修改的指针指向这个恒量(标准规定这个指针可以被当作指向了这个恒量,但不必真的指向他,而且不可以通过这个指针修改恒量内容)。
举个例子说明一下编译器的行为方式:
const int i=10;
b(i);
会被编译器当成
const int i=10;
b(10);
来处理。
所以你无论把i所在的内存改成什么都无所谓,反正b收到的参数都是10
实际上这个语法在C++里面可以当做替代预处理器的#define语句,而编译器处理他们的方式也基本是这个样子,就是扔个常量给他,不去读他们所在的内存。不变是const存在的意义,正因为有这个保证才能执行这样的 *** 作:
const int a=10;
int b[a];
以上就是关于深入Java底层:内存屏障与JVM并发详解全部的内容,包括:深入Java底层:内存屏障与JVM并发详解、内存条的基础知识、请帮忙分析下C++指针程序等相关内容解答,如果想了解更多相关内容,可以关注我们,你们的支持是我们更新的动力!
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)