天天看點

【07】多線程可見性的根源問題和volatile關鍵字的作用是什麼?

作者:Java面試技術棧
【07】多線程可見性的根源問題和volatile關鍵字的作用是什麼?

交個朋友

什麼是可見性

先看一段代碼,下面這段示範了一個使用volatile以及沒有使用volatile關鍵字,對于變量的影響。

public /*volatile*/ static boolean stop = false;

public static void main(String[] args) {
   Thread thread1 = new Thread(() -> {
      int a = 0;
      while (!stop) {
         a++;
      }
      System.out.println("a的值 = " + a);
   }, "thread1");

   Thread thread2 = new Thread(() -> {
      //休息一秒然後修改stop值
      ThreadUtil.sleep(1000L);
      stop = true;
   }, "thread1");

   thread1.start();
   thread2.start();
}
           

運作發現帶有voatile關鍵字當thread2修改變量後,thread1就會停下。不帶有的關鍵字的不會停下。這就是可見性問題,多線程情況下修改變量對其他線程不可見。

可見性問題是指在多線程環境中,一個線程對共享變量的修改,其他線程不能立即看到。

從硬體層面了解可見性

計算機的核心元件是CPU、記憶體和I/O裝置。它們在處理速度上存在差異,CPU最快,記憶體次之,I/O裝置最慢。大多數程式需要通路記憶體,有些可能還需要通路I/O裝置。

為了提升計算性能,CPU不斷更新,從單核到多核甚至采用超線程技術,但僅僅提升CPU性能,如果記憶體和I/O裝置的處理速度沒有跟上,整體計算效率會受限于最慢的裝置。為了平衡三者的速度差異,最大化利用CPU提升性能,進行了硬體、作業系統和編譯器等方面的優化。

這些優化包括:

  1. CPU增加高速緩存,加快資料通路速度。
  2. 作業系統引入程序和線程,通過時間片切換最大化利用CPU。
  3. 編譯器進行指令優化,充分利用CPU的高速緩存。

然而,每種優化都會帶來相應的問題,其中一些問題導緻了線程安全性問題的産生。為了了解可見性問題的本質,我們有必要了解這些優化過程。

CPU高速緩存

線程是CPU排程的最小單元,其設計目的是為了更充分地利用計算機的處理能力。然而,大多數計算任務不能僅依靠處理器進行計算,處理器還需要與記憶體進行互動,例如讀取運算資料和存儲運算結果,這些I/O操作很難消除。

由于計算機的儲存設備與處理器的運算速度存在很大差距,是以現代計算機系統引入了高速緩存作為記憶體和處理器之間的緩沖區,以實作讀寫速度接近處理器運算速度的效果。資料會被複制到緩存中,使得運算可以快速進行,然後在運算結束後将資料從緩存同步回記憶體中。這樣可以減少處理器等待記憶體的時間,提高計算效率。

【07】多線程可見性的根源問題和volatile關鍵字的作用是什麼?

cpu高速緩存

引入高速緩存很好的解決了cpu和記憶體讀寫的差距,但也提高了計算機的複雜度,也引入了新的問題,緩存一緻性。

緩存一緻性問題

有了高速緩存之後,cpu處理過程是,先将計算用到的資料緩存到高速緩存中,然後cpu計算,直接從高速緩存中讀取然後寫入緩存中,在運算結束後,再從高速緩存寫入到主記憶體。在多cpu,多核的情況下,每個cpu都有自己的高速緩存,同一份資料可能被緩存到多個cpu中,導緻多個cpu看到的同一個緩存值不一樣,就産生了緩存一緻性問題。為了解決緩存一緻性問題,引入了總線鎖和緩存鎖。

總線鎖和緩存鎖

總線鎖就是再多cpu下,其中一個cpu要對共享記憶體進行操作的時候,在總線上發出一個指令LOCK#,這個指令是的其他的cpu無法通過總線通路主記憶體,這使得其他cpu在總線鎖期間不能操作其他的記憶體,開銷比較大,很顯然這種不合适。

優化,就是減小鎖的力度,隻對需要被保護的緩存加鎖,是以就引入了緩存鎖,它的核心是緩存一緻性協定,鎖的是緩存行。

緩存一緻性協定

最常見的協定就是EMSI協定。

MESI表示四種狀态,分别是:

  • M 修改 (Modified)
描述:該Cache line有效,資料被修改了,和記憶體中的資料不一緻,資料隻存在于本Cache中。 監聽任務:緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存将該緩存行寫回主存并将狀态變成S(共享)狀态之前被延遲執行。
  • E 獨享、互斥 (Exclusive)
描述:該Cache line有效,資料和記憶體中的資料一緻,資料隻存在于本Cache中。 監聽任務:緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S(共享)狀态。
  • S 共享 (Shared)
描述:該Cache line有效,資料和記憶體中的資料一緻,資料存在于很多Cache中。 監聽任務:緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,并将該緩存行變成無效(Invalid)。
  • I 無效 (Invalid)
描述:該Cache line無效。 監聽任務:無

CPU 讀請求:緩存處于 M、E、S 狀态都可以被讀取,I 狀态 CPU 隻能從主存中讀取資料

CPU 寫請求:緩存處于 M、E 狀态才可以被寫。對于 S 狀态的寫,需要将其他 CPU 中緩存行置為無效才可寫使用總線鎖和緩存鎖機制之後才可以寫。

MESI雖然可以實作緩存一緻性,但是也會存在一些問題,當一個cpu1要對一個緩存進行寫入的時候,首先要發送消息給其他的緩存通知他們失效,并且要等待他們确認回執。這時候cpu1會一直處于阻塞狀态,為了避免阻塞引入了Store Buffer, cpu1寫入資料到store Buffer,并且發送失效的消息給其他cpu,然後cpu1繼續執行其他指令,等到cpu1收到回執消息之後,把store Buffer寫到cache line中,最後同步到主記憶體中。

引入store Buffer依然存在問題:

1.因為是異步操作,是以資料什麼時候送出的不确定,需要等到其他cpu确認後才會進行同步。

2.引入store buffer後,處理器會先從store buffer中讀取資料,如果沒有在去緩存行中讀取。

舉例:

value = 1;
stop = true;

void cpu1(){
   value = 10;
   stop = false;
}

void cpu2(){
   if(!stop){
      System.out.println(value);
   }
}
           

兩個cpu去運作,假如value是共享,變量stop是獨占,cpu1運作到value=10,然後就異步通知其他的cpu失效,cpu1這時繼續運作,stop=false,由于是獨占直接修改。這時cpu2開始運作,發現stop為false,繼續運作,當它開始讀取value時候,由于cpu1的消息還沒通知到cpu2,有可能還是讀取的舊值。

為了解決這個問題,cpu層面提出了記憶體屏障指令,可以了解為就是将store buffer強制flush到主線上,不異步通知了。

指令重排序的可見性問題

JVM執行程式時可能會對指令進行重排序的主要目的是為了優化程式的性能和執行效率。指令重排序是指在單線程下不改變程式的語義和最終結果的前提下,重新安排指令的執行順序。

舉一個常見的問題,懶加載單例模式下,

public class LazySimpleSingleton {

    private static LazySimpleSingleton lazySimpleSingleton = null;
    int v;

    /**
     * 私有化
     */
    private LazySimpleSingleton() {
        v = 1;
    }

    /**
     * 雙重檢查
     *
     * @return
     */
    public static LazySimpleSingleton getInstanceDoubleCheck() {
  //将synchronized放到裡邊,減小鎖的範圍,提高性能
  //synchronized放到裡邊就必須用到雙重檢查鎖,防止多個線程争搶建立多個執行個體.
        if (lazySimpleSingleton == null) {
            synchronized(LazySimpleSingleton.class) {
                if (lazySimpleSingleton == null) {
                    // cpu 執行時候會轉換為JVM指令執行
                    // 1: 配置設定記憶體給對象
                    // 2: 初始化對象,包括設定對象的預設值
                    // 3: 執行構造函數
                    // 4: 傳回對象引用
                    lazySimpleSingleton = new LazySimpleSingleton();
                }
            }
        }
        return lazySimpleSingleton;
    }

}     
           

new一個對象時候,會執行一下操作 1: 配置設定記憶體給對象 2: 初始化對象,包括設定對象的預設值 3: 執行構造函數 4: 傳回對象引用

如果在new對象的時候,先傳回對象引用,然後在進行初始化和執行構造函數,在單線程情況下沒有問題,但是在多線程情況下會存在問題,另外一個線程可能拿到了尚未完全初始化的執行個體,就會導緻問題,就會導緻可見性問題。

JMM層面可見性問題

「JMM定義」:Java虛拟機規範中定義了Java記憶體模型(Java Memory Model,JMM),用于屏蔽掉各 種硬體和作業系統的記憶體通路差異,以實作讓Java程式在各種平台下都能達到一緻的并發效果。

「JMM記憶體互動」

【07】多線程可見性的根源問題和volatile關鍵字的作用是什麼?

JMM記憶體互動

由于互動操作不是原子的,有可能在一個線程執行,assign,store時候切換上下文,沒有立即執行write寫回到主記憶體,導緻其他線程讀取資料的時候不是最新的資料。

volatile關鍵字

1.硬體層面上volatile底層用到了記憶體屏障,也就是store buffer裡邊通知其他cpu失效指令強刷到主線上。

2.volatile禁止JVM指令重排序。

3.volatile可以讓JMM層面上read,load,use和assign,store,write是原子操作,也就是cpu必須連續執行不能切換上下文,在對變量進行寫操作後,必須立即将這個更改重新整理到主記憶體中。這樣,其他線程在讀取這個變量時就能直接從主記憶體中讀取最新的值。

  • 要求在工作記憶體中,每次使用變量前都必須先從主記憶體重新整理最新的值(固定的 load -> use 順序),用于保證能看見其他線程對變量所做的修改後的值。
  • 要求在工作記憶體中,每次修改變量後都必須立刻同步回主記憶體中(固定的 assign -> store 順序),用于保證其他線程可以看到目前線程對變量所做的修改。

結合上邊說的來看幾個問題:

1.volatile變量能否保證原子性?為什麼?

volatile能保證JMM互動指令的原子性,不能保證java代碼的原子性。

2.volatile變量的寫操作和普通變量的寫操作有什麼差別?

volatile的寫操作JMM互動指令會讓新資料立即寫到主記憶體,這期間不讓cpu切換上下文,也就是原子操作。普通變量有可能被切換上下文,導緻沒有立即寫入主記憶體。

volatile底層用到了cpu提供的記憶體屏障,緩存一緻性協定MESI中通知其他cpu失效是同步的。而普通對象則是異步的。

3.單例雙重檢查鎖定為什麼需要使用volatile關鍵字?

JVM會指令重排序,保證在單線程的情況下,結果永遠一樣。但是多線程就不保證了。這就有可能導緻問題。平常new一個對象分為四步驟:

1: 配置設定記憶體給對象 2: 初始化對象,包括設定對象的預設值 3: 執行構造函數 4: 傳回對象引用

指令重排序,多線程的情況下,如果第4步排在第1步後邊,導緻其他線程拿到了未經過完全初始化的對象,有可能導緻程式出現錯誤。volatile禁止了指令重排序是以不會出問題。

【07】多線程可見性的根源問題和volatile關鍵字的作用是什麼?

交個朋友

繼續閱讀