内存模型
在多CPU系统上,每个CPU都有多级缓存,一般分为L1,L2,L3,正是因为这些缓存的存在,提供了数据的访问性能(CPU的处理速度大于硬盘速度),也减轻了数据总数的传输压力,同时也带来了一些挑战,比如两个CPU同时访问一个内存地址,会发生什么?在什么条件下可以看到相同结果。这些都是需要去解决的。
在CPU层面,内存模型的定义是充分必要的,其他CPU的写入操作对当前CPU是可见的,当前CPU的写入操作对其他CPU也是可见的。
-
保证这种可见性,有些处理器提供了强内存模型,所有CPU在任何时候都能看到CPU中任意位置相同的值,这种完全是硬件提供的支持。
-
其他处理器,提供了弱内存模型,需要执行一些特殊的指令(如:memory barriers内存屏障),刷新CPU缓存到主存中,保证这个写入操作对其他线程可见。或者将CPU缓存的状态设置为无效(重新从主存中获取数据),保证其他CPU的写操作对本CPU可见。
Java内存模型
在Java内存模型中,描述了在多线程代码中,哪些行为是正确的,合法的。以及多线程之间线程通讯,代码中的变量如何反映的主存,CPU缓存中的底层细节。在学习之前,我们需要认识几个基础概念:内存屏障(memory Barriers),指令重排序,happens-before规则,as-if-serial语义。
内存屏障 Memory Barrier
内存屏障,又称内存栅栏,是一个CPU指令
1,保证特定操作的执行顺序
2,影响某些数据(或者是某条指令的执行结果)的内存可见性
编译器和CPU可以重排指令,保证最终的执行结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU,不管什么指令,都不能和这条Memory Barrier指令重排序。
Memery Barrier 所做的另外一件事是强刷出各种CPU cache,如一个Write-barrier(写入屏障)将刷出所有在Barrier之前写入cashe的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。
指令重排序
- 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:如果不存l在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
happens-before规则
从jdk5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。
在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。
与程序员密切相关的happens-before规则如下:
1、程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
2、监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
3、volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
4、传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。
注意:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。
数据依赖性
如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。
编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。
as-if-serial语义
不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。
相关关键字
在Java中包含几个关键字:volatile、final、synchronized关键字,帮助程序员把代码中的并发需求描述给编译器。Java内存模型中定义了他们的行为,确保正确同步的Java代码在所有处理器架构上能够正确执行。
synchronized
对于同一个monitor对象,只能被一个线程持有,这是synchronized关键字的互斥功能,synchronized还保证了在同步代码块期间,进行的写入操作对其他后续进入同步代码块的线程是可见的,同步代码始终在获取和释放monitor之间。在线程释放monitor时,会将缓存中的数据刷新到内存中。其他线程获取monitor时,使CPU缓存失效,从而使变量重新在主存中加载。
final
如果一个类包含final字段,且在构造函数中初始化,那么正确的构造一个对象后,final字段被设置后对于其它线程是可见的。
这里所说的正确构造对象,意思是在对象的构造过程中,不允许对该对象进行引用,不然的话,可能存在其它线程在对象还没构造完成时就对该对象进行访问,造成不必要的麻烦。
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}
这里可以保证其他线程看到的x=3,但不能保证y=4。
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
volatile
volatiile主要用于线程间通讯,volatile保证每次的读行为都在其他线程最后一次对该变量的写行为之后,每一次读取数据都是主存的值。另外,volatile会禁止写入指令的重排序。
双重锁
double-checked locking双重锁是一种延迟初始化的技巧,避免了同步开销。
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
虽然这种方法看起来很聪明,但是有可能不起作用。因为实例的初始化和实例字段的写入,可能被编译器重排序,这样就有可能返回还未构造的对象,结果就是读到一个初始化未完成的对象。这个问题可以使用volatile修复。