从JMM提及,做为一名JAVA开发,特别在多线程编程实践中,了解和熟悉JAVA内存模型是颇有必要的。刚开始接触内存模型的时候,有不少概念很是陌生,好比happens-before,可见性,顺序性等等。要理解这些关键词,须要先对编译器、处理器的知识有一些了解。
还有一些框架例如disruptor,在设计的时候就考虑了CPU的特色,充分发挥CPU的性能。要理解这类框架,也须要对处理器有必定了解。java
先看下面这个表格,一些场景下的延时,好比CPU执行一条指令大约是1纳秒,从L1 cache获取数据须要0.5纳米,从主存中取数据须要100纳秒等等。node
经典的RISC pipeline由如下几步组成:golang
取指令编程
译指令后端
执行数组
内存访问缓存
寄存器回写多线程
因为访问主存的延时和指令执行的延时不在一个数量级,因此CPU通常会使用访问速度更快的缓存,现代处理器的缓存通常是分为三级,下图是通常CPU的缓存结构。每个CPU核共享L一、L2 Cache,全部的CPU核共享L3 Cache。架构
在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的特殊形式。
下面这张图是我看到的最容易理解的资料。
如今的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的做用有两个
保证数据的可见性 咱们知道,内存中的数据除了在内存中的副本,还有可能在各个核的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为例,作了简单的介绍。可是无论是哪一个平台,他们都是解决两个问题,一个是指令的排序,另外一个是全局可见性。
前面我讲到,内存中的数据是以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中。
咱们知道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并提高用户体验的解决方案;同时提供多元化的创业服务-物理空间、孵化运营、创业投资等,为移动创业者提供全面保障。