从JMM说起,作为一名JAVA开发,特别在多线程编程实践中,了解和熟悉JAVA内存模型是很有必要的。刚开始接触内存模型的时候,有很多概念非常陌生,比如happens-before,可见性,顺序性等等。要理解这些关键词,需要先对编译器、处理器的知识有一些了解。
还有一些框架例如disruptor,在设计的时候就考虑了CPU的特点,充分发挥CPU的性能。要理解这类框架,也需要对处理器有一定了解。
introduction
先看下面这个表格,一些场景下的延时,比如CPU执行一条指令大约是1纳秒,从L1 cache获取数据需要0.5纳米,从主存中取数据需要100纳秒等等。
经典的RISC pipeline由以下几步组成:
取指令
译指令
执行
内存访问
寄存器回写
由于访问主存的延时和指令执行的延时不在一个数量级,所以CPU一般会使用访问速度更快的缓存,现代处理器的缓存一般是分为三级,下图是一般CPU的缓存结构。每一个CPU核共享L1、L2 Cache,所有的CPU核共享L3 Cache。
cache line
在JVM中,我们都知道对象都是存在于内存堆中的,也就是主存中,而对于CPU来说,它并不关心程序中操作的对象,它只关心对某个内存块的读和写,为了让读写速度更快,CPU会首先把数据从主存中的数据以cache line的粒度读到CPU cache中,一个cache line一般是64 bytes。假设程序中读取某一个int变量,CPU并不是只从主存中读取4个字节,而是会一次性读取64个字节,然后放到cpu cache中。因为往往紧挨着的数据,更有可能在接下来会被使用到。比如遍历一个数组,因为数组空间是连续的,所以并不是每次取数组中的元素都要从主存中去拿,第一次从主存把数据放到cache line中,后续访问的数据很有可能已经在cache中了,
cache hit
CPU获取的内存地址在cache中存在,叫做cache hit。
cache miss
如果CPU的访问的内存地址不在L1 cache中,就叫做L1 cache miss,由于访问主存的速度远远慢于指令的执行速度,一旦发生cache miss,CPU就会在上一级cache中获取,最差的情况需要从主存中获取。一旦要从主存中获取数据,当前指令的执行相对来说就会显得非常慢。
cache associativity
根据内存和cache的映射关系不同,有三种映射方式。
direct mapped
mapped方式查询最快,因为只有一个坑,只需要比较一次。但是容易发生冲突。
n-way set associative
n-way associative是一种折中的方式,可以有较高的缓存命中率,又不至于每次查询比较慢。full associative
只要cache没有满还能把主存中的数据放到cache中,但是查询的时候需要全扫描,效率低。
其实direct mapped和full associative是n-way associative的特殊形式。
下面这张图是我看到的最容易理解的资料。
cache coherency
现在的CPU一般都有多个核,我们知道当某个核读取某个内存地址时,会把这个内存地址附近的64个字节放到当前核的cache line中,假设此时另外一个CPU核同时把这部分数据放到了对应的cache line中,这时候这64字节的数据实际上有三份,两份在CPU cache中,一份在主存中。自然而然就要考虑到数据一致性的问题,如何保证在某一个核中的数据做了改动时,其它的数据副本也能感知到变化呢?是由缓存一致性协议来保证的。缓存一致性协议也叫作MESI协议。简单的来说,就是CPU的cache line被标记为以下四种状态之一。
Modified
当前cache line中的数据被CPU修改过,并且只在当前核对应的cache中,数据还没有被回写到主存中,那么当前cache line就处于Modified状态。如果这个时候其它的核需要读取该cache line中的,需要把当前cache line中的数据回写到主存中去。一旦回写到主存中去后,当前cache line的状态变为Shared
Exclusive
当前cache line只在一个核对应的cache中,数据和主存中的数据一致。如果有另外一个核读取当前cache line,则状态变为Shared,如果当前核修改了其中的数据,则变成Modified状态。
Shared
如果cache line处于Shared状态,则表示该cache line在其它核对应的cache中也有副本,而且这两个副本和主存中的数据一致。
Invalid
如果cache line处于Invalid状态,则表示这块cache line中的数据已经无效了,如果要读取其中的数据的话,需要重新从主存中获取。
只有cache line处于Exclusive或者Modified状态时才能进行写操作。如果处于Shared状态,那么要先广播一个消息(Request For Ownership),invalidate其它核对应的cache line。
如果cache line处于Modified状态,那么需要能探测到其它试图读取该cache line的操作。
如果cache line处于Shared状态,它必须监听其它cache的invalidate信息,一旦其它核修改了对应的cache line,其它cache 中对应的cache line需要变为invalid状态。
MESI协议中有两个行为效率会比较低,
当cache line状态为Invalid时,需要写入数据。
把cache line的状态变为invalid
CPU通过store buffer和invalid queue来降低延时。
当在invalid状态进行写入时,首先会给其它CPU核发送invalid消息,然后把当前写入的数据写入到store buffer中。然后在某个时刻在真正的写入到cache line中。由于不是马上写入到cache line中,所以当前核如果要读cache line中的数据,需要先扫描store buffer,同时其它CPU核是看不到当前核store buffer中的数据的。除非store buffer中的数据被刷到cache中。
对于invalid queue,当收到invalid消息时,cache line不会马上变成invalid状态,而是把消息写入invalid queue中。和store buffer不同的是当前核是无法扫描invalid queue的。
为了保证数据的一致性,这就需要memory barrier了。store barrier会把store buffer中的数据刷到cache中,read barrier会执行invalid queue中的消息。
注意
要保证数据的一致性,仅仅有MESI协议还不够,通常还需要memory barrier的配合。
memory barrier
memory barrier的作用有两个
保证数据的可见性 我们知道,内存中的数据除了在内存中的副本,还有可能在各个核的CPU中,当某个核修改了对应cache中的数据后,这时其它核中对应内存地址的数据还有主存中的数据就不是最新的了,其它核为了能够读取到最新的数据,需要执行memory barrier指令,把store buffer中的修改写到主存中。
防止指令之间的重排序 前面讲到一条指令的执行会分为几个步骤,也就是pipeline,为了得到更高的性能,编译器或者处理器有可能会改变指令的执行顺序,以此来提高指令执行的并行度。不管是编译器还是处理器的重排序,都要遵守as-if-serial语义。as-if-serial说的是,不管怎么重排序,在单线程中执行这些指令,其结果应该是一样的。在多线程的情况下,需要memory barrier来保证整体的顺序,否则会出现意想不到的结果。
不同的处理器架构的memory barrier也不太一样,以Intel x86为例,有三种memory barrier
store barrier
对应sfence指令
保证了sfence前后store指令的顺序,防止重排序。
通过刷新store buffer保证了sfence之后的store指令全局可见之前,sfence之前的store要指令要先全局可见。
load barrier
对应lfence指令,
保证了lfence前后的load指令的顺序,防止重排序。
刷新load buffer。
full barrier
对应mfence指令
保证了mfence前后的store和load指令的顺序,防止重排序。
保证了mfence之后的store指令全局可见之前,mfence之前的store指令要先全局可见。
以java中的volatile为例,volatile的语义有几点:
volatile的操作是原子的
volatile的操作是全局可见的
在一定程度上防止重排序
一般是通过插入内存屏障或者具有屏障功能的其它指令(如lock指令)来保证上面的第二和第三点。
总结
内存屏障本身非常复杂,不同的处理器的实现也很不一样,编译期间和运行期间都有内存屏障,上面是以X86为例,做了简单的介绍。但是不管是哪个平台,他们都是解决两个问题,一个是指令的排序,另一个是全局可见性。
false sharing
前面我讲到,内存中的数据是以cache line为单位从内存中读到CPU cache中的,比如有两个变量X,Y,在内存中他们俩非常近,那么很有可能在读X的时候,Y也被放到了相同cache line中。假设Thread1需要不停的写X,比如在一个循环中,而Thread2需要不停的写Y,那么在Thread1写X的时候,需要Invalid其他cache中对应的cache line,Thread2写Y的时候也要做同样的事情,这样就会不停的碰到上面说过的MESI协议的两个比较耗时的操作:
当cache line状态为Invalid时,需要写入数据。
把cache line的状态变为invalid
会严重影响性能。
解决false sharing也比较简单,做padding就可以了。下面这段代码是disruptor中Sequence的一段代码,一般cache line是64 byte,long类型的value加上7个long做padding,正好是64byte,这样当以Sequence[]的方式使用时,不同下标的Sequence对象就会落在不同的cache line中。
context switch
我们知道CPU的核数量是有限的,一般是1-32核不等,而现代操作系统是多任务操作系统,同一时刻在运行的进程数量一般都会远远超过CPU的核数量。简单的说就是并行运行的任务数量最多就是CPU的核数,但是并发运行的任务数量可以有很多。打个比方,对于单核的CPU,如果不能并发运行多个任务的话,那么所有任务都会是串行的,假设某个任务进行一次远程调用,而远程调用的时间比较长,那么这样的系统效率将会非常低,如果能并发执行的话,在一个任务等待的时候,操作系统可以把CPU时间片分给其它其它任务运行,而前一个任务等待完毕后,操作系统再次调度CPU,重新让它继续运行,这样对于使用者来说,感觉就像是同时在运行多个任务。
context switch的开销
保存和恢复context
那是不是并发运行的任务越多越好呢?答案当然是否定的,并发运行任务带来的最大的缺点就是上下文切换(context switch)带来的开销。上下文(context)指的是当前任务运行时,在CPU寄存器,程序计数器中保存的状态。每一次进行线程切换时,需要保存当前所有寄存器、程序计数器、栈指针等,等线程切换回来的时候又要对这些内容进行恢复。
污染CPU缓存
当频繁的进行线程切换的时候,因为运行的任务不一样了,对应的CPU cache中的数据也不一样,当被阻塞的线程重新执行的时候,CPU cache中的内容很可能已经发生了变化,以前在缓存中的数据可能要重新从主存中加载。
因此,在系统设计的时候,应该尽量避免不必要的上下文切换。比如nodejs、golang、actor model、netty等等这些并发模型,都减少了不必要的上下文切换。
关于阿里百川
阿里百川(baichuan.taobao.com)是阿里巴巴集团“云”+“端”的核心战略是阿里巴巴集团无线开放平台,基于世界级的后端服务和成熟的商业组件,通过“技术、商业及大数据”的开放,为移动创业者提供可快速搭建App、商业化APP并提升用户体验的解决方案;同时提供多元化的创业服务-物理空间、孵化运营、创业投资等,为移动创业者提供全面保障。