作者:林振華
公衆號:編程原理

1.問題

  • 什麼是線程的交互方式?
  • 如何區分線程的同步/異步,阻塞/非阻塞?
  • 什麼是線程安全,如何做到線程安全?
  • 如何區分併發模型?
  • 何謂響應式編程?
  • 操作系統如何調度多線程?

2.關鍵詞

同步,異步,阻塞,非阻塞,並行,併發,臨界區,競爭條件,指令重排,鎖,amdahl,gustafson

3.全文概要

上一篇我們介紹分佈式架構知識體系,由於單機的性能上限原因我們纔不得不發展分佈式技術。那麼話說回來,如果單機的性能沒能最大限度的榨取出來,就盲目的就建設分佈式系統,那就有點本末倒置了。而且上一篇我們給的忠告是如果有可能的話,不要用分佈式,意思是說如果單機性能滿足的話,就不要折騰複雜的分佈式架構。

如果說分佈式架構是宏觀上的性能擴展,那麼高併發則是微觀上的性能調優,這也是上一篇性能部分拆出來的大專題。本文將從線程的基礎理論談起,逐步探究線程的內存模型,線程的交互,線程工具和併發模型的發展。掃除關於併發編程的諸多模糊概念,從新構建併發編程的層次結構。

4.基礎理論

4.1基本概念

開始學習併發編程前,我們需要熟悉一些理論概念。既然我們要研究的是併發編程,那首先應該對併發這個概念有所理解纔是,而說到併發我們肯定要要討論一些並行。

  • 併發:一個處理器同時處理多個任務
  • 並行:多個處理器或者是多核的處理器同時處理多個不同的任務
高併發編程知識體系(上)

然後我們需要再瞭解一下同步和異步的區別:

  • 同步:執行某個操作開始後就一直等着按部就班的直到操作結束
  • 異步:執行某個操作後立即離開,後面有響應的話再來通知執行者

接着我們再瞭解一個重要的概念:

臨界區:公共資源或者共享數據

由於共享數據的出現,必然會導致競爭,所以我們需要再瞭解一下:

阻塞:某個操作需要的共享資源被佔用了,只能等待,稱爲阻塞

高併發編程知識體系(上)

非阻塞:某個操作需要的共享資源被佔用了,不等待立即返回,並攜帶錯誤信息回去,期待重試

高併發編程知識體系(上)

如果兩個操作都在等待某個共享資源而且都互不退讓就會造成死鎖:

  • 死鎖:參考著名的哲學家吃飯問題
  • 飢餓:飢餓的哲學家等不齊筷子吃飯
  • 活鎖:相互謙讓而導致阻塞無法進入下一步操作,跟死鎖相反,死鎖是相互競爭而導致的阻塞

4.2併發級別

理想情況下我們希望所有線程都一起並行飛起來。但是CPU數量有限,線程源源不斷,總得有個先來後到,不同場景需要的併發需求也不一樣,比如秒殺系統我們需要很高的併發程度,但是對於一些下載服務,我們需要的是更快的響應,併發反而是其次的。所以我們也定義了併發的級別,來應對不同的需求場景。

  • 阻塞:阻塞是指一個線程進入臨界區後,其它線程就必須在臨界區外等待,待進去的線程執行完任務離開臨界區後,其它線程才能再進去。
  • 無飢餓:線程排隊先來後到,不管優先級大小,先來先執行,就不會產生飢餓等待資源,也即公平鎖;相反非公平鎖則是根據優先級來執行,有可能排在前面的低優先級線程被後面的高優先級線程插隊,就形成飢餓
  • 無障礙:共享資源不加鎖,每個線程都可以自有讀寫,單監測到被其他線程修改過則回滾操作,重試直到單獨操作成功;風險就是如果多個線程發現彼此修改了,所有線程都需要回滾,就會導致死循環的回滾中,造成死鎖
  • 無鎖:無鎖是無障礙的加強版,無鎖級別保證至少有一個線程在有限操作步驟內成功退出,不管是否修改成功,這樣保證了多個線程回滾不至於導致死循環
  • 無等待:無等待是無鎖的升級版,併發編程的最高境界,無鎖只保證有線程能成功退出,但存在低級別的線程一直處於飢餓狀態,無等待則要求所有線程必須在有限步驟內完成退出,讓低級別的線程有機會執行,從而保證所有線程都能運行,提高併發度。

4.3量化模型

首先,多線程不意味着併發,但併發肯定是多線程或者多進程。我們知道多線程存在的優勢是能夠更好的利用資源,有更快的請求響應。但是我們也深知一旦進入多線程,附帶而來的是更高的編碼複雜度,線程設計不當反而會帶來更高的切換成本和資源開銷。但是總體上我們肯定知道利大於弊,這不是廢話嗎,不然誰還願意去搞多線程併發程序,但是如何衡量多線程帶來的效率提升呢,我們需要藉助兩個定律來衡量。

Amdahl

S=1/(1-a+a/n)

其中,a爲並行計算部分所佔比例,n爲並行處理結點個數。這樣,當1-a=0時,(即沒有串行,只有並行)最大加速比s=n;當a=0時(即只有串行,沒有並行),最小加速比s=1;當n→∞時,極限加速比s→ 1/(1-a),這也就是加速比的上限。

Gustafson

系統優化某部件所獲得的系統性能的改善程度,取決於該部件被使用的頻率,或所佔總執行時間的比例。

兩面列舉了這兩個定律來衡量系統改善後提升效率的量化指標,具體的應用我們在下文的線程調優會再詳細介紹。

5.內存模型

宏觀上分佈式系統需要解決的首要問題是數據一致性,同樣,微觀上併發編程要解決的首要問題也是數據一致性。貌似我們搞了這麼多年的鬥爭都是在公關一致性這個世界性難題。既然併發編程要從微觀開始,那麼我們肯定要對CPU和內存的工作機理有所瞭解,尤其是數據在CPU和內存直接的傳輸機制。

5.1整體原則

探究內存模型之前我們要拋出三個概念:

原子性

在32位的系統中,對於4個字節32位的Integer的操作對應的JVM指令集映射到彙編指令爲一個原子操作,所以對Integer類型的數據操作是原子性,但是Long類型爲8個字節64位,32位系統要分爲兩條指令來操作,所以不是原子操作。

對於32位操作系統來說,單次次操作能處理的最長長度爲32bit,而long類型8字節64bit,所以對long的讀寫都要兩條指令才能完成(即每次讀寫64bit中的32bit)

可見性

線程修改變量對其他線程即時可見

有序性

串行指令順序唯一,並行線程直接指令可能出現不一致,也即是指令被重排了

而指令重排也是有一定原則(摘自《深入理解Java虛擬機第12章》):

  • 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作;
  • 鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作;
  • volatile變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作;
  • 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;
  • 線程啓動規則:Thread對象的start()方法先行發生於此線程的每個一個動作;
  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;
  • 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;
  • 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始;

5.2邏輯內存

我們談的邏輯內存也即是JVM的內存格局。JVM將操作系統提供的物理內存和CPU緩存在邏輯分爲堆,棧,方法區,和程序計數器。在《從宏觀微觀角度淺析JVM虛擬機》 一文我們詳細介紹了JVM的內存模型分佈,併發編程我們主要關注的是堆棧的分配,因爲線程都是寄生在棧裏面的內存段,把棧裏面的方法邏輯讀取到CPU進行運算。

高併發編程知識體系(上)

5.3物理內存

而實際的物理內存包含了主存和CPU的各級緩存還有寄存器,而爲了計算效率,CPU往往回就近從緩存裏面讀取數據。在併發的情況下就會造成多個線程之間對共享數據的錯誤使用。

高併發編程知識體系(上)

5.4內存映射


高併發編程知識體系(上)


由於可能發生對象的變量同時出現在主存和CPU緩存中,就可能導致瞭如下問題:

  • 線程修改的變量對外可見
  • 讀寫共享變量時出現競爭資源

由於線程內的變量對棧外是不可見的,但是成員變量等共享資源是競爭條件,所有線程可見,就會出現如下當一個線程從主存拿了一個變量1修改後變成2存放在CPU緩存,還沒來得及同步回主存時,另外一個線程又直接從主存讀取變量爲1,這樣就出現了髒讀。

高併發編程知識體系(上)

現在我們弄清楚了線程同步過程數據不一致的原因,接下來要解決的目標就是如何避免這種情況的發生,經過大量的探索和實踐,我們從概念上不斷的革新比如併發模型的流水線化和無狀態函數式化,而且也提供了大量的實用工具。接下來我們從無到有,先了解最簡單的單個線程的一些特點,弄清楚一個線程有多少能耐後,才能深刻認識多個線程一起打交道會出現什麼幺蛾子。

6.線程單元

6.1狀態

我們知道應用啓動體現的就是靜態指令加載進內存,進而進入CPU運算,操作系統在內存開闢了一段棧內存用來存放指令和變量值,從而形成了進程。

而其實我們的JVM也就是一個進程而且,而線程是進程的最小單位,也就是說進程是由很多個線程組成的。而由於進程的上下文關聯的變量,引用,計數器等現場數據佔用了打段的內存空間,所以頻繁切換進程需要整理一大段內存空間來保存未執行完的進程現場,等下次輪到CPU時間片再恢復現場進行運算。

這樣既耗費時間又浪費空間,所以我們纔要研究多線程。畢竟由於線程乾的活畢竟少,工作現場數據畢竟少,所以切換起來比較快而且暫用少量空間。而線程切換直接也需要遵守一定的法則,不然到時候把工作現場破壞了就無法恢復工作了。

線程狀態

我們先來研究線程的生命週期,看看Thread類裏面對線程狀態的定義就知道

高併發編程知識體系(上)

高併發編程知識體系(上)

生命週期

線程的狀態:NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED。註釋也解釋得很清楚各個狀態的作用,而各個狀態的轉換也有一定的規則需要遵循的。

6.2動作

介紹完線程的狀態和生命週期,接下來我瞭解的線程具備哪些常用的操作。首先線程也是一個普通的對象Thread,所有的線程都是Thread或者其子類的對象。那麼這個內存對象被創建出來後就會放在JVM的堆內存空間,當我們執行start()方法的時候,對象的方法體在棧空間分配好對應的棧幀來往執行引擎輸送指令(也即是方法體翻譯成JVM的指令集)。

線程操作

1、新建線程:new Thread(),新建一個線程對象,內存爲線程在棧上分配好內存空間

2、啓動線程:start(),告訴系統系統準備就緒,只要資源允許隨時可以執行我棧裏面的指令了

3、執行線程:run(),分配了CPU等計算資源,正在執行棧裏面的指令集

4、停止線程(過時):stop(),把CPU和內存資源回收,線程消亡,由於太過粗暴,已經被標記爲過時

5、線程中斷:

  • interrupt(),中斷是對線程打上了中斷標籤,可供run()裏面的方法體接收中斷信號,至於線程要不要中斷,全靠業務邏輯設計,而不是簡單粗暴的把線程直接停掉
  • isInterrupt(),主要是run()方法體來判斷當前線程是否被置爲中斷
  • interrupted(),靜態方法,也是用戶判斷線程是否被置爲中斷狀態,同時判斷完將線程中斷狀態復位

6、線程休眠:sleep(),靜態方法,線程休眠指定時間段,此間讓出CPU資源給其他線程,但是線程依然持有對象鎖,其他線程無法進入同步塊,休眠完成後也未必立刻執行,需要等到資源允許才能執行

7、線程等待(對象方法):wait(),是Object的方法,也即是對象的內置方法,在同步塊中線程執行到該方法時,也即讓出了該對象的鎖,所以無法繼續執行

8、線程通知(對象方法):notify(),notifyAll(),此時該對象持有一個或者多個線程的wait,調用notify()隨機的讓一個線程恢復對象的鎖,調用notifyAll()則讓所有線程恢復對象鎖

9、線程掛起(過時):suspend(),線程掛起並沒有釋放資源,而是隻能等到resume()才能繼續執行

10、線程恢復(過時):resume(),由於指令重排可能導致resume()先於suspend()執行,導致線程永遠掛起,所以該方法被標爲過時

11、線程加入:join(),在一個線程調用另外一個線程的join()方法表明當前線程阻塞知道被調用線程執行結束再進行,也即是被調用線程織入進來

12、線程讓步:yield(),暫停當前線程進而執行別的線程,當前線程等待下一輪資源允許再進行,防止該線程一直霸佔資源,而其他線程餓死

13、線程等待:park(),基於線程對象的操作,較對象鎖更爲精準

14、線程恢復:unpark(Thread thread),對應park()解鎖,爲不可重入鎖

線程分組

爲了管理線程,於是有了線程組的概念,業務上把類似的線程放在一個ThreadGroup裏面統一管理。線程組表示一組線程,此外,線程組還可以包括其他線程組。線程組形成一個樹,其中除了初始線程組以外的每個線程組都有一個父線程。線程被允許訪問它自己的線程組信息,但不能訪問線程組的父線程組或任何其他線程組的信息。

守護線程

通常情況下,線程運行到最後一條指令後則完成生命週期,結束線程,然後系統回收資源。或者單遇到異常或者return提前返回,但是如果我們想讓線程常駐內存的話,比如一些監控類線程,需要24小時值班的,於是我們又創造了守護線程的概念。

setDaemon()傳入true則會把線程一直保持在內存裏面,除非JVM宕機否則不會退出。

線程優先級

線程優先級其實只是對線程打的一個標誌,但並不意味這高優先級的一定比低優先級的先執行,具體還要看操作系統的資源調度情況。通常線程優先級爲5,邊界爲[1,10]。

高併發編程知識體系(上)

本節介紹了線程單元的轉態切換和常用的一些操作方法。如果只是單線程的話,其他都沒必要研究這些,重頭戲在於多線程直接的競爭配合操作,下一節則重點介紹多個線程的交互需要關注哪些問題。

7.線程交互

其實上一節介紹的線程狀態切換和線程操作都是爲線程交互做準備的。不然如果只是單線程完全沒必要搞什麼通知,恢復,讓步之類的操作了。

7.1交互方式

線程交互也就是線程直接的通信,最直接的辦法就是線程直接直接通信傳值,而間接方式則是通過共享變量來達到彼此的交互。

  • 等待:釋放對象鎖,允許其他線程進入同步塊
  • 通知:重新獲取對象鎖,繼續執行
  • 中斷:狀態交互,通知其他線程進入中斷
  • 織入:合併線程,多個線程合併爲一個

7.2線程安全

我們最關注的還是通過共享變量來達到交互的方式。線程如果都各自幹活互不搭理的話自然相安無事,但多數情況下線程直接需要打交道,而且需要分享共享資源,那麼這個時候最核心的就是線程安全了。

什麼是線程安全?

當多個線程訪問同一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替運行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行爲都可以獲取正確的結果,那這個對象是線程安全的。(摘自《深入Java虛擬機》)

如何保證線程安全?

我們最早接觸線程安全可能是JDK提供的一些號稱線程安全的容器,比如Vetor較ArrayList是線程安全,HashTable較HashMap是線程安全?其實線程安全類並不代表也不等同線程安全的程序,而線程不安全的類同樣可以完成線程安全的程序。我們關注的也就是寫出線程安全的程序,那麼如何寫出線程安全的代碼呢?下面列舉了線程安全的主要設計技術:

無狀態

這個有點函數式編程的味道,下文併發模式會介紹到,總之就是線程只有入參和局部變量,如果變量是引用的話,確保變量的創建和調用生命週期都發生在線程棧內,就可以確保線程安全。

無共享狀態

完全要求線程無狀態比較難實現,必要的狀態是無法避免的,那麼我們就必須維護不同線程之間的不同狀態,這可是個麻煩事。幸好我們有ThreadLocal這個神器,該對象跟當前線程綁定,而且只對當前線程可見,完美解決了無共享狀態的問題。

不可變狀態

最後實在沒辦法避免狀態共享,在線程之間共享狀態,最怕的就是無法確保能維護好正確的讀寫順序,而且多線程確實也無法正確維護好這個共享變量。那麼我們索性粗暴點,把共享的狀態定位不可變,比如價格final修飾一下,這樣就達到安全狀態共享。

消息傳遞

一個線程通常也不是所有步驟都需要共享狀態,而是部分環節才需要的,那麼我們把共享狀態的代碼拆開,無共享狀態的那部分自然不用關心,而共享狀態的小段代碼,則通過加入消息組件來傳遞狀態。這個設計到併發模式的流水線編程模式,下文併發模式會重點介紹。

線程安全容器

JUC裏面提供大量的併發容器,涉及到線程交互的時候,使用安全容器可以避免大部分的錯誤,而且大大降低了代碼的複雜度。

  • 通過synchronized給方法加上內置鎖來實現線程安全的類如Vector,HashTable,StringBuffer
  • AtomicXXX如AtomicInteger
  • ConcurrentXXX如ConcurrentHashMap
  • BlockingQueue/BlockingDeque
  • CopyOnWriteArrayList/CopyOnWriteArraySet
  • ThreadPoolExecutor

synchronized同步

該關鍵字確保代碼塊同一時間只被一個線程執行,在這個前提下再設計符合線程安全的邏輯

其作用域爲

  • 對象:對象加鎖,進入同步代碼塊之前獲取對象鎖
  • 實例方法:對象加鎖,執行實例方法前獲取對象實例鎖
  • 類方法:類加鎖,執行類方法前獲取類鎖

volatile約束

volatile確保每次操作都能強制同步CPU緩存和主存直接的變量。而且在編譯期間能阻止指令重排。讀寫併發情況下volatile也不能確保線程安全,上文解析內存模型的時候有提到過。

這節我們論述了編寫線程安全程序的指導思想,其中我們提到了JDK提供的JUC工具包,下一節將重點介紹併發編程常用的趁手工具。

擴展閱讀

分佈式架構知識體系

3 年 Java 應該具備的技能體系

Java架構體系學習路線圖,第6點尤爲重要!

架構師必備,帶你弄清混亂的JAVA日誌體系

Java高併發秒殺API之高併發優化

並行化:你的高併發大殺器

相关文章