多線程篇 之 volatile

生於憂患,死於安樂~ 道理都懂,有些人卻醒着醉

 

此前項目中看到了 AtomicInteger 這個關鍵字,然後順藤摸瓜瞅到了介個,構造器~還有 一個 加持了volatile關鍵字的 value。

  private volatile int value;

    /**
     * Creates a new AtomicInteger with the given initial value.
     *
     * @param initialValue the initial value
     */
    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

爲什麼需要AtomicInteger原子操作類?

  • 對於Java中的運算操作,例如自增或自減,若沒有進行額外的同步操作,在多線程環境下就是線程不安全的。

  • 原子類相比於普通的鎖,粒度更細、效率更高(除了高度競爭的情況下)。

而AtomicInteger 的自增 最後會調用如下:

unsafe.compareAndSwapInt(this, valueOffset, expect, update);

這裏利用Unsafe類的JNI方法實現,使用CAS指令,可以保證讀-改-寫是一個原子操作。

快走遠了,後續有空寫個再寫原子類吧,回到正題,總而言之利用了 關鍵字 volatile。

 

正題

先看一段代碼(基於JDK1.8)

/**
 *
 * @Description 
 * @author saiuna
 */
public class saiuna_Volatile {

	boolean running = true;
	void saiuna() {
		System.out.println("t1 start");
		while(running) {

		}
		System.out.println("t1 end!");
	}
	
	public static void main(String[] args) throws InterruptedException {
		saiuna_Volatile t = new saiuna_Volatile();
		
		new Thread(t::saiuna, "t1").start();
		//上一行代碼等價於 下面註釋的代碼
		/*new Thread(new Runnable() {
			@Override
			public void run() {
				t.m();
			}
		}, "t1").start();*/

		TimeUnit.SECONDS.sleep(1);
		t.running = false;
	}
	
}

發現lamda表達式真香~🤭 運行結果輸出了: t1 start, 不會運行t1 end!,

running是存在於堆內存的 t 對象中
當線程t1開始運行的時候,會把running值從內存中讀到t1線程的工作區,在運行過程中直接使用這個copy,並不會每次都去讀取堆內存,這樣,當主線程修改running的值之後,t1線程感知不到,所以不會停止運行。

 

啥主內存?😮 讓JMM出來解釋下

JMM(JavaMemoryModel)

 

現代計算機的內存模型

早期計算機中cpu和內存的速度是差不多的,但在現代計算機中,cpu的指令速度遠超內存的存取速度,由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統加入一層讀寫速度儘可能接近處理器運算速度的高速緩存(Cache)來作爲內存與處理器之間的緩衝。

將運算需要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。

基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是也爲計算機系統帶來更高的複雜度,因爲它引入了一個新的問題:緩存一致性(CacheCoherence)。

在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(MainMemory)。=

 

Java內存模型(JavaMemoryModel)

描述了Java程序中各種變量(線程共享變量)的訪問規則,以及在JVM中將變量,存儲到內存和從內存中讀取變量這樣的底層細節。

JMM規定:

  1. 所有的共享變量都存儲於主內存,這裏所說的變量指的是實例變量和類變量,不包含局部變量,因爲局部變量是線程私有的,因此不存在競爭問題。
  2. 每一個線程還存在自己的工作內存,線程的工作內存,保留了被線程使用的變量的工作副本。
  3. 線程對變量的所有的操作(讀,取)都必須在工作內存中完成,而不能直接讀寫主內存中的變量。
  4. 不同線程之間也不能直接訪問對方工作內存中的變量,線程間變量的值的傳遞需要通過主內存中轉來完成。

 

JMM的抽象示意圖如圖:

JMM抽象示意圖

 

 

從圖中可以看出:

  1. 所有的共享變量都存在主內存中。
  2. 每個線程都保存了一份該線程使用到的共享變量的副本。
  3. 如果線程A與線程B之間要通信的話,必須經歷下面2個步驟:
    1. 線程A將本地內存A中更新過的共享變量刷新到主內存中去。
    2. 線程B到主內存中去讀取線程A之前已經更新過的共享變量。

所以,線程A無法直接訪問線程B的工作內存,線程間通信必須經過主內存。

注意,根據JMM的規定,線程對共享變量的所有操作都必須在自己的本地內存中進行,不能直接從主內存中讀取

所以線程B並不是直接去主內存中讀取共享變量的值,而是先在本地內存B中找到這個共享變量,發現這個共享變量已經被更新了,然後本地內存B去主內存中讀取這個共享變量的新值,並拷貝到本地內存B中,最後線程B再讀取本地內存B中的新值。

那麼怎麼知道這個共享變量的被其他線程更新了呢?這就是JMM的功勞了,也是JMM存在的必要性之一。JMM通過控制主內存與每個線程的本地內存之間的交互,來提供內存可見性保證

Java中的volatile關鍵字可以保證多線程操作共享變量的可見性以及禁止指令重排序,synchronized關鍵字不僅保證可見性,同時也保證了原子性(互斥性)。在更底層,JMM通過內存屏障來實現內存的可見性以及禁止重排序(下文提及)。爲了程序員的方便理解,提出了happens-before,它更加的簡單易懂,從而避免了程序員爲了理解內存可見性而去學習複雜的重排序規則以及這些規則的具體實現方法。

 

如何解決可見性問題呢?

用volatile修飾共享變量, 優化後的代碼如下:

/**
 *
 * @Description
 * @author saiuna
 */
public class saiuna_Volatile {
        //僅僅加了volatile
	volatile boolean running = true;
	void saiuna() {
		System.out.println("t1 start");
		while(running) {

		}
		System.out.println("t1 end!");
	}
	
	public static void main(String[] args) throws InterruptedException {
		saiuna_Volatile t = new saiuna_Volatile();

		new Thread(t::saiuna, "t1").start();
		//上一行代碼等價於 下面註釋的代碼
		/*new Thread(new Runnable() {
			@Override
			public void run() {
				t.m();
			}
		}, "t1").start();*/

		TimeUnit.SECONDS.sleep(1);

		t.running = false;
	}
}

輸出(輸出了 t1  end!):

t1 start
t1 end!

使用了volatile

每個線程操作數據時會把數據從主內存讀取到自己的工作內存,如果他操作了數據並且寫回了,其他線程都會感知到,並且將已讀取的變量副本失效,需要操作數據時都得去堆內存中讀取running的值。

volatile保證不同線程對共享變量操作的可見性,也就是說一個線程修改了volatile修飾的變量,當修改寫回主內存時,另外一個線程立即看到最新的值。

 

當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致,如何解決呢?

爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,
這類協議有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。我們看MESI協議

 

MESI緩存一致性協議

當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的(嗅探),那麼它就會從內存重新讀取。

嗅探:

 每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據讀到處理器緩存裏。

嗅探缺點:( 總線風暴)

由於Volatile的MESI緩存一致性協議,需要不斷的從主內存嗅探和cas不斷循環,無效交互會導致總線帶寬達到峯值。

所以不要大量使用Volatile,至於什麼時候去使用Volatile什麼時候使用鎖,根據場景區分。

 

指令重排序

 

什麼是重排序?

計算機在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排。

爲什麼指令重排可以提高性能?

簡單地說,每一個指令都會包含多個步驟,每個步驟可能使用不同的硬件。因此,流水線技術產生了,它的原理是指令1還沒有執行完,就可以開始執行指令2,而不用等到指令1執行結束之後再執行指令2,這樣就大大提高了效率。

但是,流水線技術最害怕中斷,恢復中斷的代價是比較大的,所以我們要想盡辦法不讓流水線中斷。指令重排就是減少中斷的一種技術。

我們分析一下下面這個代碼的執行情況:

a = b + c;
d = e - f ;

先加載b、c(注意,即有可能先加載b,也有可能先加載c),但是在執行add(b,c)的時候,需要等待b、c裝載結束才能繼續執行,也就是增加了停頓,那麼後面的指令也會依次有停頓,這降低了計算機的執行效率。

爲了減少這個停頓,我們可以先加載e和f,然後再去加載add(b,c),這樣做對程序(串行)是沒有影響的,但卻減少了停頓。既然add(b,c)需要停頓,那還不如去做一些有意義的事情。

綜上所述,指令重排對於提高CPU處理性能十分必要。雖然由此帶來了亂序的問題,但是這點犧牲是值得的。

 

重排序的類型有哪些呢?

指令重排一般分爲以下三種:

  • 編譯器優化重排

    編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。

  • 指令並行重排

    現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序。

  • 內存系統重排

    由於處理器使用緩存和讀寫緩存衝區,這使得加載(load)和存儲(store)操作看上去可能是在亂序執行,因爲三級緩存的存在,導致內存與緩存的數據同步存在時間差。

指令重排可以保證串行語義一致,但是沒有義務保證多線程間的語義也一致。所以在多線程下,指令重排序可能會導致一些問題。

這裏還得提一個概念,as-if-serial

不管怎麼重排序,單線程下的執行結果不能被改變。

編譯器、runtime和處理器都必須遵守as-if-serial語義。

 

那Volatile是怎麼保證不會被執行重排序的呢?

 

內存屏障

java編譯器會在生成指令系列時在適當的位置會插入內存屏障指令來禁止特定類型的處理器重排序。

 

需要注意的是:volatile寫是在前面和後面分別插入內存屏障,而volatile讀操作是在後面插入兩個內存屏障

 

上面的我提過重排序原則,爲了提高處理速度,JVM會對代碼進行編譯優化,也就是指令重排序優化,併發編程下指令重排序會帶來一些安全隱患:如指令重排序導致的多個線程操作之間的不可見性。

如果讓程序員再去了解這些底層的實現以及具體規則,那麼程序員的負擔就太重了,嚴重影響了併發編程的效率。

從JDK5開始,提出了happens-before的概念,通過這個概念來闡述操作之間的內存可見性。

happens-before

如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係。

volatile域規則:對一個volatile域的寫操作,happens-before於任意線程後續對這個volatile域的讀。

 

注意: Volatile是沒辦法保證原子性的,如何解決呢,開頭的原子類 AtomicInteger 就是一個例子。

 

簡單的應用(涉及單例模式)

/**
 *  線程安全的懶漢式單例---雙重檢查(Double-Check idiom)
 * @author saiuna
 */
public class Singleton6 {

    /**
     * 使用volatile關鍵字防止重排序,因爲 new Instance()是一個非原子操作,可能創建一個不完整的實例
     */
    private static volatile Singleton6 singleton6;

    private Singleton6() {
    }

    public static Singleton6 getInstance() {
        // Double-Check idiom 第一次非 null檢查,避免頻繁上鎖
        if (singleton6 == null) {
            synchronized (Singleton3.class) {
                // 只需在第一次創建實例時才同步
                if (singleton6 == null) {
                    singleton6 = new Singleton6();
                }
            }
        }
        return singleton6;
    }
}

單例的一種涉及到volatile的模式。不做詳解~後續寫設計模式補上。

 

 

volatile與synchronized的區別

volatile只能修飾實例變量和類變量,而synchronized可以修飾方法,以及代碼塊。

volatile保證數據的可見性,但是不保證原子性(多線程進行寫操作,不保證線程安全);而synchronized是一種排他(互斥)的機制。

volatile用於禁止指令重排序:可以解決單例雙重檢查對象初始化代碼執行亂序問題。

volatile可以看做是輕量版的synchronized,volatile不保證原子性,但是如果是對一個共享變量進行多個線程的賦值,而沒有其他的操作,那麼就可以用volatile來代替synchronized,因爲賦值本身是有原子性的,而volatile又保證了可見性,所以就可以保證線程安全了。

 

 

總結

  1. volatile修飾符適用於以下場景:某個屬性被多個線程共享,其中有一個線程修改了此屬性,其他線程可以立即得到修改後的值,比如booleanflag;或者作爲觸發器,實現輕量級同步。

  2. volatile屬性的讀寫操作都是無鎖的,它不能替代synchronized,因爲它沒有提供原子性和互斥性。因爲無鎖,不需要花費時間在獲取鎖和釋放鎖_上,所以說它是低成本的。

  3. volatile只能作用於屬性,我們用volatile修飾屬性,這樣compilers就不會對這個屬性做指令重排序。

  4. volatile提供了可見性,任何一個線程對其的修改將立馬對其他線程可見,volatile屬性不會被線程緩存,始終從主 存中讀取。

  5. volatile提供了happens-before保證,對volatile變量v的寫入happens-before所有其他線程後續對v的讀操作。

  6. volatile可以使得long 和 double的賦值是原子的。

  7. volatile可以在單例雙重檢查中實現可見性和禁止指令重排序,從而保證安全性。

 

參考:

http://concurrent.redspider.group/article/02/8.html 深入淺出多線程

https://www.cnblogs.com/yanlong300/p/8986041.html 緩存一致性協議

https://www.cnblogs.com/yoga21/p/9224557.html 單例模式

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