为了最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化
- CPU加高速缓存
- 操作系统加进程、线程:通过CPU时间片切换最大化提升CPU的使用率
- 编译器指令优化:合理的利用CPU的高速缓存
为了解决计算机中的主内存与CPU之间的运行速度差问题,在CPU与主内存之间添加一级或多级高速缓冲存储器(Cache),将运算所需的数据复制到缓存中,让运算更快运行,运算结束后从缓存中同步会内存中,这样处理器无须等待内存的读写,而这个Cache是被集成到CPU内部,称为CPU Cache。
扩展:CPU缓存行
Cache内部按行存储,每一行称为一个Cache行。Cache行是Cache与主内存进行数据交换的单位,Cache行的大小一般是2的幂次数字节。
当CPU访问某变量时,会先看CPU Cache内是否有改变量,如有则从中获取,否则从主内存中获取该变量,然后把该变量所在主内存区域的一个Cache行大小的内存复制到Cache行,缓存与主内存交换数据的单位就是缓存行。
Cache行中存储的是内存块而不是单个变量,因此可能在一个Cache行中存储多个变量。
伪共享
当多个线程同时修改一个缓存行中的多个变量时,由于同时只能有一个线程操作缓存行,相比将每个变量放到一个缓存行,性能会有所下降,称为伪共享。
变量x与y属于同一个缓存行,在被使用时被放到了CPU的一级、二级缓存。Thread1修改修改x变量时,先修改一级缓存中x的缓存行,在缓存一致性协议下,CPU2中的x变量对应的缓存行失效。Thread2在写入变量x时只能从二级缓存查找,此时破坏了一级缓存,但是一级缓存比二级缓存更快,说明多个线程不可能同时修改当下CPU中相同缓存行里的变量。
伪共享产生的原因:
- 多个变量放入同一个缓存行,多个线程同时写入缓存行中的不同变量。
如何避免伪共享:
- jdk8之前通过字节填充来避免,即在创建变量时使用填充字段填充变量所在缓存行;如果缓存行64个字节,类FilledLong中填充6个long类型变量,(64=8*7+8(对象头))如下:
public final class FilledLong { public volatile long value = 0L; public long p1,p2,p3,p4,p5,p6;}
- jdk8使用sun.misc.Contended注解(可以修饰类或变量)解决伪共享,@Contended注解默认情况只能用在Java核心类(如rt.jar),如用户类路径下使用时JVM需加参数-XX:RestrictContended,默认填充宽度128字节,自定义宽度时需设置-XX:ContendedPaddingWidth=128
缓存一致性问题:
在多处理器系统中,每个处理器都有自己的高速缓存,且共享同一主内存(Main Memory),当多处理器的运算任务都涉及同一块主内存区域时,不同CPU中运行的不同线程看到的同一份内存中的缓存值会不一样,将可能导致各自的缓存数据不一致。
CPU层面有两种方式来解决缓存不一致问题:
- 总线锁:多CPU情况下,对共享内存操作,总线上发送LOCK#信号,其他CPU无法通过总线访问到共享内存中数据,总线锁将CPU和共享内存的通信锁定,其他CPU无法操作其他内存地址数据,这样的开销会很大,影响机器性能。
- 缓存锁:为了达到数据访问时的一致性,需要CPU在访问缓存行时遵循一些协议,在读写缓存时根据协议操作,常见协议如MSI、MESI、MOSI等。
MESI协议:
- M(Modify),共享数据只存在当前CPU缓存中,且是被修改状态,也就是缓存中数据和主内存中数据不一致
- E(Exclusive),缓存的独占状态,数据只缓存在当前CPU缓存中,没有被修改
- S(Shared),数据可能被多个CPU缓存,各个缓存中的数据与主内存数据一致
- I(Invalid),缓存无效
MESI协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也能监听其他Cache的读写操作。
对于MESI 协议,从CPU读写角度来说需遵循以下原则:
- CPU读请求:缓存处于M、E、S状态都可以被读取,I状态CPU只能从主存中读取数据
- CPU写请求:缓存处于M、E状态才可以被写。对于S状态的写,需要将其他CPU中缓存行置为无效才可写
高速缓存的出现,使得每个CPU中都缓存了相同的共享数据,可能会出现可见性问题,因为CPU1修改了本地的缓存的值对CPU2不可见,而CPU2再对相同数据进行写操作时,使用的是脏数据,使得结果不可测。
MESI 协议可以实现缓存的一致性,但会带来资源浪费问题:
CPU缓存行的状态是通过消息来进行传递的。 如CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的CPU,且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费,CPU引入了Store Buffers。
CPU0在写入共享数据时,将数据写入到StoreBuffer中,同时发送Invalidate消息给其他CPU,继续处理其他指令(异步操作)。当收到其他所有CPU发送了Invalidate Acknowledge消息时,再将StoreBuffer中的数据存储至缓存行中,最后再从缓存行同步到主内存。这种异步的操作可能会引起CPU 的重排序,进而带来可见性问题。
StoreBuffer存在两个问题:
- 数据的提交时间不确定,需要等待其他CPU回复后才能进行数据同步,这是异步操作
- 引入了Store Buffere后,处理器读取数据顺序为:先尝试从StoreBuffer中读,如果有数据,则直接读取,否则就再从缓存行中读取
CPU为解决可见性问题,提供了内存屏障指令,也就是CPU flush store bufferes中的指令。
内存屏障(Memory Barrier):将Store Buffers中的指令写入内存,从而使得访问同一共享内存的线程都可见。
X86的内存屏障:
- 读屏障(Ifence):读屏障之后的读操作,都在读屏障后执行
- 写屏障(sfence,store memory barrier):写屏障之前的指令结果对之后的指令都可见
- 全屏障(mfence):全屏障前的内存读写操作结果提交到内存后,在执行屏障后的读写操作
内存屏障作用:通过防止CPU对内存的乱序访问,来保证共享数据在多线程并行执行下的可见性
综上所述,内存可见性的根本原因是缓存和重排序,而JMM提供了合理禁用缓存及重排序的方法。