多線程與高並發(二)線程安全


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

那么什么是線程安全呢,定義如下:

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

簡單的理解就是在多線程情況下代碼的運行結果與預期的正確結果不一致,而產生線程安全的問題一般是由是主內存和工作內存數據不一致性重排序導致的。

要理解這些的必須先理解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從主存中讀取最新的共享變量

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM