JAVA併發之多執行緒引發的問題剖析以及如何保證執行緒安全
JAVA多執行緒中的各種問題剖析
首先開始之前 需要提及一下前置章節
能夠更加深入瞭解本節所講
首先我們來說一下併發的優點,根據優點特性,引出併發應當注意的安全問題
1併發的優點
技術在進步,CPU、記憶體、I/O 裝置的效能也在不斷提高。但是,始終存在一個核心矛盾:CPU、記憶體、I/O 裝置存在速度差異。CPU 遠快於記憶體,記憶體遠快於 I/O 裝置。
根據木桶短板理論可知,一隻木桶能裝多少水,取決於最短的那塊木板。程式整體效能取決於最慢的操作——I/O,即單方面提高 CPU 效能是無效的。
為了合理利用 CPU 的高效能,平衡這三者的速度差異,計算機體系機構、作業系統、編譯程式都做出了貢獻,主要體現為:
- CPU 增加了快取,以均衡與記憶體的速度差異;
- 作業系統增加了程序、執行緒,以分時複用 CPU,進而均衡 CPU 與 I/O 裝置的速度差異;
- 編譯程式優化指令執行次序,使得快取能夠得到更加合理地利用。
其中,程序、執行緒使得計算機、程式有了併發處理任務的能力,它有兩個重要優點
:
- 提升資源利用率
- 降低程式響應時間
1.1提升資源利用率
從磁碟中讀取檔案的時候,大部分的 CPU 時間用於等待磁碟去讀取資料。在這段時間裡,CPU 非常的空閒。它可以做一些別的事情。通過改變操作的順序,就能夠更好的使用 CPU 資源 ,使用併發方式不一定就是磁碟IO,也可以是網路IO和使用者輸入等,但是不管是哪種IO 都比CPU 和記憶體IO慢的多.執行緒並不能提高速度,而是在執行某個耗時的功能時,在還可以做其它的事。多執行緒使你的程式在處理檔案時不必顯得已經卡死.
1.2降低程式響應時間
為了使程式的響應時間變的更短,使用多執行緒應用程式也是常見的一種方式將一個單執行緒應用程式變成多執行緒應用程式的另一個常見的目的是實現一個響應更快的應用程式。設想一個伺服器應用,它在某一個埠監聽進來的請求。當一個請求到來時,它去處理這個請求,然後再返回去監聽。
伺服器的流程如下所述:
public class SingleThreadWebServer { public static void main(String[] args) throws IOException{ ServerSocket socket = new ServerSocket(80); while (true) { Socket connection = socket.accept(); handleRequest(connection); } } }
如果一個請求需要佔用大量的時間來處理,在這段時間內新的客戶端就無法傳送請求給服務端。只有伺服器在監聽的時候,請求才能被接收。另一種設計是,監聽執行緒把請求傳遞給工作者執行緒(worker thread),然後立刻返回去監聽。而工作者執行緒則能夠處理這個請求併發送一個回覆給客戶端。這種設計如下所述:
public class ThreadPerTaskWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable workerThread = new Runnable() {
public void run() {
handleRequest(connection);
}
};
}
}
}
這種方式,服務端執行緒迅速地返回去監聽。因此,更多的客戶端能夠傳送請求給服務端。這個服務也變得響應更快。
桌面應用也是同樣如此。如果你點選一個按鈕開始執行一個耗時的任務,這個執行緒既要執行任務又要更新視窗和按鈕,那麼在任務執行的過程中,這個應用程式看起來好像沒有反應一樣。相反,任務可以傳遞給工作者執行緒(worker thread)。當工作者執行緒在繁忙地處理任務的時候,視窗執行緒可以自由地響應其他使用者的請求。當工作者執行緒完成任務的時候,它傳送訊號給視窗執行緒。視窗執行緒便可以更新應用程式視窗,並顯示任務的結果。對使用者而言,這種具有工作者執行緒設計的程式顯得響應速度更快。
2併發帶來的安全性問題
併發安全是指 保證程式在併發處理時的結果 符合預期
併發安全需要保證3個特性:
原子性:通俗講就是相關操作不會中途被其他執行緒干擾,一般通過同步機制(加鎖:sychronized
、Lock
)實現。
有序性:保證執行緒內序列語義,避免指令重排等
可見性:一個執行緒修改了某個共享變數,其狀態能夠立即被其他執行緒知曉,通常被解釋為將執行緒本地狀態反映到主記憶體上,volatile
就是負責保證可見性的
Ps:對於volatile
這個關鍵字,需要單獨寫一篇文章來講解,後續更新 請持續關注公眾號:JAVA寶典
2.1 原子性問題
早期,CPU速度比IO操作快很多,一個程式在讀取檔案時,可將自己標記為"休眠狀態"並讓出CPU的使用權,等待資料載入到記憶體後,作業系統會喚醒該程序,喚醒後就有機會重新獲得CPU使用權.
這些操作會引發程序的切換,不同程序間是不共享記憶體空間的,所以程序要做任務切換就要切換記憶體對映地址.
而一個程序建立的所有執行緒,都是共享一個記憶體空間的,所以執行緒做任務切換成本就很低了
所以我們現在提到的任務切換
都是指執行緒切換
高階語言裡一條語句,往往需要多個 CPU 指令完成,如:
count += 1
,至少需要三條 CPU 指令
- 指令 1:首先,需要把變數 count 從記憶體載入到 CPU 的暫存器;
- 指令 2:之後,在暫存器中執行 +1 操作;
- 指令 3:最後,將結果寫入記憶體(快取機制導致可能寫入的是 CPU 快取而不是記憶體)。
原子性問題出現:
對於上面的三條指令來說,我們假設 count=0,如果執行緒 A 在指令 1 執行完後做執行緒切換,執行緒 A 和執行緒 B 按照下圖的序列執行,那麼我們會發現兩個執行緒都執行了 count+=1 的操作,但是得到的結果不是我們期望的 2,而是 1。
我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱為原子性。CPU 能保證的原子操作是 CPU 指令級別的,而不是高階語言的操作符,這是違揹我們直覺的地方。因此,很多時候我們需要在高階語言層面保證操作的原子性。
2.2有序性問題
顧名思義,有序性指的是程式按照程式碼的先後順序執行。編譯器為了優化效能,有時候會改變程式中語句的先後順序
舉個例子:
雙重檢查建立單例物件,在獲取例項 getInstance() 的方法中,我們首先判斷 instance 是否為空,如果為空,則鎖定 Singleton.class 並再次檢查 instance 是否為空,如果還為空則建立 Singleton 的一個例項.
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
執行緒A,B如果同時呼叫getInstance()方法獲取例項,他們會同時檢查到instance 為null ,這時會將Singleton.class進行加鎖操作,此時jvm保證只有一個鎖上鎖成功,另一個執行緒會等待狀態;假設執行緒A加鎖成功,這時執行緒A會new一個例項之後釋放鎖,執行緒B被喚醒,執行緒B會再次加鎖此時加鎖成功,執行緒B檢查例項是否為null,會發現已經被例項化,不會再建立另外一個例項.
這段程式碼和邏輯看上去沒有問題,但實際上getInstance()方法還是有問題的,問題在new的操作上,我們認為的new操作應該是:
1.分配記憶體
2.在這塊記憶體上初始化Singleton物件
3.將記憶體地址給instance變數
但是實際jvm優化後的操作是這樣的:
1分配記憶體
2將地址給instance變數
3在記憶體上初始化Singleton物件
優化後會導致 我們這個時候另一個執行緒訪問 instance 的成員變數時獲取物件不為null 就結束例項化操作 返回instance 會觸發空指標異常。
2.3可見性問題
一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到,稱為 可見性。
現代多核心CPU,每個核心都有自己的快取,多個執行緒在不同的CPU核心上執行時,執行緒操作的是不同的CPU快取,
執行緒不安全的示例
下面的程式碼,每執行一次 add10K() 方法,都會迴圈 10000 次 count+=1 操作。在 calc() 方法中我們建立了兩個執行緒,每個執行緒呼叫一次 add10K() 方法,我們來想一想執行 calc() 方法得到的結果應該是多少呢?
class Test {
private static long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
public static long getCount(){
return count;
}
public static void calc() throws InterruptedException {
final Test test = new Test();
// 建立兩個執行緒,執行 add() 操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 啟動兩個執行緒
th1.start();
th2.start();
// 等待兩個執行緒執行結束
th1.join();
th2.join();
}
public static void main(String[] args) throws InterruptedException {
Test.calc();
System.out.println(Test.getCount());
//執行三次 分別輸出 11880 12884 14821
}
}
直覺告訴我們應該是 20000,因為在單執行緒裡呼叫兩次 add10K() 方法,count 的值就是 20000,但實際上 calc() 的執行結果是個 10000 到 20000 之間的隨機數。為什麼呢?
我們假設執行緒 A 和執行緒 B 同時開始執行,那麼第一次都會將 count=0 讀到各自的 CPU 快取裡,執行完 count+=1 之後,各自 CPU 快取裡的值都是 1,同時寫入記憶體後,我們會發現記憶體中是 1,而不是我們期望的 2。之後由於各自的 CPU 快取裡都有了 count 的值,兩個執行緒都是基於 CPU 快取裡的 count 值來計算,所以導致最終 count 的值都是小於 20000 的。這就是快取的可見性問題。
迴圈 10000 次 count+=1 操作如果改為迴圈 1 億次,你會發現效果更明顯,最終 count 的值接近 1 億,而不是 2 億。如果迴圈 10000 次,count 的值接近 20000,原因是兩個執行緒不是同時啟動的,有一個時差
。
3如何保證併發安全
瞭解保證併發安全的方法,首先要了解同步是什麼:
同步是指在多執行緒併發訪問共享資料時,保證共享資料在同一時刻只被一個執行緒訪問
實現保證併發安全有下面3種方式:
1.阻塞同步(悲觀鎖):
阻塞同步也稱為互斥同步,是常見併發保證正確性的手段,臨界區(Critical Sections)、互斥量(Mutex)和訊號量(Semaphore)都是主要的互斥實現方式
最典型的案例是使用
synchronized
或Lock
。互斥同步最主要的問題是執行緒阻塞和喚醒所帶來的效能問題,互斥同步屬於一種悲觀的併發策略,總是認為只要不去做正確的同步措施,那就肯定會出現問題。無論共享資料是否真的會出現競爭,它都要進行加鎖(這裡討論的是概念模型,實際上虛擬機器會優化掉很大一部分不必要的加鎖)、使用者態核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要喚醒等操作。
2.非阻塞同步(樂觀鎖)
基於衝突檢測的樂觀併發策略:先進行操作,如果沒有其它執行緒爭用共享資料,那操作就成功了,否則採取補償措施(不斷地重試,直到成功為止)。這種樂觀的併發策略的許多實現都不需要將執行緒阻塞,因此這種同步操作稱為非阻塞同步
樂觀鎖指令常見的有:
- 測試並設定(Test-amd-Set)
- 獲取並增加(Fetch-and-Increment)
- 交換(Swap)
- 比較並交換(CAS)
- 載入連結、條件儲存(Load-linked / Store-Conditional)
Java 典型應用場景:J.U.C 包中的原子類(基於
Unsafe
類的 CAS (Compare and swap) 操作)
3.無同步
要保證執行緒安全,不一定非要進行同步。同步只是保證共享資料爭用時的正確性,如果一個方法本來就不涉及共享資料,那麼自然無須同步。
Java 中的 無同步方案 有:
- 可重入程式碼 - 也叫純程式碼。如果一個方法,它的 返回結果是可以預測的,即只要輸入了相同的資料,就能返回相同的結果,那它就滿足可重入性,程式可以在被打斷處繼續執行,且執行結果不受影響,當然也是執行緒安全的。
- 執行緒本地儲存 - 使用
ThreadLocal
為共享變數在每個執行緒中都建立了一個本地副本,這個副本只能被當前執行緒訪問,其他執行緒無法訪問,那麼自然是執行緒安全的。
4總結
為了併發的優點 我們選擇了多執行緒,多執行緒併發給我們帶來了好處 也帶來了問題,處理這些安全性問題我們選擇加鎖讓共享資料同時只能進入一個執行緒來保證併發時資料安全,這時加鎖也為我們帶來了諸多問題 如:死鎖,活鎖,執行緒飢餓等問題
下一篇我們將剖析加鎖導致的活躍性問題
盡請期待
關注公眾號:java寶典