我们来看一下并发编程中的原子性、可见性、有序性是怎么来的。
早期CPU的频率比内存的频率要高很多,如果CPU每次都从内存取数据的话,就会造成快车等慢车的状态,严重影响CPU的性能。为了解决这个问题,CPU中引入了缓存。缓存的频率很高,几乎跟CPU一个级别。于是就将一些用到的重要数据复制一份放到缓存中,CPU直接跟缓存交互,就能消除内存与CPU频率相差较大的问题了。
在单核CPU的时代没有这么多的麻烦事,后来为了提升性能开始使用多核CPU。CPU中的每一个核心都有缓存,于是对于内存中的同一份数据,在各个CPU缓存中有一份副本,于是问题就来了!
一、 可见性问题
主内存中有一个int data = 0,则CPU0和CPU1的缓存中都存有一份data的副本,值也为0。当CPU0执行一个data++操作后,副本1的data数据变成了1。副本1的data还没有写回主内存,主内存的data值为0,副本2的data值也为0。因此,这个时候两个副本的值不一致了,如果继续操作就会造成数据的错误。
一个线程修改了共享变量的值,是否能够立即被其他线程见到最新值,这就可见性问题。
java通过volatile关键字解决了可见性的问题。
volatile的实现原理是:
1. 当一个线程修改了共享变量,CPU的嗅探机制会发现副本与主内存数据的不一致,通过汇编lock前缀指令,锁定共享变量主内存区域,并将新值写回到主内存;
2. 写回内存的操作会使其他CPU中缓存了该数据的地址失效。(MESI协议,即缓存一致性协议)
二、原子性问题
原子性就是计算机中的一个不能再分割的动作,要么做完、要么不做,中间不会被打断。
我们对一个volatile修改的int值初始化为0,用10000个线程去对它进行+1操作。 期望中的结果是10000,但是实际上每次运行的结果都小于10000。
public static void main(String[] args) throws InterruptedException{
public static volatile int count = 0;
for(int i = 0; i < 10000; ++i){
new Thread(new Runnable(){
public void run(){
++count; //每个线程都对count进行+1
}
})
}
Thread.sleep(5000); //等待一下确保所有的线程都运行完了
System.out.println(count); //9985,9970
}
这就是因为volatile不能保证多线程计算的原子性问题。
假如CPU0的缓存里有一个副本count=0,对它进行+1后count=1, 然后将它写回主内存的过程中,需要有一个assign(将CPU0的计算结果放入CPU0的高速缓存)动作 和一个write(将CPU0的高速缓存的值写入主内存)动作,而这两步不是同时完成的。而这中间就有可能插入别的动作。
如果CPU0计算完+1后,还未将结果count=1写入主内存;CPU1见缝插针利用自己缓存中的count=0计算count+1,然后将值写入了主内存count=1,之后CPU0又茫然不知地将算好的结果count=1写入主内存。 这就造成了两次count++,结果却=1的情况。
java利用synchronized关键字来解决原子性问题。 (还有Lock和Atomic类,另讲)
synchronized实现原子性的原理是:
利用对象头中的mark word存储锁的信息,当一个线程占用这个对象时会将threadID写入对象头,其他线程就不能获取这个对象,以此来实现排他性。
三、 有序性
我们来看一段代码。 在两个线程中分别对原本为0的值x、y赋值,正常情况下x、y的值应该都为0,但是在10000次循环中很快就会出现x=1,y=1的情况而退出循环。
public class MainTest {
static int a =0, b = 0;
static int x = 0, y = 0;
public static void func(){
a = 0; b = 0;
x = 0; y = 0;
}
public static void main(String[] args){
for(int i = 0; i < 10000; ++i){
func();
new Thread(new Runnable(){
public void run(){
x = a;
a = 1;
}
}).start();
new Thread(new Runnable(){
public void run(){
y = b;
b = 1;
}
}).start();
System.out.println("x = " + x + " y = " + y);
if (1 == x && 1 == y) {
System.out.println("x = " + x + " y = " + y);
break;
}
}
}
}
这是因为存在着指令重排序的问题,编译器和处理器会对指令进行优化而出现重排序的情况。
在单线程的情况下没有影响,但在多线程情况下可能会产生影响。
java使用volatile关键字解决了指令的重排序问题,volatile的底层使用了内存屏障。针对跨处理器的读写操作,它被插入到两个指令之间,作用是禁止编译器和处理器重排序。
至此, 可见性、原子性、有序性的问题得到了解决。