预备知识
存储器层次结构
大学操作系统课程里讲到了存储器层次结构的金字塔模型,金字塔从上到下代表更大的容量、更慢的存取速度、更低的成本。如下图所示:
金字塔模型
为了解决CPU的高速与内存磁盘低速之间的矛盾,处理器不直接和系统内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2,L3)后再进行操作,操作完成后在未来某个时间点会写到内存。
常用CPU术语
- 内存屏障(memory barriers):一组处理器指令,用于实现对内存操作的顺序限制。
- 缓存行(cache line):CPU高速缓存中可以分配的最小存储单位。缓存行大小根据架构不同而不同,常见的有64Byte和32Byte,CPU填充缓存行时以缓存行为单位进行,每一次都读取数据所在的整个缓存行,即使相邻的数据没有被用到也会被读到CPU缓存中。
- 原子操作(atomic operations):不可中断的一个或一系列操作。
- 缓存行填充(cache line fill):当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3)
- 缓存命中(cache hit):如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是内存读取。
- 写命中(write hit):当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作叫做写命中。
- 写缺失(write misses the cache):一个有效的缓存行被写入到不存在的内存区域。
- 写通(write through):每次CPU修改Cache中的内容,Cache立即更新主内存的内容。
- 写回(write back):修改Cache的内容后,Cache并不会立即更新内存中的数据,而是等到cache line因为某种原因需要从cache中移除时,cache才会更新主内存中的内容。
- 嗅探(snoop):每个处理器通过嗅探在地址总线上传播的数据来检查自己缓存值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前的cache line设置成无效状态。当处理器对这个数据进行修改操作时,会重新从系统内存把数据读到Cache里。
缓存一致性
在多处理器下,每个CPU都有自己的私有缓存(可能共享L3缓存),当一个CPU在进行写内存地址操作时,并且这个地址当前处于共享状态,那么其它CPU的数据就是无效数据。缓存一致性就是为了保证多CPU之间的缓存是一致的。
IA-32处理器和Intel64处理器使用MESI控制协议处理缓存一致性。所谓MESI即是指CPU缓存的四种状态:
- M(Modified):某个处理器已经修改缓存行,即是"dirty line",它的数据和主内存中的数据不一致。该缓存行中的数据在未来某个时间点(允许其它CPU读取相应内存之前)写回(write back)主内存。
- E(Exclusive):缓存行内容和内存中的一样,数据只存在于本Cache中。
- S(Shared):缓存行内容和内存中的一样,数据存在于很多Cache中。
- I(Invalid):缓存行数据无效,不能使用。
volatile应用
Java语言规范第3版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
volatile是轻量级的synchronized,它比synchronized使用和执行成本更低,不会引起线程上下文的切换和调度。
volatile可以用来修饰字段,就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
汇编指令分析
volatile关键字修饰的共享变量在进行写操作,反汇编会发现多出一条汇编指令,如下所示:
// java代码
volatile Singleton instance = new Singleton();
...
// 汇编后指令
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);
Lock前缀的指令在多核处理器下会引发两件事情:
1) 将当前处理器缓存行的数据写回到系统内存。在锁操作时,总是在总线上声言LOCK#信号,该信号确保在声言自己期间内CPU可以独占任何共享内存。在P6和目前处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号,相反它会锁定这块内存区域的缓存并写回到内存,并使用缓存一致性机制来确保修改的原子性,这种操作被称为"缓存锁定"。
2) 这个写回系统内存的操作会使在其它CPU里缓存了该内存地址的数据无效。由嗅探技术和缓存一致性可知。
追加字节优化性能?
JDK7并发包的一个队列集合类LinkedTransferQueue代码如下:
private transient final PaddedAtomicReference<QNode> head;
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference<T> extends AtomicReference<T> {
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r) {
super(r);
}
}
public class AtomicReference<V> implements java.io.Serializable {
private volatile V value;
...
}
一个对象引用占4个字节,追加了15个变量后共占64字节。目前主流处理器的
L1
、L2
或L3
缓存的高速缓存行是64个字节宽,不支持部分填充缓存行。如果队列的头节点和尾节点都不足64字节,处理器会将它们都读到同一个高速缓存行中,当一个处理器修改头结点时会导致整个缓存行锁定,在缓存一致性机制下,其它处理器不能访问自己高速缓存中的尾节点,而入队和出队操作都需要不停修改头节点和尾节点,所以会严重影响到出、入队效率。
不应使用追加到64字节的场景:
1) P6系列和奔腾处理器的L1
和L2
高速缓存行是32字节宽。
2) 追加字节的方式需要处理器读取更多的字节到高速缓冲区,本身会带来一定的性能消耗。如果共享变量不被频繁写的话,缓存锁定的几率也是非常小的,没有必要通过追加字节的方式来避免头、尾节点相互锁定。
这种追加字节方式在Java7下不生效了,因为Java7更加智慧,它会淘汰或重排列无用字段,需要其它追加字节的方式。