1. 程式人生 > >Java記憶體模型之可見性問題

Java記憶體模型之可見性問題


本部落格系列是學習併發程式設計過程中的記錄總結。由於文章比較多,寫的時間也比較散,所以我整理了個目錄貼(傳送門),方便查閱。

併發程式設計系列部落格傳送門


前言

之前的文章中講到,JMM是記憶體模型規範在Java語言中的體現。JMM保證了在多核CPU多執行緒程式設計環境下,對共享變數讀寫的原子性、可見性和有序性。

本文就具體來講講JMM是如何保證共享變數訪問的可見性的。

什麼是可見性問題

我們從一段簡單的程式碼來看看到底什麼是可見性問題。


public class VolatileDemo {

    boolean started = false;

    public void startSystem(){
        System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
        started = true;
        System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
    }

    public void checkStartes(){
        if (started){
            System.out.println("system is running, time:"+System.currentTimeMillis());
        }else {
            System.out.println("system is not running, time:"+System.currentTimeMillis());
        }
    }

    public static void main(String[] args) {
        VolatileDemo demo = new VolatileDemo();
        Thread startThread = new Thread(new Runnable() {
            @Override
            public void run() {
                demo.startSystem();
            }
        });
        startThread.setName("start-Thread");

        Thread checkThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    demo.checkStartes();
                }
            }
        });
        checkThread.setName("check-Thread");
        startThread.start();
        checkThread.start();
    }

}

上面的列子中,一個執行緒來改變started的狀態,另外一個執行緒不停地來檢測started的狀態,如果是true就輸出系統啟動,如果是false就輸出系統未啟動。那麼當start-Thread執行緒將狀態改成true後,check-Thread執行緒在執行時是否能立即“看到”這個變化呢?答案是不一定能立即看到。這邊我做了很多測試,大多數情況下是能“感知”到started這個變數的變化的。但是偶爾會存在感知不到的情況。請看下下面日誌記錄:


start-Thread begin to start system, time:1577079553515
start-Thread success to start system, time:1577079553516  
system is not running, time:1577079553516   ==>此處start-Thread執行緒已經將狀態設定成true,但是check-Thread執行緒還是沒檢測到
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519

上面的現象可能會讓人比較困惑,為什麼有時候check-Thread執行緒能感知到狀態的變化,有時候又感知不到變化呢?這個現象就是在多核CPU多執行緒程式設計環境下會出現的可見性問題。

Java記憶體模型規定了所有的變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體,執行緒在工作記憶體中儲存的值是主記憶體中值的副本,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體。等到執行緒對變數操作完畢之後會將變數的最新值重新整理回到主記憶體。

但是何時重新整理這個最新值又是隨機的。所以就有可能一個執行緒已經將一個共享變數更新了,但是還沒重新整理回主記憶體,那麼這時其他對這個變數進行讀寫的執行緒就看不到這個最新值。這個就是多CPU多執行緒程式設計環境下的可見性問題。也是上面程式碼會出現問題的原因。

JMM對可見性問題的保證

在多CPU多執行緒程式設計環境下,對共享變數的讀寫會出現可見性問題。但是幸好JMM提供了相應的技術手段來幫我們規避這些問題,可以讓程式正確執行。JMM針對可見性問題,主要提供瞭如下手段:

  • volatile關鍵字
  • synchronized關鍵字
  • Lock鎖
  • CAS操作(原子操作類)

volatile關鍵字

使用volatile關鍵字修飾一個變數可以保證變數的可見性。所以對於上面的程式碼,我們只需要簡單的修改下程式碼就可以讓程式正確運行了。

private volatile boolean started = false;

使用volatile修飾一個共享變數可以達到如下的效果:

  • 一旦執行緒對這個共享變數的副本做了修改,會立馬重新整理最新值到主記憶體中去;
  • 一旦執行緒對這個共享變數的副本做了修改,其他執行緒中對這個共享變數拷貝的副本值會失效,其他執行緒如果需要對這個共享變數進行讀寫,必須重新從主記憶體中載入。

那麼volatile具體是怎麼達到上面兩個效果的呢?其實volatile底層使用的是記憶體屏障來保證可見性的。

記憶體屏障(英語:Memory barrier),也稱記憶體柵欄,記憶體柵障,屏障指令等,是一類同步屏障指令,是CPU或編譯器在對記憶體隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行後才可以開始執行此點之後的操作。大多數現代計算機為了提高效能而採取亂序執行,這使得記憶體屏障成為必須。

語義上,記憶體屏障之前的所有寫操作都要寫入記憶體;記憶體屏障之後的讀操作都可以獲得同步屏障之前的寫操作的結果。因此,對於敏感的程式塊,寫操作之後、讀操作之前可以插入記憶體屏障。
                 

對記憶體屏障做下簡單的總結:

  • 記憶體屏障是一個指令級別的同步點;
  • 記憶體屏障之前的寫操作都必須立馬重新整理回主記憶體;
  • 記憶體屏障之後的讀操作都必須從主記憶體中讀取最新值;
  • 在有記憶體屏障的地方,會禁止指令重排序,即屏障下面的程式碼不能跟屏障上面的程式碼交換執行順序,即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成。

synchronized關鍵字

使用synchronized程式碼塊或者synchronized方法也可以保證共享變數的可見性。只要如下修改上面的程式碼,我們就能得到正確的執行結果。


public synchronized void startSystem(){
    System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
    value = 2;
    started = true;
    System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
}

public synchronized void checkStartes(){
    if (started){
        System.out.println("system is running, time:"+System.currentTimeMillis());
    }else {
        System.out.println("system is not running, time:"+System.currentTimeMillis());
    }
}

當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中。當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數。我們發現鎖具有和volatile一致的記憶體語義,所以使用synchronized也可以實現共享變數的可見性。

Lock介面

使用Lock相關的實現類也可以保證共享變數的可見性。其實現原理和synchronized的實現原理類似,這邊也就不再贅述了。

CAS機制(Atomic類)

使用原子操作類也可以保證共享變數操作的可見性。所以我們只要如下修稿上面的程式碼就行了。

private AtomicBoolean started = new AtomicBoolean(false);

原子操作類底層使用的是CAS機制。Java中CAS機制每次都會從主記憶體中獲取最新值進行compare,比較一致之後才會將新值set到主記憶體中去。而且這個整個操作是一個原子操作。所以CAS操作每次拿到的都是主記憶體中的最新值,每次set的值也會立即寫到主記憶體中