「原創」Java並發編程系列09|基礎乾貨

2020-12-14 酷扯兒

本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫

本文為何適原創並發編程系列第9篇。現在,我們進入正題:介紹並發編程的基礎性概念。

並發編程的優勢和缺點並發編程中常見的線程安全問題如何實現線程之間的通信?死鎖是怎麼發生的,如何避免?嵌套管程鎖死、重入鎖死、飢餓和公平。1. 並發編程介紹

1.1 並發的出現

單CPU時代,單任務在一個時間點只能執行單一程序。

多任務階段,計算機能在同一時間點並行執行多進程。多個任務或進程共享一個CPU,並交由作業系統來完成多任務間對CPU的運行切換,以使得每個任務都有機會獲得一定的時間片運行。

現代的計算機多核CPU,在一個程序內部能擁有多個線程並行執行,多個CPU同時執行該程序。一個進程就包括了多個線程,每個線程負責一個獨立的子任務。

進程讓作業系統的並發性成為可能,而線程讓進程的內部並發成為可能。

一個進程雖然包括多個線程,但是這些線程是共同享有進程佔有的資源和地址空間的。

進程是作業系統進行資源分配的基本單位,而線程是作業系統進行調度的基本單位。

1.2 並發編程優點

1)資源利用率更好

舉例:

一個程序讀取文件(5s)和處理文件(2s),處理2個文件。

5秒讀取文件A2秒處理文件A5秒讀取文件B2秒處理文件B

總共需要14秒。讀取文件的時候,CPU空閒等待讀取數據,浪費CPU資源。

並發處理:

5秒讀取文件A5秒讀取文件B + 2秒處理文件A2秒處理文件B

總共需要12秒。當第二文件在被讀取的時候,利用CPU的空閒去處理第一個文件。

2)程序設計在某些情況下更簡單

如上述讀取處理文件舉例中,如果使用單線程實現,需要每個文件讀取和處理的狀態;而使用多線程,每個線程處理一個文件的讀取和處理,不需要記錄文件讀取和處理狀態,實現更簡單。

3)程序響應更快

並發編程缺點

1)設計更複雜

由於多個線程是共同佔有所屬進程的資源和地址空間的,那麼就會存在多個線程同時訪問同一個資源的問題,可能導致線程安全問題。避免多線程編程中線程安全設計較複雜。

2)上下文切換的開銷

CPU從執行一個線程切換到執行另外一個線程的時候,需要先存儲當前線程的本地的數據,程序指針等,然後載入另一個線程的本地數據,程序指針等,最後才開始執行。這種切換稱為上下文切換

對於線程的上下文切換實際上就是存儲和恢復CPU狀態的過程,它使得線程執行能夠從中斷點恢復執行。

雖然多線程可以使得任務執行的效率得到提升,但是由於在線程切換時同樣會帶來一定的開銷代價,並且多個線程會導致系統資源佔用的增加,所以在進行多線程編程時要注意這些因素。

3)增加資源消耗

線程在運行的時候需要從計算機裡面得到一些資源。除了CPU,線程還需要一些內存來維持它本地的堆棧。它也需要佔用作業系統中一些資源來管理線程。

2. 線程安全問題

競態條件:當多個線程同時訪問同一個資源,其中的一個或者多個線程對這個資源進行了寫操作,對資源的訪問順序敏感,就稱存在競態條件。多個線程同時讀同一個資源不會產生競態條件。

臨界區:導致競態條件發生的代碼區稱作臨界區。在臨界區中使用適當的同步就可以避免競態條件。

public class Counter {protected long count = 0; public void add(long value){ this.count = this.count + value; }}

多線程同時執行上面的代碼可能會出錯:多線程同時執行臨界區代碼this.count = this.count + value時,同時對同一資源this.count進行寫操作,產生了競態條件。

基本上所有的併發模式在解決線程安全問題時,都採用「序列化訪問臨界資源」的方案,即在同一時刻,只能有一個線程訪問臨界資源,也稱作同步互斥訪問。通常來說,是在訪問臨界資源的代碼前面加上一個鎖,當訪問完臨界資源後釋放鎖,讓其他線程繼續訪問。

在Java中,提供了兩種方式來實現同步互斥訪問:synchronized和Lock。

3. 線程通信

線程通信的目標是使線程間能夠互相發送信號。另一方面,線程通信使線程能夠等待其他線程的信號。

通過共享對象通信

// 必須是同一個MySignal實例,通過共享變量hasDataToProcess通信public class MySignal {protected boolean hasDataToProcess = false; public synchronized boolean hasDataToProcess() { return this.hasDataToProcess; } public synchronized void setHasDataToProcess(boolean hasData) { this.hasDataToProcess = hasData; }}

單線程A完成某一操作M之後,調用setHasDataToProcess(true),將hasDataToProcess置為true,表示操作M完成。

線程B調用hasDataToProcess()獲取hasDataToProcess為true,就知道操作M已經完成。

wait() - notify()/notifyAll()

//A線程調用doWait()等待, B線程調用doNotify()喚醒A線程public class MyWaitNotify {MonitorObject myMonitorObject = new MonitorObject(); public void doWait(){ synchronized(myMonitorObject){ try{ myMonitorObject.wait(); } catch(InterruptedException e){...} } } public void doNotify() { synchronized (myMonitorObject) { myMonitorObject.notify(); } }}

優化:

增加boolean wasSignalled,記錄是否收到喚醒信號。只有沒收到過喚醒信號時才可以wait,避免信號丟失導致永久wait。while()自旋鎖,線程被喚醒之後可以保證再次檢查條件是否滿足,避免虛假信號。

public class MyWaitNotify3 {MonitorObject myMonitorObject = new MonitorObject(); boolean wasSignalled = false; public void doWait() { synchronized (myMonitorObject) { while (!wasSignalled) { try { myMonitorObject.wait();// 如果被虛假喚醒,再回while循環檢查條件wasSignalled } catch (InterruptedException e) { } } wasSignalled = false; } } public void doNotify() { synchronized (myMonitorObject) { wasSignalled = true; myMonitorObject.notify(); } }}

4. 死鎖

死鎖:多個線程同時但以不同的順序請求同一組鎖的時候,線程之間互相循環等待鎖導致線程一直阻塞。

如果線程1鎖住了A,然後嘗試對B進行加鎖,同時線程2已經鎖住了B,接著嘗試對A進行加鎖,這樣線程1持有鎖A等待鎖B,線程2持有鎖B等待鎖A,就會發生死鎖。

死鎖可能不止包含2個線程,可以包含多個線程。如線程1等待線程2,線程2等待線程3,線程3等待線程4,線程4等待線程1。

public class Test {static Object lockObject1 = new Object(); static Object lockObject2 = new Object(); public static void main(String[] args) { new Thread() { @Override public void run() { synchronized (lockObject1) { try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lockObject2) { System.out.println(1); } } } }.start(); new Thread() { @Override public void run() { synchronized (lockObject2) { synchronized (lockObject1) { System.out.println(1); } } } }.start(); }}

如何避免死鎖?

1)按順序加鎖

多個線程請求的一組鎖按順序加鎖可以避免死鎖。

死鎖:如果線程1鎖住了A,然後嘗試對B進行加鎖,同時線程2已經鎖住了B,接著嘗試對A進行加鎖,發生死鎖。

解決:規定鎖A和鎖B的順序,某個線程需要同時獲取鎖A和鎖B時,必須先拿鎖A再拿鎖B。線程1和線程2都先鎖A再鎖B,不會發生死鎖。

問題:需要事先知道所有可能會用到的鎖,並對這些鎖做適當的排序。

2)加鎖時限(超時重試機制)

設置一個超時時間,在嘗試獲取鎖的過程中若超過了這個時限該線程則放棄對該鎖請求,回退並釋放所有已經獲得的鎖,然後等待一段隨機的時間再重試。

這段隨機的等待時間讓其它線程有機會嘗試獲取相同的這些鎖,並且讓該應用在沒有獲得鎖的時候可以繼續運行幹點其它事情。

問題:

當線程很多時,等待的這一段隨機的時間會一樣長或者很接近,因此就算出現競爭而導致超時後,由於超時時間一樣,它們又會同時開始重試,導致新一輪的競爭,帶來了新的問題。不能對synchronized同步塊設置超時時間。需要創建一個自定義鎖,或使用java.util.concurrent包下的工具。3)死鎖檢測

主要是針對那些不可能實現按序加鎖並且鎖超時也不可行的情況。

每當一個線程獲得了鎖,會在線程和鎖相關的數據結構中(比如map)將其記下。當一個線程請求鎖失敗時,這個線程可以遍歷鎖的關係圖看看是否有死鎖發生。

例如:線程1請求鎖A,但是鎖A這個時候被線程2持有,這時線程1就可以檢查一下線程2是否已經請求了線程1當前所持有的鎖。

如果線程2確實有這樣的請求,那麼就是發生了死鎖(線程1擁有鎖B,請求鎖A;線程B擁有鎖A,請求鎖B)。

當檢測出死鎖時,可以有兩種做法:

釋放所有鎖,回退,並且等待一段隨機的時間後重試。(類似超時重試機制)給這些線程設置優先級,讓一個(或幾個)線程回退,剩下的線程就像沒發生死鎖一樣繼續保持著它們需要的鎖。5. 嵌套管程鎖死

線程1獲得A對象的鎖。線程1獲得對象B的鎖(A對象鎖還未釋放)。線程1調用B.wait(),從而釋放了B對象上的鎖,但仍然持有對象A的鎖。線程2需要同時持有對象A和對象B的鎖,才能向線程1發信號B.notify()。線程2無法獲得對象A上的鎖,因為對象A上的鎖當前正被線程1持有。線程2一直被阻塞,等待線程1釋放對象A上的鎖。線程1一直阻塞,等待線程2的信號,因此不會釋放對象A上的鎖。

public class Lock {protected MonitorObject monitorObject = new MonitorObject(); protected boolean isLocked = false; public void lock() throws InterruptedException { synchronized (this) { while (isLocked) { synchronized (this.monitorObject) { this.monitorObject.wait(); } } isLocked = true; } } public void unlock() { synchronized (this) { this.isLocked = false; synchronized (this.monitorObject) { this.monitorObject.notify(); } } }}

線程1調用lock()方法,Lock對象鎖和monitorObject鎖,調用monitorObject.wait()阻塞,但仍然持有Lock對象鎖。

線程2調用unlock()方法解鎖時,無法獲取Lock對象鎖,因為線程1一直持有Lock鎖,造成嵌套管程鎖死。

6. 重入鎖死

如果一個線程持有某個對象上的鎖,那麼它就有權訪問所有在該對象上同步的塊,這就叫可重入。synchronized、ReentrantLock都是可重入鎖。

如果一個線程持有鎖A,鎖A是不可重入的,該線程再次請求鎖A時被阻塞,就是重入鎖死。

重入鎖死舉例:

public class Lock {private boolean isLocked = false; public synchronized void lock() throws InterruptedException { while (isLocked) { wait(); } isLocked = true; } public synchronized void unlock() { isLocked = false; notify(); }}

如果一個線程兩次調用lock()間沒有調用unlock()方法,那麼第二次調用lock()就會被阻塞,這就出現了重入鎖死。

7. 飢餓和公平

如果一個線程因為CPU時間全部被其他線程搶走而得不到CPU運行時間,這種狀態被稱之為飢餓。

導致線程飢餓原因:

高優先級線程吞噬所有的低優先級線程的CPU時間。線程始終競爭不到鎖。線程調用object.wait()後沒有被喚醒。解決飢餓的方案被稱之為公平性,即所有線程均能公平地獲得運行機會。關於公平鎖會在之後ReentrantLock中詳細介紹。

總結

並發編程可以更好的利用CPU資源,更高效快速的響應程序,但是設計較複雜,並且上下文切換會造成一定的消耗。

並發編程中,由於多個線程同時訪問同一個資源,可能造成線程安全問題,Java中可以通過synchronized和Lock的方式實現同步解決線程安全問題。

更好的發揮多線程的優勢需要線程之間通信,常用的線程通信方式是通過共享對象的狀態通信和wait()/notify()。

多個線程同時但以不同的順序請求同一組鎖的時候,線程之間互相循環等待鎖導致線程一直阻塞,造成死鎖。最常用的解決死鎖的方式是按順序加鎖。

線程持有不可重入鎖之後再次請求不可重入鎖時被阻塞,就是重入鎖死。

相關焦點

  • 原創】Java並發編程系列01|開篇獲獎感言
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫為什麼要學並發編程我曾聽一個從事15年開發工作的技術人員說過3.並發基礎:並發編程中用到的一些基本概念,如:死鎖、飢餓與公平等;線程的創建、運行、調度。4.CAS原子操作:並發編程的基礎與核心CAS的實現原理,以及Java中的CAS原子操作。
  • 「原創」Java並發編程系列02|並發編程三大核心問題
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫寫在前面編寫並發程序是比較困難的,因為並發程序極易出現Bug,這些Bug有都是比較詭異的,很多都是沒辦法追蹤,而且難以復現。
  • 「原創」Java並發編程系列28|Copy-On-Write容器
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫正文前面兩篇講了並發編程中線程安全HashMap:ConcurrentHashMap
  • 「原創」Java並發編程系列06|你不知道的final
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫final在Java中是一個保留的關鍵字,可以修飾變量、方法和類。那麼fianl在並發編程中有什麼作用呢?本文就在對final常見應用總結基礎上,講解final並發編程中的應用。
  • 「原創」Java並發編程系列18|讀寫鎖(下)
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫本文為何適原創並發編程系列第 18 篇,文末有本系列文章匯總。上篇為【原創】Java並發編程系列17 | 讀寫鎖八講(上),沒看過的可以先看看。本文是下篇,從「源碼分析寫鎖的獲取與釋放」開始。7.
  • 「原創」Java並發編程系列14|AQS源碼分析
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫本文為何適原創並發編程系列第 14 篇,文末有本系列文章匯總。AbstractQueuedSynchronizer是Java並發包java.util.concurrent的核心基礎組件,是實現Lock的基礎。
  • 「原創」Java並發編程系列29|ConcurrentLinkedQueue
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫J.U.C 為常用的集合提供了並發安全的版本,前面講解了 map 的並發安全集合 ConcurrentHashMap,List 並發安全集合 CopyOnWriteArrayList,Set 並發安全集合
  • JAVA並發編程:並發問題的根源及主要解決方法
    而在java中,不變性變量即通過final修飾的變量,如String,Long,Double等類型都是Immutability的,它們的內部實現都是基於final關鍵字的。那這又和並發編程有什麼關係呢?其實啊,並發問題很大部分原因就是因為線程切換破壞了原子性,這又導致線程隨意對變量的讀寫破壞了數據的一致性。
  • 「原創」Java並發編程系列33|深入理解線程池(上)
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫並發編程必不可少的線程池,接下來分兩篇文章介紹線程池,本文是第一篇。介紹1.1 使用場景並發編程可以高效利用CPU資源,提升任務執行效率,但是多線程及線程間的切換也伴隨著資源的消耗。當遇到單個任務處理時間比較短,但需要處理的任務數量很大時,線程會頻繁的創建銷毀,大量的時間和資源都會浪費在線程的創建和銷毀上,效率很低。
  • 「原創」Java並發編程系列03|重排序-可見性和有序性問題根源
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫思維導圖寫在前面並發編程的三大問題:原子性、可見性、有序性。從java原始碼到最終實際執行的指令序列,會分別經歷下面三種重排序:編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。指令級並行的重排序。
  • NO.001- 簡說 Java 並發編程史
    這篇文章是Java並發編程思想系列的第一篇,主要從理解Java並發編程歷史的原因和Java並發演進過程兩部分,以極簡地回溯並發編程的歷史,幫助大家從歷史這個角度去了解一門語言一個特性的演進。對歷史理解的越多,思考的越多,未來的方向就會更加堅定。我是誰?從哪來?到哪去?
  • Java並發編程系列20|StampedLock源碼解析
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫本文為何適原創並發編程系列第 20 篇,文末有本系列文章匯總。上一篇介紹了StampedLock存在的意義以及如何使用,按照這個系列的風格大家也應該猜到了,這一篇就是的源碼分析。
  • 「原創」Java並發編程系列36|FutureTask
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫線程池源碼中出現了很多Callable、Future、FutureTask等以前沒介紹過的接口,尤其是線程池提交任務時總是把任務封裝成FutureTask,今天就來為大家解惑:
  • Java的synchronized 能防止指令重排序嗎?
    「二胖」:別說了我就出去試試水,看看現在工作好不好找,順帶出去找找打擊,然後才能好好靜下心來好好學習。「二狗:」 那被打擊的怎麼樣啊?知道自己是什麼樣的水平了吧,壞笑。「二胖」:基礎太差,一面就讓回去等通知了,我要好好學習了,不跟你瞎扯了。「二狗:」 都問了你什麼問題啊,把你打擊成這樣?一起復盤下讓我也好好準備下啊。
  • Alibaba架構師從零開始,一步一步帶你進入並發編程的世界
    並發編程實踐無論如何,開發、測試、調試多線程的程序仍然非常困難:常見的情形總是開發的並發程序看上去可以正常工作,但是在極端情況下就會失敗,就生產環境而言這種情況是指高負載。《JAVA 並發編程實踐》以堅實的理論基礎和翔實的實踐技術,幫助讀者構建可靠的、可伸縮的和可維護的並發應用程式。
  • Java並發編程系列23|循環屏障CyclicBarrier
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫本篇介紹第二個並發工具類CyclicBarrier,CyclicBarrier的字面意思是可循環使用(Cyclic)的屏障(Barrier),分以下部分介紹:
  • 動力節點Java學院2021年Java學習路線圖最新出爐啦
    Java在程式語言排行榜中一直牢牢佔據榜首位置,幾乎所有的大中型網際網路的應用系統在伺服器端開發首選都是Java編程,正因如何吸引這不少年輕人投入該行業,Java雖不想其它程式語言那麼複雜,但是知識體系還是很龐大的,因此想要學好並非容易之事,不少想要跨入Java編程行業的同學們通過網絡搜索各式各樣的學習資料
  • Java並發編程系列21|Condition-Lock的等待通知
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫我們知道 synchronized 鎖通過 Object 類的 wait()和 notify()方法實現線程間的等待通知機制,而比 synchronized 更靈活 Lock 鎖同樣也有實現等待通知機制的方式
  • 「原創」JVM系列03|Java棧—方法是如何調用的?
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫本文是何適 JVM 修仙系列第 3 篇,文末有本系列文章匯總。 e.printStackTrace(); } }}設置參數-Xss128K 執行以上代碼,結果如下:調用深度count=1088java.lang.StackOverflowError
  • 九碼課堂|大學生學編程系列:自學編程需要多久才能找到工作?
    自學編程要學成什麼樣或者要學多長時間才能找到工作?相信很多剛剛學習編程的人都想知道這個問題的答案。但是由於每個人的學習能力以及用功程度不同,所以沒法給出具體的時間。因此,我們接下來主要針對學成什麼樣才能找到工作來進行分析。