一、现代计算机内存模型
早期的计算机中由于CPU和内存的速度是差不多的,所以CPU是直接访问内存地址的。而在现代计算机中,CPU指令的运行速度远远超过了内存数据的读写速度,为了降低这两者间这高达几个数量级的差距,所以在CPU与主内存之间加入了CPU高速缓存。
高速缓存可以很好地解决CPU与主内存之间的速度差距,但CPU缓存并不是所有CPU共享的,因此产生了一个新的问题:数据一致性问题。
现代计算机内存模型二、缓存一致性协议(MESI)
CPU缓存的一致性问题会导致并发处理的不同步,对于这个问题,大概有以下两种方案:
- 总线加锁 ---> 降低了CPU的吞吐量,不现实
- 采用缓存上的一致性协议MESI ---> 现代处理器常用,或使用其变种的协议
1. MESI四种状态
MESI 这个名称本身是由Modified(修改)、Exclusive(独享)、Shared(共享)、Invalid(无效)。这个四个单词也代表了缓存协议中对缓存行(即Cache Line,缓存存储数据的单元)声明的四种状态,用2 bit表示,它们所代表的含义如下所示:
状态 | 描述 | 监听任务 |
---|---|---|
M修改(Modified) | 这行数据有效,数据被修改了,和内存种的数据不一致,数据只存在于本Cache中 | 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。 |
E独享(Exclusive) | 这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中 | 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 |
S共享(Shared) | 这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中 | 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 |
I无效(Invalid) | 这行数据无效 | 无 |
-
E状态示例如下:
E状态只有Core 0访问变量x,它的Cache Line状态为E。
-
S状态示例如下:
S状态
3个Core都访问变量x,它们对应的Cache line为S(Shared)状态。
-
M状态和I状态示例如下:
imageCore 0 修改了x的值之后,这个Cache Line变成了M(Modified)状态,其他Core对应的Cache line变成了I(Invalid)状态。
2. 状态间的迁移
在MESI协议中,每个Cache的cache控制器不仅知道自己的读写操作,而且也监听其他cache的读写操作。每个Cache Line所处的状态根据本核和其他核的操作在4个状态间进行迁移。
M状态和I状态在上图中,Local Read表示本内核读本Cache中的值,Local Write表示本内核写本Cache中的值,Remote Read表示其它内核读其它Cache中的值,Remote Write表示其它内核写其它Cache中的值,箭头表示本Cache line状态的迁移,环形箭头表示状态不变。
当内核需要访问的数据不在本Cache中,而其它Cache有这份数据的备份时,本Cache既可以从内存中导入数据,也可以从其它Cache中导入数据,不同的处理器会有不同的选择。
本文只进行简单介绍,具体请阅读Cache一致性协议之MESI。
3. 如何保证缓存一致性
了解完什么是MESI,那么具体是如何保证缓存一致性的呢?
《Java并发编程的艺术》中提到:在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存中的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
三、Java内存模型
1. Java内存模型的抽象结构
Java内存模型线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地工作内存(Local Memory),工作内存中存储了线程以读/写共享变量的副本。(本地工作内存是 JMM 的一个抽象概念,并不真实存在,线程中所谓工作内存其实还是存在于主内存中的。)
2. Java内存模型与现代计算机内存模型区分
Java内存模型和现代计算机内存模型都需要解决一致性问题,但是这个一致性问题在现代计算机内存模型中指代的是缓存一致性问题,MESI协议所设计的目的也是为了解决这个问题。而在Java内存模型中,这个一致性问题则是指代内存一致性问题。两者之间有一定区别。
-
缓存一致性
计算机数据需要经过内存、计算机缓存再到寄存器,计算机缓存一致性是指硬件层面的问题,指的是由于多核计算机中有多套缓存,各个缓存之间的数据不一致问题。缓存一致性协议(如MESI)就是用来解决多个缓存副本之间的数据一致性问题。
-
内存一致性
线程的数据则是放在内存中,共享副本也是,内存一致性保证的是多线程程序并发时的数据一致性问题。我们常见的volatile、synchronized关键字就是用来解决内存一致性问题。这里屏蔽了计算机硬件问题,主要解决原子性、可见性和有序性问题。
至于内存一致性与缓存一致性问题之间的关系,就是实现内存一致性时需要利用到底层的缓存一致性(之后的volatile关键字会涉及)。
四、并发编程的特性
首先我们要先了解并发编程的三大特性:原子性,可见性,有序性;
1. 原子性
原子性是指一个操作是不可间断的,即使是多个线程同时执行,该操作也不会被其他线程所干扰。
我们来看一下Java中几条常见的指令是否具有原子性
-
x = 10
private int x; // 具有原子性 x = 10;
-
i++
不具备原子性,因为i++包括了以下三个步骤:
- 读取 i 的值到内存空间
- i + 1
- 刷新结果到内存
-
y =x
private int x, y; x = 10; /* y = x没有原子性 1. 把数据x读到工作空间(这一步具有原子性) 2. 把x的值写到y中(这一步也具有原子性) */ y = x;
总结:多个原子性的操作结合在一起的操作并不具备原子性。
2. 可见性
内存可见性(Memory visibility)是指当某个线程正在使用对象状态而同时另一个线程正在修改该状态,此时需要确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
正如我们上面所说的,每个线程都有一个私有的本地工作内存并存储了线程间读/写的共享副本。所以当一个线程对这个副本进行修改而没有将这个修改后的值写入主内存中,亦或者这个修改后的值写入了主内存中而其他线程并没有去访问主内存中的值,依旧使用的是本地工作内存中的值,那么此时的并发就有产生问题。我们来看下面代码:
public class NoVisibility {
public static boolean ready = false;
private static class ReaderThread extends Thread {
public void run() {
while (true) {
if (ready) {
System.out.println("=== 即将结束循环 ===");
break;
}
}
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
Thread.sleep(2000);
ready = true;
System.out.println("ready = " + ready);
}
}
上面代码很可能会持续循环下去,永远不会打印出"=== 循环即将结束 ==="这段文字。我们在代码中可以显而易见的有两个线程,主线程和我们声明的ReaderThread
线程。 ready 这个变量在ReaderThread
线程开始循环时就已经被复制一份到本地工作内存中了,当主线程修改ready的值为true时,此修改对于其他线程并不可见,ReaderThread
线程并没有去读取新的值,一直使用本地工作内存中的值,所以会造成无限循环。
对于这个问题很好解决,只要将ready变量声明为volatile变量即可。
3. 有序性
有序性即程序按照我们代码所书写的那样,按其先后顺序执行。第一次接触这个特性可能会有所疑惑,所以在了解有序性之前我们需要来了解执行重排序以及相关概念。
3.1 指令重排序
为了提高性能,编译器和处理器会对程序的指令做重排序操作,重排序分为3种类型:
- 编译器优化的重排序:属于编译器重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 指令级并行的重排序:属于处理器重排序,现代处理器采用指令级并行技术来将多条指令重叠执行。如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序;
- 内存系统的重排序:处于处理器重排序由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
指令重排序对于程序执行有利有弊,我们并不是要去完全禁止它。对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型个的内存屏障指令,通过内存屏障指令来禁止特定的处理器重排序。
3.2 as-if-serial
as-if-serial语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r // C
如上个图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。一次在最终执行的指令序列种,C不能被重排序到A和B前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:
as-if-serial语义把单线程程序保护起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建一个幻觉:单线程程序是按程序顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
3.3 happens-before
如果说 as-if-serial 是 JMM 提供用来解决单线程间的内存可见性问题的话,那么 happens-before 就是JMM向程序员提供的可跨越线程的内存可见性保证。具体表现为:如果线程A的写操作a与线程B的读操作b之间具有 happens-before 关系,那么JMM将保证这个操作a对操作b可见。此外,happens-before 还有传递关系,表现为:a happens-before b,b happens-before c,那么a happens-before c。
注意:两个操作之间存在happens-before关系,并不意味着一个操作必须要在后一个操作之前执行,只要求前一个操作执行的结果对后一个操作可见。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不违法(也就是说,JMM允许这种重排序)。
比对 happens-before 与 as-if-serial。
-
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
-
as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序来执行的。
-
as-if-serial 语义和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
所以,总的说来 happens-before 与 as-if-serial 在本质上是同一种概念。
五、volatile变量
volatile可以视为轻量级的synchronized,可以确保共享变量在各个线程间的“可见性”。
1. volatile内存语义
我们可以将volatile变量的读写操作分别视之为 get 方法和 set 方法,所以从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。
2. volatile缓存可见性实现原理
底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存。其中lock前缀指令在多核处理器下会引发两件事情:
- 会将当前处理器缓存行的数据立即回写到系统内存。
- 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(通过MESI缓存一致性协议)。
3. volatile的应用
volatile变量的一个种典型的用法:检查某个状态标记以判断是否退出循环。
还有单例模式的实现,典型的双重检查锁定(即DCL)。
参考
-
《Java并发编程的艺术》
网友评论