從計算機的角度理解volatile關鍵字

原文:https://mp.weixin.qq.com/s/l6dbdilAwUhiqNO0O-K9Wg

極簡計算機發展史

 

我們知道,計算機CPU和內存的交互是最頻繁的,內存是我們的高速緩存區。而剛開始用戶磁盤和CPU進行交互,CPU運轉速度越來越快,磁盤遠遠跟不上CPU的讀寫速度,才設計了內存,但是隨着CPU的發展,內存的讀寫速度也遠遠跟不上CPU的讀寫速度,因此,爲了解決這一矛盾,CPU廠商在每顆CPU上加入了高速緩存,用來緩解這種症狀,因此,現在CPU同內存交互就變成了下面的樣子。

單核CPU的性能不可能無限制的增長,要想很多的提升新能,需要多個處理器協同工作。 基於高速緩存的存儲交互很好的解決了處理器與內存之間的矛盾,也引入了新的問題:緩存一致性問題。在多處理器系統中,每個處理器有自己的高速緩存,而他們又共享同一塊內存(下文成主存,main memory 主要內存),當多個處理器運算都涉及到同一塊內存區域的時候,就有可能發生緩存不一致的現象。爲了解決這一問題,需要各個處理器運行時都遵循一些協議,在運行時需要用這些協議保證數據的一致性。

緩存一致性協議中最出名的就是Intel 的MESI協議,MESI協議保證了每個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存設置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中該變量是無效狀態,那麼它就會從內存重新讀取

 

Java內存模型

 

Java的內存模型和上面的結構還是挺相似的,此時在看工作內存和主內存關係,從邏輯上,高速緩存對應工作內存,每個線程分配到CPU時間片時,獨自享有高速緩存的使用能力。主內存對應存儲的物理內存。特別注意,這只是邏輯上的對等關係,物理的上具體對應關係十分複雜,這裏不討論。

 

volatile的作用是什麼

 

volatile可以保證可見性,有序性,但不能保證原子性

 

可見性

 

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

 

假如說有2個線程對一個變量data進行操作,線程先會把主內存中的值緩存到工作內存,這樣做的原因和上面提到的高速緩存類似,提高效率

 

但是這樣會引入新的問題,假如說線程A把data修改爲1,線程A的工作內存data值爲1,但是主內存和線程B的工作內存data值爲0,此時就有可能出現Java併發編程中的可見性問題

 

舉個例子,如下面代碼,線程A已經將flag的值改變,但是線程B並沒有及時的感知到,導致一直進行死循環

 

public class Test {

    public static boolean flag = false;

    public static void main(String[] args) {

        new Thread(()->{
            while(!flag) {
            }
            System.out.println("threadB end");
        }).start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(()->{
            flag = true;
            System.out.println("threadA end");
        }).start();
    }

}

 

輸出爲,線程B一直沒有結束

threadA end

 

但是如果將data定義爲如下形式,線程A對data的變更,線程B立馬能感知到

 

public static volatile boolean flag = false;

 

輸出爲

threadA end
threadB end

 

那麼是如何實現的呢?其實volatile保證可見性的方式和上面提到的緩存一致性協議的原理很類似

 

  1. 線程A將工作內存的data更改後,強制將data值刷回主內存

  2. 如果線程B的工作內存中有data變量的緩存時,會強制讓這個data變量緩存失效

  3. 當線程B需要讀取data變量的值時,先從工作內存中讀,發現已經過期,就會從主內存中加載data變量的最新值了

 

放個圖理解的更清楚

有序性

 

有序性即程序執行的順序按照代碼的先後順序執行

 

int i = 0;              
boolean flag = false;
i = 1;                //語句1  
flag = true;          //語句2

 

上面代碼定義了一個int型變量,定義了一個boolean類型變量,然後分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那麼JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,爲什麼呢?這裏可能會發生指令重排序(Instruction Reorder)。

 

下面解釋一下什麼是指令重排序,一般來說,處理器爲了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。

 

比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那麼就有可能在執行過程中,語句2先執行而語句1後執行。

 

但是有依賴關係的語句不會進行重排序,如下面求圓面積的代碼

 

double pi = 4.14   //A
double r = 1.0     //B
double area = pi * r * r   //c 

 

程序的執行順序只有下面這2個形式
A->B->C和B->A->C,因爲A和C之間存在依賴關係,同時B和C之間也存在依賴關係。因此最終執行的指令序列中C不能被重排序到A和B前面。

 

雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子

 

//線程1:
context = loadContext();   //語句1
inited = true;             //語句2

//線程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

 

上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以爲初始化工作已經完成,那麼就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。

 

從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程併發執行的正確性

 

當寫雙重檢測鎖定版本的單例模式時,就要用到volatile來保證可見性

 

public class Singleton {

   private volatile static Singleton uniqueInstance;

   private Singleton() {}

   public static Singleton getInstance() {
       if (uniqueInstance == null) {
           synchronized (Singleton.class) {
               if (uniqueInstance == null) {
                   uniqueInstance = new Singleton();
               }
           }
       }
       return uniqueInstance;
   }
}

 

至於爲什麼要用volatile,看推薦閱讀。

 

原子性

 

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

 

public class Test {

    public static volatile int inc = 0;

    public static void main(String[] args) {

        //新建一個線程池
        ExecutorService service = Executors.newCachedThreadPool();
        //Java8 lambda表達式執行runnable接口
        for (int i = 0; i < 5; i++) {
            service.execute(() -> {
                for (int j = 0; j < 1000; j++) {
                    inc++;
                }
            });
        }

        //關閉線程池
        service.shutdown();
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("inc = " + inc);
    }

}

 

執行上述代碼結果並不是每次都是5000,表明volatile並不能保證原子性

 

可能有的朋友就會有疑問,不對啊,上面是對變量inc進行自增操作,由於volatile保證了可見性,那麼在每個線程中對inc自增完之後,在其他線程中都能看到修改後的值啊,所以有5個線程分別進行了1000次操作,那麼最終inc的值應該是1000*5=5000。

 

這裏面就有一個誤區了,volatile關鍵字能保證可見性沒有錯,但是上面的程序錯在沒能保證原子性。可見性只能保證每次讀取的是最新的值,但是volatile沒辦法保證對變量的操作的原子性。

 

在前面已經提到過,自增操作是不具備原子性的,它包括讀取變量的原始值、進行加1操作、寫入工作內存。那麼就是說自增操作的三個子操作可能會分割開執行,就有可能導致下面這種情況出現:

 

假如某個時刻變量inc的值爲10,線程1對變量進行自增操作,線程1先讀取了變量inc的原始值,然後線程1被阻塞了;然後線程2對變量進行自增操作,線程2也去讀取變量inc的原始值,由於線程1只是對變量inc進行讀取操作,而沒有對變量進行修改操作,所以不會導致線程2的工作內存中緩存變量inc的緩存行無效,也不會導致主存中的值刷新,所以線程2會直接去主存讀取inc的值(這個部分小編感覺是海子大佬的筆誤,應該是線程2會直接去工作內存讀取inc的值,因爲工作內存中inc並沒有失效),發現inc的值時10,然後進行加1操作,並把11寫入工作內存,最後寫入主存。

 

然後線程1接着進行加1操作,由於已經讀取了inc的值(inc++,包括3個操作,1.讀取inc的值,2.進行加1操作,3.寫入新的值),注意此時在線程1的工作內存中inc的值仍然爲10,所以線程1對inc進行加1操作後inc的值爲11,然後將11寫入工作內存,最後寫入主存。

 

那麼兩個線程分別進行了一次自增操作後,inc只增加了1。

 

根源就在這裏,自增操作不是原子性操作,而且volatile也無法保證對變量的任何操作都是原子性的。

 

解決方案:可以通過synchronized或lock,進行加鎖,來保證操作的原子性。也可以通過使用AtomicInteger

 

應用

 

前面已經演示過了

1.狀態標記量

2.單例模式中的double check,看推薦閱讀

 

參考資料

 

《Java併發編程的藝術》

博客園 海子博客

 

推薦閱讀

深入理解單例模式

幫你體系化的學習Git

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