1. 程式人生 > >從三個特性理解多執行緒開發

從三個特性理解多執行緒開發

工作中許多地方需要涉及到多執行緒的設計與開發,java多執行緒開發當中我們為了執行緒安全所做的任何操作其實都是圍繞多執行緒的三個特性:原子性、可見性、有序性展開的。針對這三個特性的資料網上已經很多了,在這裡我希望在站在便於理解的角度,用相對直觀的方式闡述這三大特性,以及為什麼要實現和滿足三大特性。

一、原子性

原子性是指一個操作或者一系列操作要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。其實這句話就是在告訴你,如果有多個執行緒執行相同一段程式碼時,而你又能夠預見到這多個執行緒相互之間會影響對方的執行結果,那麼這段程式碼是不滿足原子性的。結合到實際開發當中,如果程式碼中出現這種情況,大概率是你操作了共享變數。

針對這個情況網上有個很經典的例子,銀行轉賬問題:

比如A和B同時向C轉賬10萬元。如果轉賬操作不具有原子性,A在向C轉賬時,讀取了C的餘額為20萬,然後加上轉賬的10萬,計算出此時應該有30萬,但還未來及將30萬寫回C的賬戶,此時B的轉賬請求過來了,B發現C的餘額為20萬,然後將其加10萬並寫回。然後A的轉賬操作繼續——將30萬寫回C的餘額。這種情況下C的最終餘額為30萬,而非預期的40萬。 如果A和B兩個轉賬操作是在不同的執行緒中執行,而C的賬戶就是你要操作的共享變數,那麼不保證執行操作原子性的後果是十分嚴重的。

OK,上面的狀況我們理清楚了,由此可以引申出下列三個問題

1、哪些是共享變數

從JVM記憶體模型的角度上講,儲存在堆記憶體上資料都是執行緒共享的,如例項化的物件、全域性變數、陣列等。儲存線上程棧上的資料是執行緒獨享的,如區域性變數、操作棧、動態連結、方法出口等資訊。

舉個通俗的例子,如果你的執行方法相當於做菜,你可以認為每個執行緒都是一名廚師,方法執行時會在虛擬機器棧中建立棧幀,相當於給每個廚師分配一個單獨的廚房,做菜也就是執行方法的過程中需要很多資源,裡面的鍋碗瓢盆各種工具,就諸如你在方法內的區域性變數是每個廚師獨享的;但如果需要使用水電煤氣等公共資源,就諸如全域性變數一般是共享的,使用時需要保證執行緒安全。

2、哪些是原子操作

既然是要保證操作的原子性,如何判斷我的操作是否符合原子性呢,一段程式碼肯定是不符合原子性的,因為它包含很多步操作。但如果只是一行程式碼呢,比如上面的銀行轉賬的例子如果沒有這麼複雜,共享變數“C的賬戶

”只是一個簡單的count++操作呢?針對這個問題,首先我們要明確,看起來十分簡單的一句程式碼,在JMM(java執行緒記憶體模型)中可能是需要多步操作的。

先來看一個經典的例子:使用程式實現一個計數器,期望得到的結果是1000,程式碼如下:

複製程式碼
public class threadCount {
     public volatile static int count = 0; 
     public static void main( String[] args ) throws InterruptedException {
          ExecutorService threadpool = Executors.newFixedThreadPool(1000);
            for (int i = 0; i < 1000; i++) {
                threadpool.execute(new Runnable() {
                    @Override
                    public void run() {
                        count++;
                    }
                });
            }
            threadpool.shutdown();
            //保證提交的任務全部執行完畢
            threadpool.awaitTermination(10000, TimeUnit.SECONDS);
            System.out.println(count);
     }
}
複製程式碼

執行程式你可以看到,輸出的結果並不每次都是期望的1000,這正是因為count++不是原子操作,執行緒不安全導致的錯誤結果。

實際上count++包含2個操作,首先它先要去讀取count的值,再將count的值寫入工作記憶體,雖然讀取count的值以及將count的值寫入工作記憶體 2個操作都是原子性操作,但合起來就不是原子性操作了。

在JMM中定義了8中原子操作,如下圖所示,原子性變數操作包括read、load、assign、use、store、write,其實你可以理解為只有JMM定義的一些最基本的操作是符合原子性的,如果需要對程式碼塊實行原子性操作,則需要JMM提供的lock、unlock、synchronized等來保證。

 

3、如何保證操作的原子性

使用較多的三種方式:

內建鎖(同步關鍵字):synchronized;

顯示鎖:Lock;

自旋鎖:CAS;

當然這三種實現方式和保證同步的機制上都有所不同,在這裡我們不做深入的說明。

二、可見性

可見性是一種複雜的屬性,因為可見性的錯誤通常比較隱蔽並且違反我們的直覺。

我們看下面這段程式碼

複製程式碼
public class VolatileApp {
    //volatile
    private static boolean isOver = false;

    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isOver) {
                    //Thread.yield();
                }
                System.out.println(number);
            }
        });
        thread.start();
        Thread.sleep(1000);
        number = 50;
        isOver = true;
    }

}
複製程式碼

如果你直接執行上面的程式碼,那麼你永遠也看不到number的輸出的,執行緒將會無限的迴圈下去。你可能會有疑問程式碼當中明明已經把isOver設定為了false,為什麼迴圈還不會停止呢?這正是因為多執行緒之間可見性的問題。在單執行緒環境中,如果向某個變數寫入某個值,在沒有其他寫入操作的影響下,那麼你總能取到你寫入的那個值。然而在多執行緒環境中,當你的讀操作和寫操作在不同的執行緒中執行時,情況就並非你想象的理所當然,也就是說不滿足多執行緒之間的可見性,所以為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步機制。

我們來看下JMM(java執行緒記憶體模型):

JMM規定多執行緒之間的共享變數儲存在主存中,每個執行緒單獨擁有一個本地記憶體( 邏輯概念),本地記憶體儲存執行緒操作的共享變數副本;
  • JMM中的變數指的是執行緒共享變數(例項變數,static欄位和陣列元素),不包括執行緒私有變數(區域性變數和方法引數);
  • JMM規定執行緒對變數的寫操作都在自己的本地記憶體對副本進行,不能直接寫主存中的對應變數;
  • 多執行緒間變數傳遞通過主存完成(Java執行緒通訊通過共享記憶體),執行緒修改變數後通過本地記憶體寫回主存,從主存讀取變數,彼此不允許直接通訊(本地記憶體私有原因);
綜上,JMM通過控制主存和每個執行緒的本地記憶體的資料互動,保證一致的記憶體可見性;也就是說執行緒之間“變數的共享”都需要通過重新整理主記憶體,其他執行緒讀取來完成,而一旦無法保證這個動作完成,多個執行緒之間是無法及時獲取共享變數的變化的。那麼我們怎麼知道什麼時候工作記憶體的變數會刷寫到主記憶體當中呢?這其實要基於java的happens-before原則(先行發生原則),這也也與多執行緒的有序性相關,我們放到後面闡述。

volatile

保證執行緒之間可見性的手段有多種,在上面的程式碼中,我們就可以通過volatile修飾靜態變數來保證執行緒的可見性。

你可以把volatile變數看作一種削弱的同步機制,它可以確保將變數的更新操作通知到其他執行緒;使用volatile保證可見性相比一般的同步機制更加輕量級,開銷也相對更低。

其實這裡還有另外一種情況,如果上面的程式碼中你撤銷對Thread.yield()的註釋,你會發現即便沒有volatile的修飾兩個靜態變數 ,number也會正常列印輸出了,乍一看你會以為可見性是沒有問題的,其實不然,這是因為Thread.yield()的加入,使JVM幫助你完成了執行緒的可見性。

下面這段段話闡述的比較明確:

程式執行中,JVM會盡力保證記憶體的可見性,即便這個變數沒有加同步關鍵字。換句話說,只要CPU有時間,JVM會盡力去保證變數值的更新。這種與volatile關鍵字的不同在於,volatile關鍵字會強制的保證執行緒的可見性。而不加這個關鍵字,JVM也會盡力去保證可見性,但是如果CPU一直有其他的事情在處理,它也沒辦法。也就是說Thread.yield()的加入,執行緒讓出了一部分執行時間,使CPU從一直被while迴圈佔用中佔分配出了一些時間給JVM,這才能夠保證執行緒的可見性。 所以說如果你不用volatile變數強制保證執行緒的可見性,雖然執行結果可能符合預期,也並不代表程式是執行緒安全的,你的程式會在有“隱患”的狀態下執行,出現問題也不好排查與處理。

三、有序性

理解多執行緒的有序性其實是比較困難的,因為你很難直觀的去觀察到它。

有序性的本義是指程式在執行的時候,程式的程式碼執行順序和語句的順序是一致的。但是在Java記憶體模型中,是允許編譯器和處理器對指令進行重排序的,但是重排序過程不會影響到單執行緒程式的執行,卻會影響到多執行緒併發執行的正確性。也就是說在多執行緒中程式碼的執行順序,不一定會與你直觀上看到的程式碼編寫的邏輯順序一致。

下面我們舉個簡單的例子:

執行緒A:

context = loadContext(); //語句1
inited = true; //語句2

執行緒B:

while(!inited ){
 sleep
}
doSomethingwithconfig(context);

執行緒A中的程式碼中語句1與語句2之間沒有必然的聯絡,所以執行緒A是會發生重排序問題的,也就是說語句2會在語句1之前執行,這必然會影響到執行緒B的執行(context沒有例項化)。

其實指令的重排序之所以抽象難懂,因為它是一種較為底層的行為,是基於編譯器對你程式碼進行深層優化的一種結果,結合上面的例子如果loadContext()中存在阻塞的話,優先執行語句2可以說是一種合理的行為。

四、happen-before規則

上面我們也提到了,多執行緒的可見性與有序性之間其實是有聯絡的,如果程式沒有按你希望的順序執行,那麼可見性也就無從談起。JMM(Java 執行緒記憶體模型) 中的 happen-before規則,該規則定義了 Java 多執行緒操作的有序性和可見性,防止了編譯器重排序對程式結果的影響。

按照官方的說法:

當一個變數被多個執行緒讀取並且至少被一個執行緒寫入時,如果讀操作和寫操作沒有happen-before關係,則會產生資料競爭問題。 要想保證操作 B 的執行緒看到操作 A 的結果(無論 A 和 B 是否在一個執行緒),那麼在 A 和 B 之間必須滿足 HB 原則,如果沒有,將有可能導致重排序。 當缺少 happen-before關係時,就可能出現重排序問題。

簡單來說可以理解為在JMM中,如果一個的執行緒執行的結果需要對另一個對另一個執行緒B可見,那麼這兩個執行緒A操作與執行緒B操作之間必須存在happens-before關係。happens-before規則如下: 複製程式碼
1.程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作;
2.鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作;
3.volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作;
4.傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;
5.執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每個一個動作;
6.執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生;
7.執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到執行緒已經終止執行;
8.物件終結規則:一個物件的初始化完成先行發生於他的finalize()方法的開始;
複製程式碼

從上面的規則中我們可以看到,使用synchronized、volatile,加鎖lock等方式一般及可以保證執行緒的可見性與有序性。

通過以上對多執行緒三大特性的總結,可以看出多執行緒開發中執行緒安全問題主要是基於原子性、可見性、有序性實現的,在這裡我根據自己的理解進行了一下簡單整理和闡述,自我感覺還是比較淺顯的,如有不足之處還望指出與海涵。

  分類:  java

工作中許多地方需要涉及到多執行緒的設計與開發,java多執行緒開發當中我們為了執行緒安全所做的任何操作其實都是圍繞多執行緒的三個特性:原子性、可見性、有序性展開的。針對這三個特性的資料網上已經很多了,在這裡我希望在站在便於理解的角度,用相對直觀的方式闡述這三大特性,以及為什麼要實現和滿足三大特性。

一、原子性

原子性是指一個操作或者一系列操作要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。其實這句話就是在告訴你,如果有多個執行緒執行相同一段程式碼時,而你又能夠預見到這多個執行緒相互之間會影響對方的執行結果,那麼這段程式碼是不滿足原子性的。結合到實際開發當中,如果程式碼中出現這種情況,大概率是你操作了共享變數。

針對這個情況網上有個很經典的例子,銀行轉賬問題:

比如A和B同時向C轉賬10萬元。如果轉賬操作不具有原子性,A在向C轉賬時,讀取了C的餘額為20萬,然後加上轉賬的10萬,計算出此時應該有30萬,但還未來及將30萬寫回C的賬戶,此時B的轉賬請求過來了,B發現C的餘額為20萬,然後將其加10萬並寫回。然後A的轉賬操作繼續——將30萬寫回C的餘額。這種情況下C的最終餘額為30萬,而非預期的40萬。 如果A和B兩個轉賬操作是在不同的執行緒中執行,而C的賬戶就是你要操作的共享變數,那麼不保證執行操作原子性的後果是十分嚴重的。

OK,上面的狀況我們理清楚了,由此可以引申出下列三個問題

1、哪些是共享變數

從JVM記憶體模型的角度上講,儲存在堆記憶體上資料都是執行緒共享的,如例項化的物件、全域性變數、陣列等。儲存線上程棧上的資料是執行緒獨享的,如區域性變數、操作棧、動態連結、方法出口等資訊。

舉個通俗的例子,如果你的執行方法相當於做菜,你可以認為每個執行緒都是一名廚師,方法執行時會在虛擬機器棧中建立棧幀,相當於給每個廚師分配一個單獨的廚房,做菜也就是執行方法的過程中需要很多資源,裡面的鍋碗瓢盆各種工具,就諸如你在方法內的區域性變數是每個廚師獨享的;但如果需要使用水電煤氣等公共資源,就諸如全域性變數一般是共享的,使用時需要保證執行緒安全。

2、哪些是原子操作

既然是要保證操作的原子性,如何判斷我的操作是否符合原子性呢,一段程式碼肯定是不符合原子性的,因為它包含很多步操作。但如果只是一行程式碼呢,比如上面的銀行轉賬的例子如果沒有這麼複雜,共享變數“C的賬戶”只是一個簡單的count++操作呢?針對這個問題,首先我們要明確,看起來十分簡單的一句程式碼,在JMM(java執行緒記憶體模型)中可能是需要多步操作的。

先來看一個經典的例子:使用程式實現一個計數器,期望得到的結果是1000,程式碼如下:

複製程式碼
public class threadCount {
     public volatile static int count = 0; 
     public static void main( String[] args ) throws InterruptedException {
          ExecutorService threadpool = Executors.newFixedThreadPool(1000);
            for (int i = 0; i < 1000; i++) {
                threadpool.execute(new Runnable() {
                    @Override
                    public void run() {
                        count++;
                    }
                });
            }
            threadpool.shutdown();
            //保證提交的任務全部執行完畢
            threadpool.awaitTermination(10000, TimeUnit.SECONDS);
            System.out.println(count);
     }
}
複製程式碼

執行程式你可以看到,輸出的結果並不每次都是期望的1000,這正是因為count++不是原子操作,執行緒不安全導致的錯誤結果。

實際上count++包含2個操作,首先它先要去讀取count的值,再將count的值寫入工作記憶體,雖然讀取count的值以及將count的值寫入工作記憶體 2個操作都是原子性操作,但合起來就不是原子性操作了。

在JMM中定義了8中原子操作,如下圖所示,原子性變數操作包括read、load、assign、use、store、write,其實你可以理解為只有JMM定義的一些最基本的操作是符合原子性的,如果需要對程式碼塊實行原子性操作,則需要JMM提供的lock、unlock、synchronized等來保證。

 

3、如何保證操作的原子性

使用較多的三種方式:

內建鎖(同步關鍵字):synchronized;

顯示鎖:Lock;

自旋鎖:CAS;

當然這三種實現方式和保證同步的機制上都有所不同,在這裡我們不做深入的說明。

二、可見性

可見性是一種複雜的屬性,因為可見性的錯誤通常比較隱蔽並且違反我們的直覺。

我們看下面這段程式碼

複製程式碼
public class VolatileApp {
    //volatile
    private static boolean isOver = false;

    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isOver) {
                    //Thread.yield();
                }
                System.out.println(number);
            }
        });
        thread.start();
        Thread.sleep(1000);
        number = 50;
        isOver = true;
    }

}
複製程式碼

如果你直接執行上面的程式碼,那麼你永遠也看不到number的輸出的,執行緒將會無限的迴圈下去。你可能會有疑問程式碼當中明明已經把isOver設定為了false,為什麼迴圈還不會停止呢?這正是因為多執行緒之間可見性的問題。在單執行緒環境中,如果向某個變數寫入某個值,在沒有其他寫入操作的影響下,那麼你總能取到你寫入的那個值。然而在多執行緒環境中,當你的讀操作和寫操作在不同的執行緒中執行時,情況就並非你想象的理所當然,也就是說不滿足多執行緒之間的可見性,所以為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步機制。

我們來看下JMM(java執行緒記憶體模型):

JMM規定多執行緒之間的共享變數儲存在主存中,每個執行緒單獨擁有一個本地記憶體( 邏輯概念),本地記憶體儲存執行緒操作的共享變數副本;
  • JMM中的變數指的是執行緒共享變數(例項變數,static欄位和陣列元素),不包括執行緒私有變數(區域性變數和方法引數);
  • JMM規定執行緒對變數的寫操作都在自己的本地記憶體對副本進行,不能直接寫主存中的對應變數;
  • 多執行緒間變數傳遞通過主存完成(Java執行緒通訊通過共享記憶體),執行緒修改變數後通過本地記憶體寫回主存,從主存讀取變數,彼此不允許直接通訊(本地記憶體私有原因);
綜上,JMM通過控制主存和每個執行緒的本地記憶體的資料互動,保證一致的記憶體可見性;也就是說執行緒之間“變數的共享”都需要通過重新整理主記憶體,其他執行緒讀取來完成,而一旦無法保證這個動作完成,多個執行緒之間是無法及時獲取共享變數的變化的。那麼我們怎麼知道什麼時候工作記憶體的變數會刷寫到主記憶體當中呢?這其實要基於java的happens-before原則(先行發生原則),這也也與多執行緒的有序性相關,我們放到後面闡述。

volatile

保證執行緒之間可見性的手段有多種,在上面的程式碼中,我們就可以通過volatile修飾靜態變數來保證執行緒的可見性。

你可以把volatile變數看作一種削弱的同步機制,它可以確保將變數的更新操作通知到其他執行緒;使用volatile保證可見性相比一般的同步機制更加輕量級,開銷也相對更低。

其實這裡還有另外一種情況,如果上面的程式碼中你撤銷對Thread.yield()的註釋,你會發現即便沒有volatile的修飾兩個靜態變數 ,number也會正常列印輸出了,乍一看你會以為可見性是沒有問題的,其實不然,這是因為Thread.yield()的加入,使JVM幫助你完成了執行緒的可見性。

下面這段段話闡述的比較明確:

程式執行中,JVM會盡力保證記憶體的可見性,即便這個變數沒有加同步關鍵字。換句話說,只要CPU有時間,JVM會盡力去保證變數值的更新。這種與volatile關鍵字的不同在於,volatile關鍵字會強制的保證執行緒的可見性。而不加這個關鍵字,JVM也會盡力去保證可見性,但是如果CPU一直有其他的事情在處理,它也沒辦法。也就是說Thread.yield()的加入,執行緒讓出了一部分執行時間,使CPU從一直被while迴圈佔用中佔分配出了一些時間給JVM,這才能夠保證執行緒的可見性。 所以說如果你不用volatile變數強制保證執行緒的可見性,雖然執行結果可能符合預期,也並不代表程式是執行緒安全的,你的程式會在有“隱患”的狀態下執行,出現問題也不好排查與處理。

三、有序性

理解多執行緒的有序性其實是比較困難的,因為你很難直觀的去觀察到它。

有序性的本義是指程式在執行的時候,程式的程式碼執行順序和語句的順序是一致的。但是在Java記憶體模型中,是允許編譯器和處理器對指令進行重排序的,但是重排序過程不會影響到單執行緒程式的執行,卻會影響到多執行緒併發執行的正確性。也就是說在多執行緒中程式碼的執行順序,不一定會與你直觀上看到的程式碼編寫的邏輯順序一致。

下面我們舉個簡單的例子:

執行緒A:

context = loadContext(); //語句1
inited = true; //語句2

執行緒B:

while(!inited ){
 sleep
}
doSomethingwithconfig(context);

執行緒A中的程式碼中語句1與語句2之間沒有必然的聯絡,所以執行緒A是會發生重排序問題的,也就是說語句2會在語句1之前執行,這必然會影響到執行緒B的執行(context沒有例項化)。

其實指令的重排序之所以抽象難懂,因為它是一種較為底層的行為,是基於編譯器對你程式碼進行深層優化的一種結果,結合上面的例子如果loadContext()中存在阻塞的話,優先執行語句2可以說是一種合理的行為。

四、happen-before規則

上面我們也提到了,多執行緒的可見性與有序性之間其實是有聯絡的,如果程式沒有按你希望的順序執行,那麼可見性也就無從談起。JMM(Java 執行緒記憶體模型) 中的 happen-before規則,該規則定義了 Java 多執行緒操作的有序性和可見性,防止了編譯器重排序對程式結果的影響。

按照官方的說法:

當一個變數被多個執行緒讀取並且至少被一個執行緒寫入時,如果讀操作和寫操作沒有happen-before關係,則會產生資料競爭問題。 要想保證操作 B 的執行緒看到操作 A 的結果(無論 A 和 B 是否在一個執行緒),那麼在 A 和 B 之間必須滿足 HB 原則,如果沒有,將有可能導致重排序。 當缺少 happen-before關係時,就可能出現重排序問題。

簡單來說可以理解為在JMM中,如果一個的執行緒執行的結果需要對另一個對另一個執行緒B可見,那麼這兩個執行緒A操作與執行緒B操作之間必須存在happens-before關係。happens-before規則如下: 複製程式碼
1.程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作;
2.鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作;
3.volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作;
4.傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;
5.執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每個一個動作;
6.執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生;
7.執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到執行緒已經終止執行;
8.物件終結規則:一個物件的初始化完成先行發生於他的finalize()方法的開始;
複製程式碼

從上面的規則中我們可以看到,使用synchronized、volatile,加鎖lock等方式一般及可以保證執行緒的可見性與有序性。

通過以上對多執行緒三大特性的總結,可以看出多執行緒開發中執行緒安全問題主要是基於原子性、可見性、有序性實現的,在這裡我根據自己的理解進行了一下簡單整理和闡述,自我感覺還是比較淺顯的,如有不足之處還望指出與海涵。