CUP
多级缓存
为了解决计算机系统中主内存与 CPU 之间运行速度差问题,会在 CPU 与主内存之间添加一级或者多级高速缓冲存储器( Cache )。这个 Cache 一般是被集成到 CPU 内部的,所以也叫 CPU Cache。

在 Cache 内部是按行存储的,其中每一行称为一个 Cache 行。Cache 行是 Cache 与主内存进行数据交换的单位,Cache 行的大小一般为2的幕次数字节。

当 CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个 Cache 行大小的内存复制到 Cache 中 。 由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到一个 Cache 行中。
伪共享
当多个线程同时修改一个缓存行里面的多个变量时, 由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所下降,这就是伪共享

在该图中,变量 x 和 y 同时被放到了 CPU 的一级和二级缓存,当线程 1使用 CPU1 对变量 x 进行更新时,首先会修改 CPU1 的一级缓存变量 x 所在的缓存行,这时候在缓存一致性协议(如 MESI)下,CPU2 中变量 x 对应的缓存行失效。那么线程2 在写入变量 x 时就只能去二级缓存里查找,这就破坏了一级缓存。而一级缓存比二级缓存更快,这也说明了多个线程不可能同时去修改自己所使用的 CPU 中相同缓存行里面的变量。
出现伪共享的原因
伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量 。
那么为何多个变量会被放入一个缓存行呢?
其实是因为缓存与内存交换数据的单位就是缓存行,当 CPU 要访问的变量没有在缓存中找到时,根据程序运行的局部性原理,会把该变量所在内存中大小为缓存行的内存放入缓存行。
1 | long a; |
如上代码声明了四个 long 变量,假设缓存行的大小为32字节,那么当 CPU 访问变量 a 时,发现该变量没有在缓存中,就会去主内存把变量 a 以及内存地址附近的b、 c、 d 放入缓存行。也就是地址连续的多个变量才有可能会被放到一个缓存行中。
如何避免伪共享
在JDK 8 之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中,例如如下代码。
1 |
|
假如缓存行为64字节节,那么我们在FilledLong类里面填充了6个long类型的变量,每个long类型变量占用8字节,加上value变量的8字节总共56字节。另外,这里FilledLong是一个类对象,而类对象的字节码的对象头占用8字节,所以一个FilledLong对象实际会占用64字节的内存,这正好可以放入一个缓存行。
JDK8提供了一个sun.misc.Contended注解,用来解决伪共享问题。
CPU对缓存一致性协议的优化
缓存一致性协议对性能有很大损耗,为了解决这个问题,CPU 的设计者们在这个基础上又进行了各种优化。例如,在计算单元和L1之间加了Store Buffer、Load Buffer。

L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和L1之间却是异步的。也就是说,往内存中写入一个变量,这个变量会保存在StoreBuffer里面,稍后才异步地写入L1中,同时同步写入主内存中。
但站在操作系统内核的角度,可以统一看待这件事情,也就是下图所示的操作系统内核视角下的CPU缓存模型。

对应到Java里,就是JVM抽象内存模型。

重排序和可见性的关系
Store Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和CPU的指令重排序。
- 编译器重排序。对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
- CPU指令重排序。在指令级别,让没有依赖关系的多条指令并行。
- CPU内存重排序。CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。
1、2是造成代码没有按照书写的逻辑执行的原因,第三类就是造成“内存可见性”问题的主因。
as-if-serial语义
对开发者而言,当然希望不要有任何的重排序,这样理解起来最简单,指令执行顺序和代码顺序严格一致,写内存的顺序也严格地和代码顺序一致。但是,从编译器和CPU的角度来看,希望尽最大可能进行重排序,提升运行效率。
单线程程序的重排序规则
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。
换句话说,只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题。
多线程程序的重排序规则
对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。
所以,**编译器和CPU只能保证每个线程的as-if-serial语义。**线程之间的数据依赖和相互影响,需要编译器和CPU的上层(JVM)来确定。上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。
happen-before
为了明确定义在多线程场景下,什么时候可以重排序,什么时候不能重排序,Java 引入了JMM(Java Memory Model),也就是Java内存模型(单线程场景不用说明,有as-if-serial语义保证)。这个模型就是一套规范,对上,是JVM和开发者之间的协定;对下,是JVM和编译器、CPU之间的协定。
定义这套规范,其实是要在开发者写程序的方便性和系统运行的效率之间找到一个平衡点。一方面,要让编译器和CPU可以灵活地重排序;另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。
为了描述这个规范,JMM引入了happen-before,使用happen-before描述两个操作之间的内存可见性。
happen-before具体内容参考[[jvm06_高效并发]]。
内存屏障
为了禁止编译器重排序和CPU 重排序,在编译器和CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。
这也正是JMM和happen-before规则的底层实现原理。
编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了。
CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
总结
从底向上看并发编程背后的原理。

关联内容
[[jvm06_高效并发]]