資深消防猿為你解讀Java多執行緒與併發模型之共享物件

脫毛程式猿發表於2019-03-04

網際網路上充斥著對Java多執行緒程式設計的介紹,每篇文章都從不同的角度介紹並總結了該領域的內容。但大部分文章都沒有說明多執行緒的實現本質,沒能讓開發者真正“過癮”。

以下內容如無特殊說明均指代Java環境。

共享物件

使用Java編寫執行緒安全的程式關鍵在於正確的使用共享物件,以及安全的對其進行訪問管理。在第一章我們談到Java的內建鎖可以保障執行緒安全,對於其他的應用來說併發的安全性是在內建鎖這個“黑盒子”內保障了執行緒變數使用的邊界。談到執行緒的邊界問題,隨之而來的是Java記憶體模型另外的一個重要的含義,可見性。Java對可見性提供的原生支援是volatile關鍵字。

volatile關鍵字

volatile 變數具備兩種特性,其一是保證該變數對所有執行緒可見,這裡的可見性指的是當一個執行緒修改了變數的值,那麼新的值對於其他執行緒是可以立即獲取的。其二 volatile 禁止了指令重排。

雖然 volatile 變數具有可見性和禁止指令重排序,但是並不能說 volatile 變數能確保併發安全。

public class VolatileTest {public static volatile int a = 0;public static final int THREAD_COUNT = 20;public static void increase() {a++;}public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[THREAD_COUNT];for (int i = 0; i < THREAD_COUNT; i++) {threads[i] = new Thread(new Runnable() {public void run() {for (int i = 0; i < 1000; i++) {increase();}}});threads[i].start();}while (Thread.activeCount() > 2) {Thread.yield();}System.out.println(a);}}複製程式碼

按照我們的預期,它應該返回 20000 ,但是很可惜,該程式的返回結果幾乎每次都不一樣。

問題主要出在 a++ 上,複合操作並不具備原子性, 雖然這裡利用 volatile 定義了 a ,但是在做 a++ 時, 先獲取到最新的 a 值,比如這時候最新的可能是 50,然後再讓 a 增加,但是在增加的過程中,其他執行緒很可能已經將 a 的值改變了,或許已經成為 52、53 ,但是該執行緒作自增時,還是使用的舊值,所以會出現結果往往小於預期的 2000。如果要解決這個問題,可以對 increase() 方法加鎖。

volatile 適用場景

volatile 適用於程式運算結果不依賴於變數的當前值,也相當於說,上述程式的 a 不要自增,或者說僅僅是賦值運算,例如 boolean flag = true 這樣的操作。

volatile boolean shutDown = false;public void shutDown() {shutDown = true;}public void doWork() {while (!shutDown) {System.out.println("Do work " + Thread.currentThread().getId());}}複製程式碼

程式碼2.1:變數的可見性問題

在程式碼2.1中,可以看到按照正常的邏輯應該列印10之後執行緒停止,但是實際的情況可能是列印出0或者程式永遠不會被終止掉。其原因是沒有使用恰當的同步機制以保障執行緒的寫入操作對所有執行緒都是可見的。

我們一般將volatile理解為synchronized的輕量級實現,在多核處理器中可以保障共享變數的“可見性”,但是不能保障原子性。關於原子性問題在該章節的程式變數規則會加以說明,下面我們先看下Java的記憶體模型實現以瞭解JVM和計算機硬體是如何協調共享變數的以及volatile變數的可見性。

Java記憶體模型

我們都知道現代計算機都是馮諾依曼結構的,所有的程式碼都是順序執行的。如果計算機需要在CPU中運算某個指令,勢必就會涉及對資料的讀取和寫入操作。由於程式資料的大部分內容都是儲存在主記憶體(RAM)中的,在這當中就存在著一個讀取速度的問題,CPU很快而主記憶體相對來說(相對CPU)就會慢上很多,為了解決這個速度階梯問題,各個CPU廠商都在CPU裡面引入了快取記憶體來優化主記憶體和CPU的資料互動。針對上面的技術我特意整理了一下,有很多技術不是靠幾句話能講清楚,所以乾脆找朋友錄製了一些視訊,很多問題其實答案很簡單,但是背後的思考和邏輯不簡單,要做到知其然還要知其所以然。如果想學習Java工程化、高效能及分散式、深入淺出。微服務、Spring,MyBatis,Netty原始碼分析的朋友可以加我的Java進階群:591240817,群裡有大牛直播講解技術,以及Java大型網際網路技術的視訊免費分享

此時當CPU需要從主記憶體獲取資料時,會拷貝一份到快取記憶體中,CPU計算時就可以直接在快取記憶體中進行資料的讀取和寫入,提高吞吐量。當資料執行完成後,再將快取記憶體的內容重新整理到主記憶體中,此時其他CPU看到的才是執行之後的結果,但在這之間存在著時間差。

看這個例子:

int counter = 0; counter = counter + 1;複製程式碼

程式碼2.2:自增不一致問題

程式碼2.2在執行時,CPU會從主記憶體中讀取counter的值,複製一份到當前CPU核心的快取記憶體中,在CPU執行完成加1的指令之後,將結果1寫入快取記憶體中,最後將快取記憶體重新整理到主記憶體中。這個例子程式碼在單執行緒的程式中將正確的執行下去。

但我們試想這樣一種情況,現在有兩個執行緒共同執行該段程式碼,初始化時兩個執行緒分別從主記憶體中讀取了counter的值0到各自的快取記憶體中,執行緒1在CPU1中運算完成後寫入快取記憶體Cache1,執行緒2在CPU2中運算完成後寫入快取記憶體Cache2,此時counter的值在兩個CPU的快取記憶體中的值都是1。

此時CPU1將值重新整理到主記憶體中,counter的值為1,之後CPU2將counter的值也重新整理到主記憶體,counter的值覆蓋為1,最終的結果計算counter為1(正確的兩次計算結果相加應為2)。這就是快取不一致性問題。這會在多執行緒訪問共享變數時出現。

解決快取不一致問題的方案:

  1. 通過匯流排鎖LOCK#方式。

  2. 通過快取一致性協議。

資深消防猿為你解讀Java多執行緒與併發模型之共享物件

圖2.1 :快取不一致問題

圖2.1中提到的兩種記憶體一致性協議都是從計算機硬體層面上提供的保障。CPU一般是通過在匯流排上增加LOCK#鎖的方式,鎖住對記憶體的訪問來達到目的,也就是阻塞其他CPU對記憶體的訪問,從而使只有一個CPU能訪問該主記憶體。因此需要用匯流排進行記憶體鎖定,可以分析得到此種做法對CPU的吞吐率造成的損害很嚴重,效率低下。

隨著技術升級帶來了快取一致性協議,市場佔有率較大的Intel的CPU使用的是MESI協議,該協議可以保障各個快取記憶體使用的共享變數的副本是一致的。其實現的核心思想是:當在多核心CPU中訪問的變數是共享變數時,某個執行緒在CPU中修改共享變數資料時,會通知其他也儲存了該變數副本的CPU將快取置為無效狀態,因此其他CPU讀取該快取記憶體中的變數時,發現該共享變數副本為無效狀態,會從主記憶體中重新載入。但當快取一致性協議無法發揮作用時,CPU還是會降級使用匯流排鎖的方式進行鎖定處理。

一個小插曲:為什麼volatile無法保障的原子性

我們看下圖2.2,CPU在主記憶體中讀取一個變數之後,拷貝副本到快取記憶體,CPU在執行期間雖然識別了變數的“易變性”,但是隻能保障最後一步store操作的原子性,在load,use期間並未實現其原子性操作。

資深消防猿為你解讀Java多執行緒與併發模型之共享物件

圖2.2:資料載入和記憶體屏障

JVM為了使我們的程式碼得到最優的執行體驗,在進行自我優化時,並不保障程式碼的先後執行順序(滿足Happen-Before規則的除外),這就是“指令重排”,而上面提到的store操作保障了原子性,JVM是如何實現的呢?其原因是這裡存在一個“記憶體屏障”的指令(以後我們會談到整個內容),這個是CPU支援的一個指令,該指令只能保障store時的原子性,但是不能保障整個操作的原子性。

從整個小插曲中,我們看到了volatile雖然有可見性的語義,但是並不能真正的保證執行緒安全。如果要保證併發執行緒的安全訪問,需要符合併發程式變數的訪問規則。

併發程式變數的訪問規則

1. 原子性

程式的原子性和資料庫事務的原子性有著同樣的意義,可以保障一次操作要麼全部執行成功,要不全部都不執行。

2. 可見性

可見性是微妙的,因為最終的結果總是和我們的直覺大相徑庭,當多個執行緒共同修改一個共享變數的值時,由於存在快取記憶體中的變數副本操作,不能及時將資料重新整理到主記憶體,導致當前執行緒在CP中的操作結果對其他CPU是不可見狀態。

3. 有序性

有序性通俗的理解就是程式在JVM中是按照順序執行的,但是前面已經提到了JVM為了優化程式碼的執行速度,會進行“指令重排”。在單執行緒中“指令重排”並不會帶來安全問題,但在併發程式中,由於程式的順序不能保障,執行過程中可能會出現不安全的執行緒訪問問題。

綜上,要想在併發程式設計環境中安全的執行程式,就必須滿足原子性、可見性和有序性。只要以上任何一點沒有保障,那程式執行就可能出現不可預知的錯誤。最後我們介紹一下Java併發的“殺手鐗”,Happens-Before法則,符合該法則的情況下可以保障併發環境下變數的訪問規則。

happens-before語義

Java記憶體模型使用了各種操作來定義的,包括對變數的讀寫,監視器的獲取釋放等,JMM中使用了

happens-before

語義闡述了操作之間的記憶體可見性。如果想要保證執行操作B的執行緒看到操作A的結構(無論AB是否在同一執行緒),那麼A,B必須滿足

happens-before

關係。如果兩個操作之間缺乏

happens-before


Happens-Before法則:

  1. 程式次序法則:執行緒中的每個動作A都Happens-Before於該執行緒中的每一個動作B,在程式中,所有的動作B都出現在動作A之後。

  2. Lock法則:對於一個Lock的解鎖操作總是Happens-Before於每一個後續對該Lock的加鎖操作。

  3. volatile變數法則:對於volatile變數的寫入操作Happens-Before於後續對同一個變數的讀操作。

  4. 執行緒啟動法則:在一個執行緒裡,對Thread.start()函式的呼叫會Happens-Before於每一個啟動執行緒中的動作。

  5. 執行緒終結法則:執行緒中的任何動作都Happens-Before於其他執行緒檢測到這個執行緒已經終結或者從Thread.join()函式呼叫中成功返回或者Thread.isAlive()函式返回false。

  6. 中斷法則:一個執行緒呼叫另一個執行緒的interrupt總是Happens-Before於被中斷的執行緒發現中斷。

  7. 終結法則:一個物件的建構函式的結束總是Happens-Before於這個物件的finalizer(Java沒有直接的類似C的解構函式)的開始。

  8. 傳遞性法則:如果A事件Happens-Before於B事件,並且B事件Happens-Before於C事件,那麼A事件Happens-Before於C事件。

當一個變數在多執行緒競爭中被讀取和儲存,如果並未按照Happens-Before的法則,那麼他就會存在資料競爭關係。

總結

給大家關於Java的共享變數的內容就介紹到這裡,現在你已經明白Java的volatile關鍵字的含義了,瞭解了為什麼volatile不能保障原子性的原因了,瞭解了Happens-Before規則能讓我們的Java程式執行的更加安全。通過這節內容希望可以幫助你更深入的瞭解Java的併發概念中的內建鎖和共享變數。Java的併發內容還有很多,例如在某些場景下比synchronized效率要更高的Lock,阻塞佇列,同步器等。資深消防猿為你解讀Java多執行緒與併發模型之共享物件



相關文章