(4)Java可见性、原子性、有序性的本质——CPU缓存模型

    我们来看一下并发编程中的原子性、可见性、有序性是怎么来的。

    早期CPU的频率比内存的频率要高很多,如果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的底层使用了内存屏障。针对跨处理器的读写操作,它被插入到两个指令之间,作用是禁止编译器和处理器重排序。

    至此, 可见性、原子性、有序性的问题得到了解决。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章