多執行緒與高併發(二)執行緒安全

茶底世界發表於2019-06-30

上一篇學習了多執行緒的一些基礎知識:多執行緒的基本概念,及建立和操作多執行緒。內容相對簡單,但多執行緒的知識肯定不會這麼簡單,否則我們也不需要花這麼多心思去學習,因為多執行緒中容易出現執行緒安全問題。

那麼什麼是執行緒安全呢,定義如下:

當多個執行緒訪問同一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲取正確的結果,那這個物件是執行緒安全的。

簡單的理解就是在多執行緒情況下程式碼的執行結果與預期的正確結果不一致,而產生執行緒安全的問題一般是由是主記憶體和工作記憶體資料不一致性重排序導致的。

要理解這些的必須先理解java的記憶體模型。

一 Java記憶體模型

在併發程式設計領域,有兩個關鍵問題:執行緒之間的通訊同步

1.1 通訊與同步

執行緒通訊是指執行緒之間以何種機制來交換資訊,在指令式程式設計中,執行緒之間的通訊機制有兩種共享記憶體訊息傳遞

在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊,典型的共享記憶體通訊方式就是通過共享物件進行通訊。

在訊息傳遞的併發模型裡,執行緒之間沒有公共狀態,執行緒之間必須通過明確的傳送訊息來顯式進行通訊,在java中典型的訊息傳遞方式就是wait()和notify()。

執行緒同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。在共享記憶體併發模型裡,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的。

java記憶體模型是共享記憶體的併發模型,執行緒之間主要通過讀-寫共享變數來完成隱式通訊。如果不能理解Java的共享記憶體模型在編寫併發程式時一定會遇到各種各樣關於記憶體可見性的問題。

1.2 java記憶體模型(JMM)

CPU的處理速度和主存的讀寫速度不是一個量級的,為了平衡這種巨大的差距,每個CPU都會有快取。因此,共享變數會先放在主存中,每個執行緒都有屬於自己的工作記憶體,並且會把位於主存中的共享變數拷貝到自己的工作記憶體,之後的讀寫操作均使用位於工作記憶體的變數副本,並在某個時刻將工作記憶體的變數副本寫回到主存中去。JMM就從抽象層次定義了這種方式,並且JMM決定了一個執行緒對共享變數的寫入何時對其他執行緒是可見的。

如圖為JMM抽象示意圖,執行緒A和執行緒B之間要完成通訊的話,要經歷如下兩步:

  1. 執行緒A從主記憶體中將共享變數讀入執行緒A的工作記憶體後並進行操作,之後將資料重新寫回到主記憶體中;

  2. 執行緒B從主存中讀取最新的共享變數

從橫向去看看,執行緒A和執行緒B就好像通過共享變數在進行隱式通訊。這其中有很有意思的問題,如果執行緒A更新後資料並沒有及時寫回到主存,而此時執行緒B讀到的是過期的資料,這就出現了“髒讀”現象。可以通過同步機制(控制不同執行緒間操作發生的相對順序)來解決或者通過volatile關鍵字使得每次volatile變數都能夠強制重新整理到主存,從而對每個執行緒都是可見的。

java的記憶體模型內容還有很多,推薦看這篇文章:https://blog.csdn.net/suifeng3051/article/details/52611310

1.3 可見性和競爭現象

當物件和變數儲存到計算機的各個記憶體區域時,必然會面臨一些問題,其中最主要的兩個問題是:

  1. 共享物件對各個執行緒的可見性

  2. 共享物件的競爭現象

共享物件的可見性

當多個執行緒同時操作同一個共享物件時,如果沒有合理的使用volatile和synchronization關鍵字,一個執行緒對共享物件的更新有可能導致其它執行緒不可見。

一個CPU中的執行緒讀取主存資料到CPU快取,然後對共享物件做了更改,但CPU快取中的更改後的物件還沒有flush到主存,此時執行緒對共享物件的更改對其它CPU中的執行緒是不可見的。最終就是每個執行緒最終都會拷貝共享物件,而且拷貝的物件位於不同的CPU快取中。

要解決共享物件可見性這個問題,我們可以使用volatile關鍵字,volatile 關鍵字可以保證變數會直接從主存讀取,而對變數的更新也會直接寫到主存,這個後面會詳講。

競爭現象

如果多個執行緒共享一個物件,如果它們同時修改這個共享物件,這就產生了競爭現象。

執行緒A和執行緒B共享一個物件obj。假設執行緒A從主存讀取Obj.count變數到自己的CPU快取,同時,執行緒B也讀取了Obj.count變數到它的CPU快取,並且這兩個執行緒都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU快取中。

要解決競爭現象我們可以使用synchronized程式碼塊。synchronized程式碼塊可以保證同一個時刻只能有一個執行緒進入程式碼競爭區,synchronized程式碼塊也能保證程式碼塊中所有變數都將會從主存中讀,當執行緒退出程式碼塊時,對所有變數的更新將會flush到主存,不管這些變數是不是volatile型別的。

二 重排序

指令重排序是指編譯器和處理器為了提高效能對指令進行重新排序,重排序一般有以下三種:

  1. 編譯器優化重排序:編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

  2. 指令級並行的重排序:如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。

  3. 記憶體系統的重排序:處理器使用快取和讀寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

1屬於編譯器重排序,而2和3統稱為處理器重排序。這些重排序會導致執行緒安全的問題,JMM確保在不同的編譯器和不同的處理器平臺之上,通過插入特定型別的Memory Barrier來禁止特定型別的編譯器重排序和處理器重排序,為上層提供一致的記憶體可見性保證。

那麼什麼情況下一定不會重排序呢?編譯器和處理器不會改變存在資料依賴性關係的兩個操作的執行順序,即不會重排序,這裡有個資料依賴性概念是什麼意思呢?看如下程式碼:

int a = 1;//A
int b = 2;//B
int c = a + b;//c

這段程式碼中A和B沒有任何關係,改變A和B的執行順序,不會對結果產生影響,這裡就可以對A和B進行指令重排序,因為不管是先執行A或者B都對結果沒有影響,這個時候就說這兩個操作不存在資料依賴性,資料依賴性是指如果兩個操作訪問同一個變數,且這兩個操作有一個為寫操作,此時這兩個操作就存在資料依賴性,如果我們對變數a進行了寫操作,後又進行了讀取操作,那麼這兩個操作就是有資料依賴性,這個時候就不能進行指令重排序,這個很好理解,因為如果重排序的話會影響結果。

這裡還有一個概念要理解:as-if-serial:不管怎麼重排序,單執行緒下的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。

這裡也比較好理解,就是在單執行緒情況下,重排序不能影響執行結果,這樣程式設計師不必擔心單執行緒中重排序的問題干擾他們,也無需擔心記憶體可見性問題。

三 happens-before規則

我們知道處理器和編譯器會對指令進行重排序,但是如果要我們去了解底層的規則,那對我們來說負擔太大了,因此,JMM為程式設計師在上層提供了規則,這樣我們就可以根據規則去推論跨執行緒的記憶體可見性問題,而不用再去理解底層重排序的規則。

3.1 happens-before

我們無法就所有場景來規定某個執行緒修改的變數何時對其他執行緒可見,但是我們可以指定某些規則,這規則就是happens-before。

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

因此,JMM可以通過happens-before關係向程式設計師提供跨執行緒的記憶體可見性保證(如果A執行緒的寫操作a與B執行緒的讀操作b之間存在happens-before關係,儘管a操作和b操作在不同的執行緒中執行,但JMM向程式設計師保證a操作將對b操作可見)。具體的定義為:

1)如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

2)兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。

3.2 具體規則

具體的規則有8條:

具體的一共有六項規則:

  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()方法的開始。

參考文章:

https://blog.csdn.net/suifeng3051/article/details/52611310

相關文章