Java內存模型以及Volatile關鍵字深度剖析

Java內存模型

Java內存模型應該說成Java線程內存模型,同CPU緩存模型類似,是基於CPU緩存模型來建立的,Java線程內存模型是標準化的屏蔽掉了底層計算機的區別。

如上圖所示每一個線程對應一個工作內存,相當於cpu的高速緩存,從主內存中獲取共享變量,並存貯一份共享變量副本,而每個線程的共享變量副本都是相對獨立的,那麼當我們寫多線程程序的時候,當不了解Java線程內存模型的時候,併發一上來就會出現這種bug,我們來看下面這個程序。

    private static boolean initFlag = false;

    public static void main(String[] args) throws InterruptedException {

        new Thread(()->{
            System.out.println("======>wait data");
            while(!initFlag){
            }
            System.out.println("==============>success");
        }).start();

        Thread.sleep(2000);

        new Thread(()->{
            prepareData();
        }).start();
    }

    public static void prepareData(){
        System.out.println("==============>prepareData start");
        initFlag = true;
        System.out.println("==============>prepareData end");
    }

這個程序線程1是執行一個循環,當initFlag值不變的時候,會一直循環。線程2是執行修改這個initFlag這個值,那麼理論上來說這個程序的數據結果應該是:

======>wait data
==============>prepareData start
==============>prepareData end
==============>success

實際上,並不是這樣的,它會陷入一個死循環,success不會輸出出來,按理說,線程一應該可以感受的到initFlag值的變化,實際上線程1一直讀取的是,工作內存中的共享變量副本。

先了解一下JMM的原子操作:

read(讀取):從主內存中讀取數據

load(載入):將主內存中讀取到的數據寫入工作內存中

use(使用):從工作內存中讀取數據來計算

assign(賦值):將計算好的值重新賦值到工作內存中

store(存儲):將工作內存數據寫入主內存中

write(寫入):將store過去的變量賦值給主內存中的變量

lock(鎖定):將主存變量加鎖,標識爲線程獨佔狀態

unlock(解鎖):將主存變量解鎖,解鎖後其他線程可以鎖定該變量。

那麼將上述程序結合JMM的原子操作分析如下圖所示:

 

 

首先線程1先從主內存讀取initFlag變量然後load到工作內存中後use--->!initFlag變量,此時工作內存中,線程2也從主內存中read-》initFlag變量然後load到工作內存中,use變量並assign到工作內存中,然後store將數據寫入主內存中,然後再write到主內存中,此時線程1讀取的還是工作內存中的值,導致值沒有一致性,那麼如何解決這個問題呢?

在initFlag變量加上volatile關鍵字:

private static volatile boolean initFlag = false;

衆所周知volatile可以使變量緩存可見性,並且禁止指令重排,那麼它底層是如何實現的呢?底層實現主要是通過彙編loca前綴指令,它會鎖定這塊內存區域的緩存(緩存行鎖定)

IA-32架構軟件開發手冊對lock指令解釋:

1)、會將當前處理器緩存行的數據立即寫回到系統內存

2)、這個寫會內存的操作會引起在其他CPU裏緩存了該內存地址的數據無效(MESI協議)

加了volatile關鍵字之後所對應JMM的原子操作操作如圖所示:

比較所不同之處:在store到主內存的時候,因爲MESI緩存一致性協議的存在,其他CPU裏有一個總線嗅探機制,可以理解爲監聽,該內存地址的數據改變則置爲無效,所以當再一次執行!initFlag指令時,會重新讀取載入數據,則獲取的時true,從而可以循環結束,輸出success,而如果兩個線程同時修改initFlag時,一個線程再修改時會有緩存行級鎖,所以需要等待釋放鎖之後再進行後續操作,這樣的話讀取使保持了數據的一致性,又不會影響性能,比直接再主內存加總線鎖要輕量級很多。

但是這樣又會暴露其他問題,再看以下代碼:

    private static volatile int num = 0;

    public static void increate(){
        num++;
    }

    public static void main(String[] args) throws InterruptedException {

        Thread[] threads = new Thread[10];

        for(int i = 0 ; i<=threads.length;i++ ){
            threads[i] = new Thread(()->{
               for(int j = 0; j< 1000 ;j++){
                   increate();
               }
            });
            threads[i].start();
        }

        for(Thread t:threads){
            t.join();
        }

        System.out.println(num);
    }

上述程序理論上應該輸出:10*1000=10000,實際上呢結果不一,可能是10000也是9888,這是爲什麼呢?按找上述描述加了volatile關鍵字有緩存行級鎖,那麼取值應該是對的,問題就出在這裏,線程1在執行num++的賦值之後store的時候有一個過程,線程2在store之前也執行了++操作,然後再線程一store write之後,由於cpu嗅探機制所以線程二的數據失效重新獲取,再操作一次++的時候就不是原子操作了,所以導致沒有達到預期的值,如圖所示:

那麼可以再++方法上加鎖保持操作的原子性,這裏就不討論加鎖及其細節了。

併發編程三個概念:

1、原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

2、可見性:是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

3、有序性:即程序執行的順序按照代碼的先後順序執行。

發佈了9 篇原創文章 · 獲贊 17 · 訪問量 5241
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章