一. 缓存一致性问题
cpu在工作过程中需要从内存中获取各种数据,但是由于cpu的运算速度太快了,导致内存的传输速度跟不上cpu的运算速度,这样一来,内存就成了cpu实际性能上的一个瓶颈。为了解决这个问题,人们就想出来一个办法:在cpu和内存中加上高速缓存(cache),这种高速缓存在cpu和内存之间充当了一个缓冲区,并且集成在cpu芯片里,现在的cpu一般都有3级缓存,有的比较老的cpu只有2级缓存。(这里需要注意一点:L1和L2级缓存是每个核心都有一份的,也就是核心独有的,而L3级缓存是所有核心共享的)
这是我电脑里cpu的三级缓存,可以看到这三级缓存的大小从L3到L1依次递减,这是因为这三级缓存的速度依次递增,制造成本与难度也依次递增。所以缓存等级越高,每单位存储容量制造的难度也就越高。每一级缓存包含的数据都是下一级缓存中数据的一部分。
那么当cpu工作时,会先将主存中的数据复制一份放到缓存中,再从缓存中读取所需要的数据,而缓存的传输速度非常快,这样一来cpu的性能就不会因为内存的传输速度慢而被制约了。同样的,当cpu要把算完的数据写入主存中时也会把数据先在缓存中存一份,再把数据从缓存中刷新到主存中去。
下面我们分析一下这样的内存模型在不同的场景下会有什么问题:
- 单核cpu:此时不管在运行的线程有多少,cpu只有一个核心,所以缓存也只有一份,所有线程访问的都是一份缓存资源。所以此时没有访问的缓存不一致的问题。
- 多核单线程:此时当A核心运行了这根线程,产生的数据最终会通过缓存写入主存中,所以当B核心再运行这根线程时,在B核心的缓存中找不到之前A核心运算的数据,就回去主存中查找。这样得到的值也是A核心缓存中的最终值。并不会出现读取到的数据和之前A核心算完的数据不一致的情况。
- 多核多线程:因为cpu有多个核心,所以可以支持多根线程的并行运行,那么我们假设这样的一种情况:有A,B两根线程在同时访问一个共享数据x,并且在各自运行的核心上都存了一份x的备份,那么这两根线程实际上操作的都是各自关于x的备份,当A线程修改了x的值后,并没有通知B线程,所以B线程再访问x时还是从自己的缓存中去读,这样读到的就是A线程修改前的值。那么我们就可以说:A线程修改共享变量x的操作对B线程不可见!这就是可见性问题,那么造成这种问题的根本原因其实是A线程的缓存与B线程的缓存不一致所造成的。
那么要解决由于缓存一致性问题带来的可见性问题就必须保证以下两点:
1, 线程修改后的共享变量能及时的从缓存中刷新到主内存中。
2, 其他线程能够及时的把共享变量的最新值从主内存中更新到自己的缓存中。
二. 重排序问题
2.1重排序简介
除了上面讲的缓存一致性问题可能会导致对共享变量的可见性问题,计算机对指令的重排序也有可能会产生有序性问题。
重排序:代码的实际执行顺序与代码的书写顺序不一致,指令重排序是编译器或处理器为了提高程序性能而做出的优化,不仅许多cpu会对指令进行重排序,许多编程语言的编译器也会对指令重排序。
int a = 1;
int b = 2;
//重排序后。。。。
int b = 2;
int a = 1;
2.2 as-if-serial语义
as-if-serial语义说的是无论编译器或cpu对代码如何重排序,都必须保证重排序后代码的执行结果与代码不重排序执行的结果一致
为了保证as-if-serial语义,编译器和处理器不会对相互之间有数据依赖关系的代码重排序。
int a = 1; //第一行
int b = 2; //第二行
int c = a + b; //第三行
上面代码中,由于第三行和第一行和第二行之间有数据依赖关系,所以由于as-if-serial语义,编译器和处理器不会改变第三行相对于一,二两行的位置关系,但是可能会对第一行和第二行进行重排序,因为它们之间没有数据依赖关系。
但是as-if-serial语义也只是在单线程的环境下适用,在多线程的环境下它也有可能会失效,比如下面的代码:
public class Thread1 extends Thread {
@Override
public void run() {
Test.num = 2; //代码一
Test.flag = true; //代码二
}
}
public class Thread2 extends Thread {
@Override
public void run() {
if (Test.flag) {
System.out.println(Test.num * 4);
}
}
}
public class Test {
public static boolean flag;
public static int num;
public static void main(String[] args) {
new Thread1().start();
new Thread2().start();
}
}
上面的代码中,线程一完成对num
变量的写入操作,线程二去读num变量的值,由于代码一和代码二之间没有数据依赖关系,所以处理器和编译器可能会对其进行重排序,先执行代码二在执行代码一。这样的话可能线程一先把flag
设为了true
,由于此时线程二可能正在执行if
判断,发现flag为true
,就高高兴兴的执行了输出语句,但是此时线程一还没有完成写入操作,这样产生的结果就与我们期望的不符,这就是有序性问题。
原子性问题
原子性实际上有两个方面:
- 狭义上说,如果一个操作在cpu执行过程中不会被分步执行,就说这个操作时原子性操作。广义上说,如果一个操作就算会被cpu分步执行,但是这些步骤的执行结果要么全部失败,要么全部成功,那么也说这是个原子性操作。
- 原子性还保证了在多线程的环境下,当一个线程开始执行某个操作时,不会被其他的线程所影响。
i++ // i++这行代码在cpu执行时会分为三步:1.将i的值复制到寄存器中;
// 2.修改i的值加一
// 3.将修改完的值写回缓存在同步到主存
// 所以i++不是一个原子性操作
java内存模型
上面分别介绍了可见性,原子性,有序性。这就是并发编程的三大拦路虎,几乎所有的并发问题都可以归结到这三大特性上。那么java内存模型就是为了解决这些问题所制定的一套规范。这套规范映射到面向程序员的代码层面就被封装成了synchronized和volatile等关键字
-
可见性的实现:
Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。(除了volatile关键字,还有synchronized,final以及JDK8中JUC包中的CAS算法也能保证可见性,这里就不在展开了,感兴趣的可以自行了解) -
原子性的实现:
java中提供了 sychronized代码块,lock接口,以及CAS算法用来保证原子性。(这里CAS保证原子性操作的方式和锁机制不太一样,锁机制其实是一种粗暴的阻塞其他线程的方式,而CAS算法却是一种非阻塞的方式,所以在性能上会比较高效,这里不再展开) -
有序性的实现:
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会直接禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作,一条线程单独操作的情况下,as-if-serial语义也可以间接保证有序性。
参考资料:http://www.hollischuang.com/archives/2550
最后,由于笔者经验有限,若有表达不合理之处,欢迎指出