JAVA多執行緒中的各種問題剖析
首先開始之前 需要提及一下前置章節
能夠更加深入了解本節所講
- JAVA并發之基礎概念篇
- JAVA并發之行程VS執行緒篇
首先我們來說一下并發的優點,根據優點特性,引出并發應當注意的安全問題
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寶典
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/191697.html
標籤:Java
上一篇:010_初建工程

