併發程式設計的目標與挑戰
If I had only one hour to save the worlds,I would spend fifty-five minutes defining the problem,and only five minutes finding the solution.
如果我只有1小時拯救世界,我將花55分鐘定義這個問題而只花分鐘去尋找解決方案 ——Albert Einstein
本文講解的將是多執行緒的一些重要概念,為接下來自己以及讀者更好的理解併發程式設計做個鋪墊。
之後會講解volatile關鍵字,CAS , AQS 等等,總之概念是實踐的基石
1.1 競態
多執行緒程式設計中經常遇到一個問題就是對於同樣的輸入,程式的輸出有時候是正確的,而有時候卻是錯誤的。這種一個計算結果的正確性與時間有關的現象就被稱為 競態 (Race Condition)。
如 java核心技術-多執行緒基礎 </>中 1.1 (2)
public class Ticket implements Runnable{ private int ticket = 100; @Override public void run() { while(ticket > 0){ System.out.println(Thread.currentThread().getName() + "=" + --ticket); } } }
public class TestThread2 { public static void main(String[] args) { Ticket ticket = new Ticket(); //雖然是實現了Runnable介面 本質上只是實現了執行緒執行體 啟動工作還是需要Thread類來進行 Thread t1 = new Thread(ticket,"售票視窗一"); t1.start(); Thread t2 = new Thread(ticket,"售票視窗二"); t2.start(); Thread t3 = new Thread(ticket,"售票視窗三"); t3.start(); } }
賣票的CASE,此案例中競態導致的結果是不同業務的執行緒可能拿到了重複的ticket(票),且可能出現ticket為負數的情況。
可見 while(ticket > 0) 以及 --ticket 這兩個操作 是禍端之源。
進一步來說,導致競態的常見因素是多個執行緒 在沒有采取任何控制措施的情況下,併發地更新、讀取同一個共享變數
有朋友可能會說:--ticket 操作 是一個操作啊 你怎麼能說是禍端之源
其實不是的,只是看起來像是一個操作而已,它實際上 相當於如下虛擬碼所表示的三個指令
load(ticket,r1); //指令①:將變數ticket 的值從記憶體讀到暫存器r1 decrement(r1); //指令②:將暫存器r1的值減少1 store(ticket,r1);//指令③:將暫存器r1的內容寫入變數ticket所對應的記憶體空間
而 ①②③並不能保證是一個原子操作,兩個業務執行緒可能在同一時刻讀取到ticket的同一個值,一個業務執行緒對ticket所做的更新也可能"覆蓋"其他執行緒對該變數做的更新,所以,問題不言而喻.....
1.2 競態的模式與競態產生的條件
從上述競態的典型例項中,我們可以提煉出競態的兩種模式:
① read-modify-write(讀改寫)
② check-then-act (檢測而後行動)
read-modify-write(讀改寫)操作可以被細分為這樣幾個步驟:讀取一個共享變數的值(read),然後根據該值做一些計算(modify),接著更新該共享變數的值。例如 --ticket
check-then-act (檢測而後行動) ,該操作可以被細分為這樣幾個步驟:讀取某個共享變數的值,根據該共享變數的值決定下一步的動作是什麼。while(ticket > 0) --ticket
但是對於區域性變數(包括形式引數和方法體內定義的變數),由於不同的執行緒各自訪問的各自訪問的是各自的那一份區域性變數, 因此區域性變數的使用不會導致競態 ,如下例
public class NoRaceCondition { public int nextSequence(int sequence){ if(sequence >= 999){ sequence = 0; }else{ sequence++; } return sequence; } }
1.3 執行緒安全性
一般而言,如果一個類在單執行緒環境下能夠正常執行,並且在多執行緒環境下,在其使用方不必為其做任何改變的情況下也能正常執行,那麼我們就稱其是執行緒安全的,相應的我們稱這個類具有執行緒安全性,反之亦然。 而一個類如果是執行緒安全的,那麼它就不會導致競態。
執行緒安全問題概括來說表現為3個方面: 原子性、可見性、有序性
1.3.1 原子性
原子(Atomic) 的字面意思是不可分割的。其含義簡單的來說就是, 訪問(讀、寫)某個共享變數的操作從執行執行緒以外的任何執行緒來看,該操作要麼已經執行結束,要麼尚未發生,即其他執行緒不會"看到"該操作執行緒執行了部分的中間效果
在生活中我們可以找到的一個原子操作的例子就是人們從 ATM 機提取現金; 儘管從ATM軟體的角度來說,一筆交易涉及扣減主賬戶餘額、吐鈔器吐出鈔票、新增交易記錄等一系列操作,但是從使用者的角度來看 ATM取款就是一個操作。 該操作要麼成功了,我們拿到了現金。要麼失敗了,我們沒有拿到現金。
理解原子操作要注意以下兩點:
- 原子操作是針對訪問共享變數的操作而言的
- 原子操作是從該操作的執行執行緒以外的執行緒來描述的
總的來說, Java 中有兩種方式來實現原子性。
一種是使用鎖(Lock)。鎖具有排他性,即它能保證一個共享變數在任意時刻只能夠被一個執行緒訪問。這就排除了多個執行緒在同一時刻訪問通一個共享變數而導致干擾與衝突的可能,即消除了競態。
另一種是利用處理器處理器專門提供的 CAS(Compare-and-Swap)指令,CAS 指令實現原子性的方式與鎖實現原子性的方式實質上相同的,差別在於鎖通常是在軟體這一層次實現的,而CAS 是直接在硬體(處理器和記憶體) 這一層次實現的,它可以被看作"硬體鎖"
在Java 語言中,long型 和 double型 以外的任何基礎型別的變數的寫操作 都是原子操作。
對 long/double 型變數的寫操作 由於 Java語言規範並不保障其具有原子性 ,因此多個執行緒併發訪問同 一 long/double型變數的情況下,一個執行緒可能會讀取到其他執行緒更新該變數的"中間結果"(64位的虛擬機器應該不會出現這個問題);
注:使用32位虛擬機器 用對個執行緒對long,double型資料進行操作 會有低32位 高32位的問題,儘管如此可以使用volatile關鍵字進行解決,它可以保證變數寫操作的原子性,即執行緒共享變數 重新整理到主存這個動作是原子的
1.3.2 可見性
在多執行緒環境下,一個執行緒對某個共享變數進行更新後,後續訪問該變數的執行緒可能無法立刻讀取到這個更新的結果,甚至永遠無法讀取到這個更新的結果。這就是執行緒安全問題的另外一個表現形式: 可見性
下面我們來一個Demo吧
public class ThreadVolatile{ public static void main(String[] args) { ThreadDemo td = new ThreadDemo(); //01 new Thread(td).start();//02 while(true){ if(td.isFlag()){//03 System.out.println("-----------------"); break; } } } } class ThreadDemo implements Runnable{ private boolean flag = false; @Override public void run() { //此處的目的 是讓main執行緒 從主存那 先獲取flag等於false的值 try { Thread.sleep(200); } catch (Exception e) { } flag = true;//04 System.out.println("flag=" + flag); } public boolean isFlag(){ return flag; } public void setFlag(boolean flag){ this.flag = flag; } }
執行結果:
列印flag=true, 但迴圈無法終止
在解釋原因之前先說幾個概念:(很重要)
- 棧:執行緒獨有,儲存其執行狀態以及區域性自動變數,作業系統在切換執行緒的時候會自動切換棧,也就是切換暫存器
- 堆:儲存物件的實體以及全域性變數,可以把堆記憶體 約看成 主記憶體
01-初始化完ThreadDemo 記憶體空間:
02.子執行緒ThreadDemo啟動 獲取到flag=false的值 開始睡覺
03.main執行緒獲得了flag=false的值 在迴圈體中跑了若干次
04.由於03步驟main執行緒獲得了flag=flase,雖然主存變了,但是由於while(true)執行效率太高,根本沒有時間讓主存中的資料同步到main執行緒中去,所以main執行緒一直在死迴圈
那麼,在Java平臺中 如何保證可見性呢?
對於上例Demo,我們只需將其flag的宣告新增一個 volatile 關鍵字即可,即
private volatile boolean flag = false;
這裡,volatile關鍵字所起到的一個作用就是,提示JIT編譯器被修飾的變數可能被多個執行緒共享,以組織JIT編譯器做出可能導致執行不正常的優化 (重排序)。另外一個作用就是 讀取一個volatile關鍵字所修飾的變數會使相應的處理器執行重新整理處理器快取的動作
1.3.3 有序性
有序性指在什麼情況下一個處理器上的執行的一個執行緒所執行的記憶體訪問操作在另外一個處理器上執行的其他執行緒看來是亂序的。( 某書定義 )
我的理解:程式執行順序要與程式碼邏輯順序保持基本一致,避免多執行緒情況由於重排導致的錯誤
所謂亂序,是指記憶體訪問操作的順序看起來像是發生了變化。在進一步介紹有序性概念之前,我們需要介紹重排序的概念
重排序:是指編譯器和處理器為了優化程式效能而對指令序列進行重新排序的一種手段
- 指令重排序:原始碼順序與程式順序不一致,或者程式順序與執行順序不一致的情況下 (編譯器,處理器)
- 儲存子系統重排:原始碼順序、程式順序和執行順序這三者保持一致,但是感知順序與執行順序不一致 (快取記憶體,寫緩衝器)
注:這一塊建議瞭解編譯原理 以及彙編
as-if-serial語義:編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變程式執行結果。但是,如果操作之間不存在資料依賴關係,這些操作就可以被編譯器和處理器重排序。
示例:
double pi = 3.14;// A double r = 1.0;//B double area = pi * r * r; //C
分析:A與C之間存在資料依賴關係,所以C不能排到A的前面,同時B與C之間也存在資料依賴關係,所以,C也不能排到B的前面,但是A與B之間是不存在資料依賴關係的,所以A與B之間是可以進行重排序的。
程式順序規則:
根據 happens-before 的程式規則,上面的計算圓的示例程式碼存在3個happens-before關係:
A happens-before B ; B happens-before C; A happens-before C;
重排序對多執行緒的影響:
class RecorderExample{ int a = 0; boolean flag = false; public void writer(){ a = 1; // 1 flag = true; // 2 } public void reader(){ if(flag){// 3 int i = a * a;// 4 ...... } } }
flag是一個變數,用來表示變數a是否已被寫入。這裡假設有兩個執行緒A和B ,A執行緒首先執行writer方法,隨後執行緒B執行reader方法。執行緒B在執行操作4的時候,能否看到執行緒A在操作共享變數a的寫入呢?
答案是:在多執行緒的情況下,不一定能看到;
由於操作1和操作2沒有資料依賴的關係,編譯器和處理器可以對這兩個操作進行重排序,操作3和操作4沒有資料依賴關係,編譯器和處理器也可以對其進行重排序,下面我們看一下可能的執行情況的示意圖:
如上所示,操作1 和操作2 進行了重排序。程式執行時,執行緒A首先寫標記變數flag,隨後執行緒B讀這個變數。由於判斷條件為真,執行緒B將讀取變數a。此時,變數a還沒有被執行緒A寫入,所以在這裡,多項層程式的語義就被重排序破壞了。
下面在看一下操作3和操作4重排序會發生什麼效果:
在程式中,操作3和操作4存在控制依賴關係。當代碼中存在控制依賴行時,會影響指令序列執行的並行度。為此,編譯器和處理器會採用猜測執行來克服控制相關性對並行度的影響。以處理器的猜測執行為例,執行執行緒B的處理器可以提前讀取並計算a*a,然後把計算結果臨時儲存到一個名為重排序緩衝的硬體快取中。當操作3的條件判斷為真的時候,就把該結算結果寫入到變數i中。
從上圖我們可以看出,猜測執行實質上是對操作3和操作4進行了重排序,重排序在這裡破壞了多執行緒程式的語義。
在單執行緒程式中,對存在控制依賴的操作進行重排序,不會改變執行結果(這也是as-if-serial 語義允許對存在控制依賴的操作做重排序的原因),但是在多執行緒的程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。