下图简单的展示了最简单的高速缓存的配置,数据的读取和存储都经过高速缓存,CPU核心与高速缓存有一条特殊的快速通道;主存与高速缓存都连在系统总线上(BUS)这条总线同时还用于其他组件的通信:
在高速缓存出现后不久,系统变得越来越复杂,高速缓存与主存之间的速度差异被拉大,直到加入了另一级缓存,新加入的这级缓存比第一缓存更大,但是更慢,而且经济上不合适,所以有了二级缓存,甚至有些系统已经拥有了三级缓存,于是就演变成了多级缓存,如下图:
为什么需要CPU cache:
CPU的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,这样就会浪费资源。所以cache的出现,是为了缓解CPU和内存之间速度的不匹配问题(结构:CPU -> cache -> memory)
缓存的容量远远小于主存,因此出现缓存不命中的情况在所难免,既然缓存不能包含CPU所需要的所有数据,那么缓存的存在真的有意义吗?
CPU cache是肯定有它存在的意义的,至于CPU cache有什么意义,那就要看一下它的局部性原理了:
1.时间局部性:如果某个数据被访问,那么在不久的将来它很可能再次被访问 2.空间局部性:如果某个数据被访问,那么与它相邻的数据很快也可能被访问
多级缓存-缓存一致性(MESI),MESI是一个协议,这协议用于保证多个CPU cache之间缓存共享数据的一致性。它定义了CacheLine的四种数据状态,而CPU对cache的四种操作可能会产生不一致的状态。因此缓存控制器监听到本地操作与远程操作的时候需要对地址一致的CacheLine状态做出一定的修改,从而保证数据在多个cache之间流转的一致性。CacheLine的四种状态如下:
MESI示意图:
CacheLine有四种数据状态(MESI),而引起数据状态转换的CPU cache操作也有四种:
因此要完整的理解MESI这个协议,就需要把这16种状态转换的情况理解清楚,状态之间的相互转换关系,可以使用下图进行表示:
在一个典型的多核系统中,每一个核都会有自己的缓存来共享主存总线,每个相应的CPU会发出读写(I/O)请求,而缓存的目的是为了减少CPU读写共享主存的次数。一个缓存除了在 Invalid 状态之外,都可以满足CPU的读请求。
一个写请求只有在该缓存行是M状态,或者E状态的时候才能够被执行。如果当前状态是处在S状态的时候,它必须先将缓存中的该缓存行变成无效的(Invalid)状态,这个操作通常作用于广播的方式来完成。这个时候它既不允许不同的CPU同时修改同一个缓存行,即使修改该缓存行不同位置的数据也是不允许的,这里主要解决的是缓存一致性的问题。一个处于M状态的缓存行它必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行。
一个处于S状态的缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
一个处于E状态的缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S状态。
因此,对于M和E两种状态而言总是精确的,他们在和该缓存行的真正状态是一致的。而S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
什么是乱序执行优化:
例如,我现在有两个变量a和b,a的值为10,b的值为200,我要计算a乘以b的结果。而我在代码上写的是:
a=10; b=200; result=a*b;
但是到了CPU上的乱序执行优化后,可能就变成了:
b=200; a=10; result=a*b;
如下图:
从上图中,可以看到CPU乱序执行优化后的代码并不会对计算结果造成影响,但这也只是其中一种没被影响的情况而已。在单核时代,处理器保证做出的优化不会导致执行结果远离预期目标。但是在多核环境下则并非如此,因为在多核环境下同时会有多个核心在执行指令,每个核心的指令都可能被乱序。另外处理器还引入了L1、L2等多级缓存机制,而每个核心都有自己的缓存,这就导致了逻辑次序上后写入的数据未必真的写入了。如果我们不做任何防护措施,那么处理器最终处理的结果可能与我们代码的逻辑结果大不相同。比如我们在一个核心上执行数据写入操作,并在最后写一个标记用来表示之前的数据已经准备好了。然后从另外一个核心上通过判断这个标记来判定所需要的数据是否已准备就绪,这种做法就存在一定的风险,标记位可能先被写入,而数据并未准备完成,这个未完成既有可能是没有计算完成,也有可能是缓存没有被及时刷新到主存之中,这样最终就会导致另外的核心使用了错误的数据,所以我们才需要在多线程的情况下保证线程安全。
以上我们简单介绍了在多核并发的环境下CPU进行乱序执行优化时所带来的线程安全问题,为了保证线程安全,我们需要采取一些额外的手段去防止这种问题的发生。
不过在介绍如何采用实际手段解决这种问题之前,我们先来看看Java虚拟机是如何解决这种问题的:为了屏蔽各种硬件和操作系统内存的访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,所以Java虚拟机规范中定义了Java内存模型(Java Memory Model简称JMM)。
Java内存模型是一种规范,它定义了Java虚拟机与计算机内存是如何协同工作的。它规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步地访问共享变量。
在明确了Java内存模型是做什么的之后,我们来看一下其中内存分配的两个概念
Java内存模型要求调用栈和本地变量存放在线程栈(Thread Stack)上,而对象则存放在堆上。一个本地变量也可能是指向一个对象的引用,这种情况下这个保存对象引用的本地变量是存放在线程栈上的,但是对象本身则是存放在堆上的。
一个对象可能包含方法,而这些方法可能包含着本地变量,这些本地变量仍然是存放在线程栈上的。即使这些方法所属的对象是存放在堆上的。一个对象的成员变量,可能会随着所属对象而存放在堆上,不管这个成员变量是原始类型还是引用类型。静态成员变量则是随着类的定义一起存放在堆上。
存放在堆上的对象,可以被持有这个对象的引用的线程访问。当一个线程可以访问某个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,那么它们都将会访问这个方法中的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。
硬件内存架构
现代硬件内存模型与Java内存模型有一些不同。理解内存模型架构以及Java内存模型如何与它协同工作也是非常重要的。这部分描述了通用的硬件内存架构,下面的部分将会描述Java内存是如何与它“联手”工作的。
下图简单展示了现代计算机硬件内存架构:
运作原理:通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。
当CPU需要在缓存层存放一些东西的时候,存放在缓存中的内容通常会被刷新回主存。CPU缓存可以在某一时刻将数据局部写到它的内存中,和在某一时刻局部刷新它的内存。它不会再某一时刻读/写整个缓存。通常,在一个被称作“cache lines”的更小的内存块中缓存被更新。一个或者多个缓存行可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。
Java内存模型和硬件内存架构之间的桥接
上面已经提到,Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件而言,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示:
线程和主内存的抽象关系
Java内存模型抽象结构图:
每个线程之间的共享变量存储在主内存里面,每个线程都有一个私有的本地内存,本地内存是Java内存模型的一个抽象的概念,并不是真实存在的。它涵盖了缓存、写缓存区、寄存器以及其他的硬件和编译器的优化,本地内存中存储了该线程已读或写共享变量的拷贝的一个副本。
从一个更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。
如果上图中的线程A和线程B要通信,必须经历两个步骤:
因此,多线程的环境下就会出现线程安全问题。例如我们要进行一个计数的操作:线程A在主内存中读取到了变量值为1,然后保存到本地内存A中进行累加。就在此时线程B并没有等待线程A把累加后的结果写入到主内存中再进行读取,而是在主内存中直接读取到了变量值为1,然后保存到本地内存B中进行累加。此时,两个线程之间的数据是不可见的,当两个线程同时把计算后的结果都写入到主内存中,就导致了计算结果是错误的。这种情况下,我们就需要采取一些同步的手段,确保在并发环境下,程序处理结果的准确性。
Java内存模型定义同步的八种操作
同步规则
同步操作与规则: