多執行緒系列之 執行緒安全

舊城已空舊夢已逝發表於2019-01-09

 

序言:提到執行緒安全,可能大家首先想到的是確保介面對共享變數的操作要具備 原子性。實際上,在多執行緒程式設計中我們需要同時關注可見性,順序性和原子性。本篇文章將從這三個問題出發,結合例項詳解volatile如何保u證可見性及一定程式上保證順序性,同時例講synchronized如何同時保證可見性和原子性,最後對比volatile和synchronized的適用場景。

本文部分摘自技術世界

文章結構:

  • 多執行緒程式設計的三個核心概念

  • Java如何解決多執行緒併發問題

  • volatile 關鍵字的適用場景

  • 本人面試中被問到的多執行緒安全問題

  • 下期預告

 

1.多執行緒程式設計的三個核心概念

  • 原子性

    這一點,類比資料庫事務的原子性;即一個操作,也有可能是一組操作,要麼全部生效,要麼全部失效

        關於原子性,一個非常經典的案例就是銀行卡之間轉賬的問題:比如A和B同時向C轉賬10萬元。如果轉賬操作不具有性,A在向C轉賬時,讀取了C的餘額為20萬,然後加上轉賬的10萬,計算出此時應該有30萬,但還未來及將30萬寫回C的賬戶,此時B的轉賬請求過來了,B發現C的餘額為20萬,然後將其加10萬並寫回。然後A的轉賬操作繼續——將30萬寫回C的餘額。這種情況下C的最終餘額為30萬,而非預期的40萬。

  • 可見性

    當多個執行緒併發訪問共享變數時,一個執行緒對共享變數的修改,其它執行緒能夠立即看到。可見性問題是好多人忽略或者理解錯誤的一點。

        CPU從主記憶體中讀資料的效率相對來說不高,現在主流的計算機中,都有幾級快取。每個執行緒讀取共享變數時,都會將該變數載入進其對應CPU的快取記憶體裡,修改該變數後,CPU會立即更新該快取,但並不一定會立即將其寫回主記憶體(實際上寫回主記憶體的時間不可預期)。此時其它執行緒(尤其是不在同一個CPU上執行的執行緒)訪問該變數時,從主記憶體中讀到的就是舊的資料,而非第一個執行緒更新後的資料。

這一點是作業系統或者說是硬體層面的機制

  • 順序性

    指的是,程式執行的順序按照程式碼的先後順序執行。

    如下面這段程式碼

    1  boolean started = false; 
    2   long counter = 0L; 
    3   counter = 1; 
    4   started = true; 

     

            從程式碼順序上看,上面這4條語句應該一次執行,但實際上JAVA 虛擬機器真正執行這段程式碼時,並不保證他們一定按順序執行。

    處理器為了提高程式整體的執行效率,可能會對程式碼進行優化,其中一項優化方式就是調整程式碼順序,按照更高效的順序執行程式碼。

            講到這裡,我曾經問過自己,CPU不按照我的程式碼順序執行,那怎麼保證得到我們想要的結果?實際上,CPU 雖然不保證完全按照程式碼順序執行,但他會保證程式的最終執行結果和程式碼順序執行結果一致。

 

2.Java如何解決多執行緒併發問題

 

2.1Java如何保證原子性

常用的保證Java操作原子性的工具是鎖和同步方法(或者同步程式碼塊)。使用鎖,可以保證同一時間只有一個執行緒能拿到鎖,也就保證了同一時間只有一個執行緒能執行申請鎖和釋放鎖之間的程式碼。

1 public void testLock () {
2     lock.lock();
3     try{
4       int j = i;
5       i = j + 1;
6     } finally {
7       lock.unlock();
8     }
9   }

 

與鎖類似的是同步方法或者同步程式碼塊。使用非靜態同步方法時,鎖住的是當前例項;使用靜態同步方法時,鎖住的是該類的Class物件;使用靜態程式碼塊時,鎖住的是synchronized關鍵字後面括號內的物件。下面是同步程式碼塊示例

1 public void testLock () {
2     synchronized (anyObject){
3       int j = i;
4       i = j + 1;
5     }
6   }

 

小節:無論使用鎖還是synchronized,本質都是一樣,通過鎖來實現資源的排它性,從而實際目的碼段同一時間只會被一個執行緒執行,進而保證了目的碼段的原子性。這是一種以犧牲效能為代價的方法。

 

2.2CAS(compare and swap)

基礎型別變數自增(i++)是一種常被新手誤以為是原子操作而實際不是的操作。Java中提供了對應的原子操作類來實現該操作,並保證原子性,其本質是利用了CPU級別的CAS指令。由於是CPU級別的指令,其開銷比需要作業系統參與的鎖的開銷小。AtomicInteger使用方法如下。

1 AtomicInteger atomicInteger = new AtomicInteger();
2   for(int b = 0; b < numThreads; b++) {
3     new Thread(() -> {
4       for(int a = 0; a < iteration; a++) {
5         atomicInteger.incrementAndGet();
6       }
7     }).start();
8   }

 

2.3 Java如何保證可見性

Java提供了volatile關鍵字來保證可見性。當使用volatile修飾某個變數時,它會保證對該變數的修改會立即被更新到記憶體中,並且將其它快取中對該變數的快取設定成無效,因此其它執行緒需要讀取該值時必須從主記憶體中讀取,從而得到最新的值。

2.4Java如何保證順序性

上文講過編譯器和處理器對指令進行重新排序時,會保證重新排序後的執行結果和程式碼順序執行的結果一致,所以重新排序過程並不會影響單執行緒程式的執行,卻可能影響多執行緒程式併發執行的正確性。

Java中可通過volatile在一定程式上保證順序性,另外還可以通過synchronized和鎖來保證順序性。

synchronized和鎖保證順序性的原理和保證原子性一樣,都是通過保證同一時間只會有一個執行緒執行目的碼段來實現的。

除了從應用層面保證目的碼段執行的順序性外,JVM還通過被稱為happens-before原則隱式地保證順序性。兩個操作的執行順序只要可以通過happens-before推匯出來,則JVM會保證其順序性,反之JVM對其順序性不作任何保證,可對其進行任意必要的重新排序以獲取高效率。

2.5 happens-before原則(先行發生原則)

  • 傳遞規則:如果操作1在操作2前面,而操作2在操作3前面,則操作1肯定會在操作3前發生。該規則說明了happens-before原則具有傳遞性

  • 鎖定規則:一個unlock操作肯定會在後面對同一個鎖的lock操作前發生。這個很好理解,鎖只有被釋放了才會被再次獲取

  • volatile變數規則:對一個被volatile修飾的寫操作先發生於後面對該變數的讀操作

  • 程式次序規則:一個執行緒內,按照程式碼順序執行

  • 執行緒啟動規則:Thread物件的start()方法先發生於此執行緒的其它動作

  • 執行緒終結原則:執行緒的終止檢測後發生於執行緒中其它的所有操作

  • 執行緒中斷規則: 對執行緒interrupt()方法的呼叫先發生於對該中斷異常的獲取

  • 物件終結規則:一個物件構造先於它的finalize發生

 

3.volatile適用場景

volatile適用於不需要保證原子性,但卻需要保證可見性的場景。一種典型的使用場景是用它修飾用於停止執行緒的狀態標記。如下所示

 1  boolean isRunning = false;
 2  3   public void start () {
 4     new Thread( () -> {
 5       while(isRunning) {
 6         someOperation();
 7       }
 8     }).start();
 9   }
10 11   public void stop () {
12     isRunning = false;
13   }

 

 

        在這種實現方式下,即使其它執行緒通過呼叫stop()方法將isRunning設定為false,迴圈也不一定會立即結束。可以通過volatile關鍵字,保證while迴圈及時得到isRunning最新的狀態從而及時停止迴圈,結束執行緒。

 

4.本人面試中被問到的多執行緒安全問題總結

下面的問題是我在重慶和成都面試的時候被問到的問題,當時不懂的已經下來查資料搞定了,僅供參考

 

Q:平時專案中使用鎖和synchronized比較多,而很少使用volatile,難道就沒有保證可見性?

A:鎖和synchronized即可以保證原子性,也可以保證可見性。都是通過保證同一時間只有一個執行緒執行目的碼段來實現的。

 

Q:鎖和synchronized為何能保證可見性?

A:根據JDK中對concurrent包的說明,一個執行緒的寫結果保證對另外執行緒的讀操作可見,只要該寫操作可以由happen-before原則推斷出在讀操作之前發生。

 

 

Q:既然鎖和synchronized即可保證原子性也可保證可見性,為何還需要volatile?

A:synchronized和鎖需要通過作業系統來仲裁誰獲得鎖,開銷比較高,而volatile開銷小很多。因此在只需要保證可見性的條件下,使用volatile的效能要比使用鎖和synchronized高得多。

 

 

Q:既然鎖和synchronized可以保證原子性,為什麼還需要AtomicInteger這種的類來保證原子操作?

A:鎖和synchronized需要通過作業系統來仲裁誰獲得鎖,開銷比較高,而AtomicInteger是通過CPU級的CAS操作來保證原子性,開銷比較小。所以使用AtomicInteger的目的還是為了提高效能。

 

 

Q:還有沒有別的辦法保證執行緒安全

A:有。儘可能避免引起非執行緒安全的條件——共享變數。如果能從設計上避免共享變數的使用,即可避免非執行緒安全的發生,也就無須通過鎖或者synchronized以及volatile解決原子性、可見性和順序性的問題。

 

 

Q:synchronized即可修飾非靜態方式,也可修飾靜態方法,還可修飾程式碼塊,有何區別

A:synchronized修飾非靜態同步方法時,鎖住的是當前例項;synchronized修飾靜態同步方法時,鎖住的是該類的Class物件;synchronized修飾靜態程式碼塊時,鎖住的是synchronized關鍵字後面括號內的物件。

 

下期預告:

多執行緒系列之 Java多執行緒核心技術的演進

相關文章