[高併發Java 三] Java內存模型和線程安全

網上不少資料在描述Java內存模型的時候,都會介紹有一個主存,而後每一個工做線程有本身的工做內存。數據在主存中會有一份,在工做內存中也有一份。工做內存和主存之間會有各類原子操做去進行同步。 java

下圖來源於這篇Blog git

可是因爲Java版本的不斷演變,內存模型也進行了改變。本文只講述Java內存模型的一些特性,不管是新的內存模型仍是舊的內存模型,在明白了這些特性之後,看起來也會更加清晰。 github

1. 原子性

  • 原子性是指一個操做是不可中斷的。即便是在多個線程一塊兒執行的時候,一個操做一旦開始,就不會被其它線程干擾。

通常認爲cpu的指令都是原子操做,可是咱們寫的代碼就不必定是原子操做了。 設計模式

好比說i++。這個操做不是原子操做,基本分爲3個操做,讀取i,進行+1,賦值給i。 緩存

假設有兩個線程,當第一個線程讀取i=1時,還沒進行+1操做,切換到第二個線程,此時第二個線程也讀取的是i=1。隨後兩個線程進行後續+1操做,再賦值回去之後,i不是3,而是2。顯然數據出現了不一致性。 安全

再好比在32位的JVM上面去讀取64位的long型數值,也不是一個原子操做。固然32位JVM讀取32位整數是一個原子操做。 多線程

2. 有序性

  • 在併發時,程序的執行可能就會出現亂序。

計算機在執行代碼時,不必定會按照程序的順序來執行。 併發

class OrderExample { 
		int a = 0; 
		boolean flag = false; 
		public void writer() 
		{ 
			a = 1; 
			flag = true; 
		} 
		public void reader() 
		{ 
			if (flag) 
			{ 
				int i = a +1;  
			}
		} 
	}
好比上述代碼,兩個方法分別被兩個線程調用。按照常理,寫線程應該先執行a=1,再執行flag=true。當讀線程進行讀的時候,i=2;

可是由於a=1和flag=true,並無邏輯上的關聯。因此有可能執行的順序顛倒,有可能先執行flag=true,再執行a=1。這時當flag=true時,切換到讀線程,此時a=1尚未執行,那麼讀線程將i=1。 app

固然這個不是絕對的。是有可能會發生亂序,有可能不發生。 jvm

那麼爲何會發生亂序呢?這個要從cpu指令提及,Java中的代碼被編譯之後,最後也是轉換成彙編碼的。

一條指令的執行是能夠分爲不少步驟的,假設cpu指令分爲如下幾步

  • 取指 IF
  • 譯碼和取寄存器操做數 ID
  • 執行或者有效地址計算 EX
  • 存儲器訪問 MEM
  • 寫回 WB
假設這裏有兩條指令

通常來講咱們會認爲指令是串行執行的,先執行指令1,而後再執行指令2。假設每一個步驟須要消耗1個cpu時間週期,那麼執行這兩個指令須要消耗10個cpu時間週期,這樣作效率過低。事實上指令都是並行執行的,固然在第一條指令在執行IF的時候,第二條指令是不能進行IF的,由於指令寄存器等不能被同時佔用。因此就如上圖所示,兩條指令是一種相對錯開的方式並行執行。當指令1執行ID的時候,指令2執行IF。這樣只用6個cpu時間週期就執行了兩個指令,效率比較高。

按照這個思路咱們來看下A=B+C的指令是如何執行的。

如圖所示,ADD操做時有一個空閒(X)操做,由於當想讓B和C相加的時候,在圖中ADD的X操做時,C還沒從內存中讀取(當MEM操做完成時,C才從內存中讀取。這裏會有一個疑問,此時尚未回寫(WB)到R2中,怎麼會將R1與R1相加。那是由於在硬件電路當中,會使用一種叫「旁路」的技術直接把數據從硬件當中讀取出來,因此不須要等待WB執行完才進行ADD)。因此ADD操做中會有一個空閒(X)時間。在SW操做中,由於EX指令不能和ADD的EX指令同時進行,因此也會有一個空閒(X)時間。

接下來舉個稍微複雜點的例子

a=b+c 
d=e-f

對應的指令以下圖

緣由和上面的相似,這裏就不分析了。咱們發現,這裏的X不少,浪費的時間週期不少,性能也被影響。有沒有辦法使X的數量減小呢?

咱們但願用一些操做把X的空閒時間填充掉,由於ADD與上面的指令有數據依賴,咱們但願用一些沒有數據依賴的指令去填充掉這些由於數據依賴而產生的空閒時間。

咱們將指令的順序進行了改變

改變了指令順序之後,X被消除了。整體的運行時間週期也減小了。

指令重排可使流水線更加順暢

固然指令重排的原則是不能破壞串行程序的語義,例如a=1,b=a+1,這種指令就不會重排了,由於重排的串行結果和原先的不一樣。

指令重排只是編譯器或者CPU的優化一種方式,而這種優化就形成了本章一開始程序的問題。

如何解決呢?用volatile關鍵字,這個後面的系列會介紹到。

3. 可見性

  • 可見性是指當一個線程修改了某一個共享變量的值,其餘線程是否可以當即知道這個修改。

可見性問題可能有各個環節產生。好比剛剛說的指令重排也會產生可見性問題,另外在編譯器的優化或者某些硬件的優化都會產生可見性問題。

好比某個線程將一個共享值優化到了內存中,而另外一個線程將這個共享值優化到了緩存中,當修改內存中值的時候,緩存中的值是不知道這個修改的。

好比有些硬件優化,程序在對同一個地址進行屢次寫時,它會認爲是沒有必要的,只保留最後一次寫,那麼以前寫的數據在其餘線程中就不可見了。

總之,可見性的問題大多都源於優化。

接下來看一個Java虛擬機層面產生的可見性問題

問題來自於一個Blog

package edu.hushi.jvm;
 
/**
 *
 * @author -10
 *
 */
public class VisibilityTest extends Thread {
 
    private boolean stop;
 
    public void run() {
        int i = 0;
        while(!stop) {
            i++;
        }
        System.out.println("finish loop,i=" + i);
    }
 
    public void stopIt() {
        stop = true;
    }
 
    public boolean getStop(){
        return stop;
    }
    public static void main(String[] args) throws Exception {
        VisibilityTest v = new VisibilityTest();
        v.start();
 
        Thread.sleep(1000);
        v.stopIt();
        Thread.sleep(2000);
        System.out.println("finish main");
        System.out.println(v.getStop());
    }
 
}
代碼很簡單,v線程一直不斷的在while循環中i++,直到主線程調用stop方法,改變了v線程中的stop變量的值使循環中止。

看似簡單的代碼運行時就會出現問題。這個程序在 client 模式下是能中止線程作自增操做的,可是在 server 模式先將是無限循環。(server模式下JVM優化更多)

64位的系統上面大多都是server模式,在server模式下運行:

finish main
true
只會打印出這兩句話,而不會打印出finish loop。但是可以發現stop的值已是true了。

該Blog做者用工具將程序還原爲彙編代碼

這裏只截取了一部分彙編代碼,紅色部分爲循環部分,能夠清楚得看到只有在0x0193bf9d才進行了stop的驗證,而紅色部分並無取stop的值,因此才進行了無限循環。

這是JVM優化後的結果。如何避免呢?和指令重排同樣,用volatile關鍵字。

若是加入了volatile,再還原爲彙編代碼就會發現,每次循環都會get一下stop的值。

接下來看一些在「Java語言規範」中的示例


上圖說明了指令重排將會致使結果不一樣。


上圖使r5=r2的緣由是,r2=r1.x,r5=r1.x,在編譯時直接將其優化成r5=r2。最後致使結果不一樣。

4. Happen-Before

  • 程序順序原則:一個線程內保證語義的串行性
  • volatile規則:volatile變量的寫,先發生於讀,這保證了volatile變量的可見性
  • 鎖規則:解鎖(unlock)必然發生在隨後的加鎖(lock)前
  • 傳遞性:A先於B,B先於C,那麼A必然先於C
  • 線程的start()方法先於它的每個動做
  • 線程的全部操做先於線程的終結(Thread.join())
  • 線程的中斷(interrupt())先於被中斷線程的代碼
  • 對象的構造函數執行結束先於finalize()方法
這些原則保證了重排的語義是一致的。

5. 線程安全的概念

指某個函數、函數庫在多線程環境中被調用時,可以正確地處理各個線程的局部變量,使程序功能正確完成。

好比最開始所說的i++的例子

就會致使線程不安全。

關於線程安全的詳情使用,請參考之前寫的這篇Blog,或者關注後續系列,也會談到相關內容。






系列:

[高併發Java 一] 前言

[高併發Java 二] 多線程基礎

[高併發Java 三] Java內存模型和線程安全

[高併發Java 四] 無鎖

[高併發Java 五] JDK併發包1

[高併發Java 六] JDK併發包2

[高併發Java 七] 併發設計模式

[高併發Java 八] NIO和AIO

[高併發Java 九] 鎖的優化和注意事項

[高併發Java 十] JDK8對併發的新支持

相關文章
相關標籤/搜索