作者:一字馬胡
鏈接:https://www.jianshu.com/p/5f499f8212e7

Java併發編程中的若干核心技術,向高手進階!(上)

Future

Future代表一種未來某個時刻會發生的事情,在併發環境下使用Future是非常重要的,使用Future的前提是我們可以容許線程執行一段時間來完成這個任務,但是需要在我們提交了任務的時候就返回一個Future,這樣在接下來的時間程序員可以根據實際情況來取消任務或者獲取任務,在多個任務沒有相互依賴關係的時候,使用Future可以實現多線程的併發執行,多個線程可以執行在不同的處理器上,然後在某個時間點來統一獲取結果就可以了。上文中已經提到了FutureTask,FutureTask既是一種Runnable,也是一種Future,並且結合了兩種類型的特性。下面展示了Future提供的一些方法,使用這些方法可以很方便的進行任務控制:

Java併發編程中的若干核心技術,向高手進階!(下)

在java 8中增加了一個新的類CompletableFuture,這是對Future的極大增強,CompletableFuture提供了非常豐富的操作可以來控制我們的任務,並且可以根據多種規則來關聯多個Future。

Fork/Join框架

Fork/Join框架是一種並行框架,它可以將一個較大的任務切分成一些小任務來執行,並且多個線程之間會相互配合,每個線程都會有一個任務隊列,對於某些線程來說它們可能很快完成了自己的任務隊列中的任務,但是其他的線程還沒有完成,那麼這些線程就會去竊取那些還沒有完成任務執行的線程的任務來執行,這成爲“工作竊取”算法,關於Fork/Join中的工作竊取,其實現還是較爲複雜的,如果想對Fork/Join框架有一個大致的認識,可以參考文章Java Fork/Join並行框架。下面展示了Fork/Join框架的工作模式:

Java併發編程中的若干核心技術,向高手進階!(下)

Fork/Join工作模式

可以從上面的圖中看出,一個較大的任務會被切分爲一個小任務,並且小任務還會繼續切分,直到符合我們設定的執行閾值,然後就會執行,執行完成之後會進行join,也就是將小任務的結果組合起來,組裝出我們提交的整個任務的結果,這是一種非常先進的工作模式,非常有借鑑意義。當然,使用Fork/Join框架的前提是我們的任務時可以拆分成小任務來執行的,並且小人物的結果可以組裝出整個大任務的結果,歸併排序是一種可以藉助Fork/Join框架來提供處理速度的算法,下面展示了使用Fork/Join框架來執行歸併排序的代碼,可以試着調整參數來進行性能測試:

Java併發編程中的若干核心技術,向高手進階!(下)

Java併發編程中的若干核心技術,向高手進階!(下)

Java併發編程中的若干核心技術,向高手進階!(下)

Java併發編程中的若干核心技術,向高手進階!(下)

Java併發編程中的若干核心技術,向高手進階!(下)

在jdk中,使用Fork/Join框架的一個典型案例是Streams API,Streams API試圖簡化我們的併發編程,可以使用很簡單的流式API來處理我們的數據流,在我們無感知的狀態下,其實Streams的實現上藉助了Fork/Join框架來實現了併發計算,所以強烈建議使用Streams API來處理我們的流式數據,這樣可以充分的利用機器的多核心資源,來提高數據處理的速度。關於java的Streams API的分析總結可以參考文章Java Streams API,關於Stream API是如何使用Fork/Join框架來實現並行計算的內容可以參考文章Java Stream的並行實現。鑑於Fork/Join框架的先進思想,理解並且學會使用Fork/Join框架來處理我們的實際問題是非常有必要的。

Java volatile關鍵字

volatile解決的問題是多個線程的內存可見性問題,在併發環境下,每個線程都會有自己的工作空間,每個線程只能訪問各自的工作空間,而一些共享變量會被加載到每個線程的工作空間中,所以這裏面就有一個問題,內存中的數據什麼時候被加載到線程的工作緩存中,而線程工作空間中的內容什麼時候會回寫到內存中去。這兩個步驟處理不當就會造成內存可加性問題,也就是數據的不一致,比如某個共享變量被線程A修改了,但是沒有回寫到內存中去,而線程B在加載了內存中的數據之後讀取到的共享變量是髒數據,正確的做法應該是線程A的修改應該對線程B是可見的,更爲通用一些,就是在併發環境下共享變量對多個線程是一致的。

對於內存可見性的一點補充是,之所以會造成多個線程看到的共享變量的值不一樣,是因爲線程在佔用CPU時間的時候,cpu爲了提高處理速度不會直接和內存交互,而是會先將內存中的共享內容讀取到內部緩存中(L1,L2),然後cpu在處理的過程中就只會和內部緩存交互,在多核心的機器中這樣的處理方式就會造成內存可見性問題。

volatile可以解決併發環境下的內存可見性問題,只需要在共享變量前面加上volatile關鍵字就可以解決,但是需要說明的是,volatile僅僅是解決內存可見性問題,對於像i++這樣的問題還是需要使用其他的方式來保證線程安全。使用volatile解決內存可見性問題的原理是,如果對被volatile修飾的共享變量執行寫操作的話,JVM就會向cpu發送一條Lock前綴的指令,cpu將會這個變量所在的緩存行(緩存中可以分配的最小緩存單位)寫回到內存中去。但是在多處理器的情況下,將某個cpu上的緩存行寫回到系統內存之後,其他cpu上該變量的緩存還是舊的,這樣再進行後面的操作的時候就會出現問題,所以爲了使得所有線程看到的內容都是一致的,就需要實現緩存一致性協議,cpu將會通過監控總線上傳遞過來的數據來判斷自己的緩存是否過期,如果過期,就需要使得緩存失效,如果cpu再來訪問該緩存的時候,就會發現緩存失效了,這時候就會重新從內存加載緩存。

總結一下,volatile的實現原則有兩條:

1、JVM的Lock前綴的指令將使得cpu緩存寫回到系統內存中去

2、爲了保證緩存一致性原則,在多cpu的情景下,一個cpu的緩存回寫內存會導致其他的cpu上的緩存都失效,再次訪問會重新從系統內存加載新的緩存內容。

原子操作CAS

原子操作表達的意思是要麼一個操作成功,要麼失敗,中間過程不會被其他的線程中斷,這一點對於併發編程來說非常重要,在java中使用了大量的CAS來做併發編程,包括jdk的ConcurrentHsahMap的實現,還有AtomicXXX的實現等其他一些併發工具的實現都使用了CAS這種技術,CAS包括兩部分,也就是Compare and swap,首先是比較,然後再交互,這樣做的原因是,在併發環境下,可能不止一個線程想要來改變某個共享變量的值,那麼在進行操作之前使用一個比較,而這個比較的值是當前線程認爲(知道)該共享變量最新的值,但是可能其他線程已經改變了這個值,那麼此時CAS操作就會失敗,只有在共享變量的值等於線程提供的用於比較的值的時候纔會進行原子改變操作。

java中有一個類是專門用於提供CAS操作支持的,那就是Unsafe類,但是我們不能直接使用Unsafe類,因爲Unsafe類提供的一些底層的操作,需要非常專業的人才能使用好,並且Unsafe類可能會造成一些安全問題,所以不建議直接使用Unsafe類,但是如果想使用Unsafe類的話還是有方法的,那就是通過反射來獲取Unsafe實例,類似於下面的代碼:

Java併發編程中的若干核心技術,向高手進階!(下)

Java併發編程中的若干核心技術,向高手進階!(下)

如果想要了解Unsafe類到底提供了哪些較爲底層的操作,可以直接參考Unsafe的源碼。CAS操作解決了原子操作問題,只要進行操作,CAS就會保證操作會成功,不會被中斷,這是一種非常好非常強大的特性,下面就java 8中的ConcurrentHashMap的size實現來談談CAS操作在併發環境下的使用案例。

在java 7中,ConcurrentHashMap的實現是基於分段鎖協議的實現,本質上還是使用了鎖,只是基於一種考慮,就是多個線程訪問哈希桶具有隨機性,基於這種考慮來將數據存儲在不同的哈希段上面,然後每一個段配有一把鎖,在需要寫某個段的時候需要加鎖,而在這個時候,其他訪問其他段的線程是不需要阻塞的,但是對於該段的線程訪問就需要等待,直到這個加鎖的線程釋放了鎖,其他線程才能進行訪問。在java 8中,ConcurrentHashMap的實現拋棄了這種複雜的架構設計,但是繼承了這種分散線程競爭壓力的思想,其實就提高系統的併發度這一維度來說,分散競爭壓力是一種最爲直接明瞭的解決方案,而java 8在實現ConcurrentHashMap的時候大量使用了CAS操作,減少了使用鎖的頻度來提高系統的響應度,其實使用鎖和使用CAS來做併發在複雜度上不是一個數量級的,使用鎖在很大程度上假設了多個線程的排斥性,並且使用鎖會將線程阻塞等待,也就是說使用鎖來做線程同步的時候,線程的狀態是會改變的,但是使用CAS是不會改變線程的狀態的(不太嚴謹的說),所以使用CAS比起使用synchronized或者使用Lcok來說更爲輕量級。

現在就ConcurrentHashMap的size方法來分析一下如何將線程競爭的壓力分散出去。在java 7的實現上,在調用size方法之後,ConcurrentHashMap會進行兩次對哈希桶中的記錄累加的操作,這兩次累加的操作是不加鎖的,然後判斷兩次結果是否一致,如果一致就說明目前的系統是讀多寫少的場景,並且可能目前沒有線程競爭,所以直接返回就可以,這就避免了使用鎖,但是如果兩次累加結果不一致,那就說明此時可能寫的線程較多,或者線程競爭較爲嚴重,那麼此時ConcurrentHashMap就會進行一個重量級的操作,對所有段進行加鎖,然後對每一個段進行記錄計數,然後求得最終的結果返回。在最有情況下,size方法需要做兩次累加計數,最壞情況需要三次,並且會涉及全局加鎖這種重量級的加鎖操作,性能肯定是不高的。而在java 8的實現上,ConcurrentHashMap的size方法實際上是與ConcurrentHashMap是解耦的,size方法更像是接入了一個額外的併發計數系統,在進行size方法調用的時候是不會影響數據的存取的,這其實是一種非常先進的思想,就是一個系統模塊化,然後模塊可以進行更新,系統解耦,比如java 8中接入了併發計數組件Striped64來作爲size方法的支撐,可能未來出現了比Striped64更爲高效的算法來計數,那麼只需要將Striped64模塊換成新的模塊就可以了,對原來的核心操作是不影響的,這種模塊化系統設定的思想應該在我們的項目中具體實踐。

上面說到java 8在進行size方法的設計上引入了Striped64這種併發計數組件,這種組件的計數思想其實也是分散競爭,Striped64的實現上使用了volatile和CAS,在Striped64的實現中是看不到鎖的使用的,但是Striped64確實是一種高效的適用於併發環境下的計數組件,它會基於請求計數的線程,Striped64的計數會根據兩部分的內容來得到最後的結果,類似於java 7中ConcurrentHashMap的size方法的實現,在Striped64的實現上也是借鑑了這種思想的,Striped64會首先嚐試將某個線程的計數請求累加到一個base共享變量上,如果成功了,那麼說明目前的競爭不是很激烈,也就沒必要後面的操作了,但是很多情況下,併發環境下的線程競爭是很激烈的,所以嘗試累加到base上的計數請求很大概率是會失敗的,那麼Striped64會維護一個Cell數組,每個Cell是一個計數組件,Striped64會爲每個請求計數的線程計算一個哈希值,然後哈希到Cell數組中的某個位置上,然後這個線程的計數就會累加到該Cell上面去。

併發同步框架AQS

AQS是java中實現Lock的基礎,也是實現線程同步的基礎,AQS提供了鎖的語義,並且支持獨佔模式和共享模式,對應於悲觀鎖和樂觀鎖,獨佔模式的含義是說同一時刻只能有一個線程獲取鎖,而其他試圖獲取鎖的線程都需要阻塞等待,而共享鎖的含義是說可以有多個線程獲得鎖,兩種模式在不同的場景下使用。

而鎖在併發編程中的地位不言而喻,多個線程的同步很多時候是需要鎖來做同步的,比如對於某些資源,我們希望可以有多個線程獲得鎖來讀取,但是隻允許有一個線程獲得鎖來執行寫操作,這種鎖稱爲讀寫鎖,它的實現上結合了AQS的共享模式和獨佔模式,共享模式對應於可以使得多個線程獲得鎖來進行讀操作,獨佔模式對應於只允許有一個線程獲得鎖來進行寫操作。關於java中多個Lock的實現細節,以及是如何藉助AQS來實現其具體邏輯的內容,可以參考文章ava可重入鎖詳解。該文章詳細講述了多個Lock接口的實現類,以及他們是如何藉助AQS來實現的具體細節。

某些時候,我們需要定製我們自己的線程同步策略,個性化的線程同步藉助AQS可以很容易的實現,比如我們的需求是允許限定個數的線程獲得鎖來進行一些操作,想要實現這樣的語義,只需要實現一個類,繼承AQS,然後重寫方法下面兩個方法:

Java併發編程中的若干核心技術,向高手進階!(下)

還需要提到的一點是,鎖分爲公平鎖和非公平鎖,java中大多數時候會使用隊列來實現公平鎖,而使用棧來實現非公平鎖,當然這是基於隊列和棧這兩種數據結構的特點來實現的,直觀的來說,使用隊列的FIFO的特性就可以實現類似排隊的效果,也就保證了公平性,而棧是一個後進先出的數據結構,它的這種結構造成的結果就是,最新進入的線程可能比那些等待過一段時間的線程更早的獲得鎖,更爲具體的內容可以參考上面的文章進行了解。

synchronized(同步鎖)

相對於volatile,synchronized就顯得比較重量級了。

首先,我們應該知道,在java中,所有的對象都可以作爲鎖。可以分爲下面三種情況:

1、普通方法同步,鎖是當前對象

2、靜態方法同步,鎖是當前類的Class對象

3、普通塊同步,鎖是synchronize裏面配置的對象

當一個線程試圖訪問同步代碼時,必須要先獲得鎖,退出或者拋出異常時必須要釋放鎖。

JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,可以使用monitorenter和monitorexit指令實現。monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit指令則插入到方法結束和異常處,JVM保證每個monitorenter都有一個monitorexit閾值相對應。線程執行到monitorenter的時候,會嘗試獲得對象所對應的monitor的鎖,然後才能獲得訪問權限,synchronize使用的鎖保存在Java對象頭中。

併發隊列(阻塞隊列,同步隊列)

併發隊列,也就是可以在併發環境下使用的隊列,爲什麼一般的隊列不能再併發環境下使用呢?因爲在併發環境下,可能會有多個線程同時來訪問一個隊列,這個時候因爲上下文切換的原因可能會造成數據不一致的情況,併發隊列解決了這個問題,並且java中的併發隊列的使用時非常廣泛的,比如在java的線程池的實現上使用了多種不同特性的阻塞隊列來做任務隊列,對於阻塞隊列來說,它要解決的首要的兩個問題是:

  1. 多線程環境支持,多個線程可以安全的訪問隊列
  2. 支持生產和消費等待,多個線程之間互相配合,當隊列爲空的時候,消費線程會阻塞等待隊列不爲空;當隊列滿了的時候,生產線程就會阻塞直到隊列不滿。

java中提供了豐富的併發隊列實現,下面展示了這些併發隊列的概覽:

Java併發編程中的若干核心技術,向高手進階!(下)

java併發隊列概覽

根據上面的圖可以將java中實現的併發隊列分爲幾類:

  1. 一般的阻塞隊列
  2. 支持雙端存取的併發隊列
  3. 支持延時獲取數據的延時阻塞隊列
  4. 支持優先級的阻塞隊列

這些隊列的區別就在於從隊列中存取數據時的具體表現,比如對於延時隊列來說,獲取數據的線程可能被阻塞等待一段時間,也可能立刻返回,對於優先級阻塞隊列,獲取的數據是根據一定的優先級取到的。下面展示了一些隊列操作的具體表現:

Java併發編程中的若干核心技術,向高手進階!(下)

Throws Exception 類型的插入和取出在不能立即被執行的時候就會拋出異常。

Special Value 類型的插入和取出在不能被立即執行的情況下會返回一個特殊的值(true 或者 false)

Blocked 類型的插入和取出操作在不能被立即執行的時候會阻塞線程直到可以操作的時候會被其他線程喚醒

Timed out 類型的插入和取出操作在不能立即執行的時候會被阻塞一定的時候,如果在指定的時間內沒有被執行,那麼會返回一個特殊值

無鎖併發設計須知

在併發系統設計的時候,爲了數據安全等原因需要對共享數據進行加鎖訪問,但是使用鎖必然會有開銷,在並併發系統較爲繁忙的時候,這個開銷就變得很可觀了,爲了規避這個問題,無鎖併發系統設計成爲一種趨勢。

所謂無鎖併發,即在進行併發系統設計的時候不使用鎖,而使用一些其他的技術來達到鎖的效果,在java中,CAS技術是無鎖併發系統設計的基礎技術,結合CAS和spin即可實現無鎖併發系統的設計,但是,因爲涉及spin,也就是自旋,所以需要特別注意CPU 100%的問題,以及因爲使用無鎖,如果沒有做好線程競爭管理,就會出現線程飢餓的問題,下面是在使用CAS進行無鎖併發系統設計時需要注意的一些問題:

  • (1)、使用隊列來管理競爭線程,解決線程飢餓的問題
  • (2)、在多次CAS無果之後,請讓線程休息一會,讓出CPU使得一些其他的事情得到處理
  • (3)、避免出現CPU 100%的問題

如果沒有足夠的CAS經驗,不推薦使用CAS來進行無鎖併發設計,使用鎖可以方便快速的解決併發問題,並且性能問題逐漸得到解決,所以,如果對性能不是要求特別嚴苛,使用鎖即可,當然,鎖的使用也需要合理,否則性能依然會成爲一個很大的問題。

總結

本文總結了java併發編程中的若干核心技術,並且對每一個核心技術都做了一些分析,並給出了參考鏈接,可以在參考鏈接中查找到更爲具體深入的分析總結內容。java併發編程需要解決一些問題,比如線程間同步問題,如何保證數據可見性問題,以及如何高效的協調多個線程工作等內容,本文在這些維度上都有所設計,本文作爲對閱讀java.util.Concurrent包的源碼閱讀的一個總結,同時本文也作爲一個起點,一個開始更高層次分析總結的起點,之前的分析都是基於jdk源碼來進行的,並且某些細節的內容還沒有完全搞明白,其實在閱讀了一些源碼之後就會發現,如果想要深入分析某個方面的內容,就需要一些底層的知識,否則很難完整的分析總結出來,但是這種不徹底的分析又是很有必要的,至少可以對這些內容有一些大概的瞭解,並且知道自己的不足,以及未來需要了解的底層內容。對於java併發包的分析研究,深入到底層就是對jvm如何管理內容,如何管理線程的分析,在深入下去,就是操作系統對內存的管理,對線程的管理等內容,從操作系統再深入下去,就是去理解cpu的指令系統,學習磁盤知識等內容,當然,知識的關聯是無止境的,學習也是無止境的,目前來說,首要解決的問題是可以熟練的使用java提供的併發包內容來進行併發編程,在業務上提高併發處理能力,在出現問題的時候可以很快找到問題並且解決問題,在達到這個要求之後,可以去了解一些jvm層次的內容,比如jvm的內存模型,以及線程的實現,並且可以與學習操作系統的相關內容並行進行。

34張架構史上最全技術知識圖譜

程序員專屬手機壁紙來了。。。

相关文章