Java內存模型之可見性問題


本博客系列是學習併發編程過程當中的記錄總結。因爲文章比較多,寫的時間也比較散,因此我整理了個目錄貼(傳送門),方便查閱。html

併發編程系列博客傳送門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這個變量的變化的。可是偶爾會存在感知不到的狀況。請看下下面日誌記錄:ide

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針對可見性問題,主要提供了以下手段:atom

  • 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的值也會當即寫到主內存中。

相關文章
相關標籤/搜索