云原生集成開發(fā)環(huán)境——TitanIDE
通過網(wǎng)頁在任何地方更安全、更高效地編碼2022-06-22
940
作者:kaiyun開云創(chuàng)新 張磊
何為并發(fā)控制
這篇文章我們總結(jié)一下進(jìn)程內(nèi)的并發(fā)控制問題,主要是在高并發(fā)下的進(jìn)程內(nèi)部并發(fā)控制。
那么,什么是并發(fā)控制呢?
并發(fā)控制這個問題,從概念上講,不難理解,個人覺得并發(fā)控制就是在多線程的環(huán)境下,控制請求或者任務(wù)的執(zhí)行順序,避免產(chǎn)生因為資源競爭導(dǎo)致的數(shù)據(jù)處理問題。
為什么要做多線程的并發(fā)控制?
這一切得從JVM底層的內(nèi)存模型JMM(Java Memory Model)說起,設(shè)計Java的內(nèi)存模型的主要目標(biāo)是定義進(jìn)程內(nèi)各個變量的訪問規(guī)則,也就是說在JVM內(nèi)部,變量值的讀取與存儲的底層細(xì)節(jié)。JMM主要分為主內(nèi)存和工作內(nèi)存兩個部分,根據(jù)周志明寫的《深入理解Java虛擬機(jī)》一書中的描述,線程、工作內(nèi)存和主內(nèi)存之間的關(guān)系如下圖所示:
可以看到,圖中一共有三個區(qū)域,線程執(zhí)行區(qū)、工作內(nèi)存、主內(nèi)存,其中線程執(zhí)行區(qū)和工作內(nèi)存是線程獨(dú)享的,也就是線程隔離的,而主內(nèi)存也就是我們常說的堆里面存儲對象實(shí)例數(shù)據(jù)所占用的內(nèi)存,是線程共享的。JVM定義了8種內(nèi)存間的交互操作,分別如下:
根據(jù)上面java內(nèi)存模型定義中可以看到,java對象的數(shù)據(jù)都是在主內(nèi)存中,每個線程要去使用的時候,首先要通過read-load操作,將主內(nèi)存的數(shù)據(jù)加載到工作內(nèi)存中,成為一個數(shù)據(jù)副本,后面線程執(zhí)行引擎使用的時候,通過use-assign操作從工作內(nèi)存中將變量副本加載出來用于棧上計算,用完后通過store-write操作將工作內(nèi)存中的數(shù)據(jù)回寫到主內(nèi)存中。整個過程如果不做并發(fā)控制,用最基本的兩個線程對同一個主內(nèi)存數(shù)據(jù)操作來舉例說明:
上圖是最基本的多線程并發(fā)修改的示例圖,兩個線程同時對一個變量值a進(jìn)行賦值操作,根據(jù)Java內(nèi)存模型中定義的那幾步操作來看,如果說不加鎖控制的情況下,會發(fā)生如圖中所示的場景,整形變量a初始值等于1,線程A對變量a進(jìn)行加1操作,線程B對變量a進(jìn)行加2操作,按理a最終應(yīng)該為4,但是由于發(fā)生了并發(fā)修改,導(dǎo)致a最終為3,原因很簡單,線程A在工作內(nèi)存中修改了變量a的值,但是還沒有往主內(nèi)存中寫的時候,線程B已經(jīng)從主內(nèi)存中讀取變量a的值進(jìn)行操作了,由于工作內(nèi)存是線程隔離的,因此線程B并不知道線程A修改了變量a的值,導(dǎo)致線程B讀取變量a的值是線程A修改之前的值,這個時候就發(fā)生了多線程的并發(fā)修改問題。因此,為了避免這種情況發(fā)生,我們應(yīng)該對多線程的并發(fā)修改做控制,也就是今天的主題,進(jìn)程內(nèi)的多線并發(fā)控制。
如何做并發(fā)控制
之前的文章里面也聊到過并發(fā)控制,但是講得比較淺,想在這里多聊一下并發(fā)控制相關(guān)的處理。一說到并發(fā)控制,可能很多人第一時間會想到鎖,其實(shí)鎖這個東西,不是個好東西,不得已的情況下,并不推薦用鎖來實(shí)現(xiàn)并發(fā)控制。CPU是一個很昂貴的系統(tǒng)資源,現(xiàn)在一個CPU也就幾十個核,CPU的計算資源很寶貴,線程執(zhí)行的時候,是通過CPU的時間片輪轉(zhuǎn)方式執(zhí)行,如果進(jìn)行線程上下文切換,那么會浪費(fèi)CPU的時鐘,因此一個核也就適合1-2個線程占用執(zhí)行,如果通過鎖的方式來控制并發(fā),那么可能會產(chǎn)生大量的block,導(dǎo)致上下文切換,非常浪費(fèi)CPU的時鐘,所以鎖是最后考慮的用作同步的方式。
進(jìn)程內(nèi)的隊列使用
如果多線程內(nèi)并發(fā)處理的地方比較多,那么看看能否從設(shè)計的角度來規(guī)避這個問題,例如事件驅(qū)動模型中,將多個線程中的請求,通過Disruptor的方式,聚合到一個線程中去處理,這個比較適合SEDA這種線程并發(fā)結(jié)構(gòu),如下圖所示:
這樣可以規(guī)避多線程的并發(fā)處理問題,當(dāng)然并不是說所有多線程并發(fā)控制都適合這么做,這是一種規(guī)避并發(fā)控制的思路,可以參考。
集合的合理使用
高并發(fā)下的集合使用,可能會想到ArrayList、HashMap這類沒有做并發(fā)控制的集合,在高并發(fā)下,要使用Collections的synchronized方法,轉(zhuǎn)換成裝飾過的類來進(jìn)行并發(fā)控制,如果是HashMap的話,采用ConcurrentHashMap來進(jìn)行并發(fā)控制,ConcurrentHashMap采用二次hash的方式來進(jìn)行分段并發(fā)控制,相比table的話,效率更高一點(diǎn),適合寫比較多的環(huán)境。
COW(Copy On Write)
上面講的集合使用,其實(shí)還有一種方式可以考慮,就是COW寫時復(fù)制的方式,這種方式適合讀多寫少的環(huán)境,可以提高并發(fā)性能,guava里面有Lists.newCopyOnWriteArrayList()可以直接使用,map的話需要自己做控制,具體實(shí)現(xiàn)可以百度。
CAS(Compare And Swap)
CAS是一種系統(tǒng)原語,原語屬于操作系統(tǒng)用語范疇,是由若干條指令組成的,用于完成某個功能的一個過程,并且原語的執(zhí)行必須是連續(xù)的,在執(zhí)行過程中不允許被中斷,也就是說CAS是一條CPU的原子指令,不會造成所謂的數(shù)據(jù)不一致問題。CAS在Java中的應(yīng)用,即并發(fā)包中的原子操作類(Atomic系列),從JDK 1.5開始提供了java.util.concurrent.atomic包,在該包中提供了許多基于CAS實(shí)現(xiàn)的原子操作類,使用起來很簡單,具體可以自行百度。
volatile內(nèi)存屏障的使用
可能很多新手沒有見過這個關(guān)鍵字,老程序猿對這個關(guān)鍵字也不熟悉,我也是之前看過很多資料,里面講到過volatile關(guān)鍵字的內(nèi)存屏障功能,但是這個關(guān)鍵字我感覺是java里面最難使用的一個關(guān)鍵字了,Disruptor里面采用volatile來替換鎖的案例非常成功,有興趣的可以看一下這篇文章《disruptor-memory-barrier》。
鎖的使用
直到最后,才是鎖的使用。這個也是大部分程序猿最熟悉的進(jìn)程內(nèi)并發(fā)控制的方式。目前鎖主要有synchronized關(guān)鍵字和concurrent包里面的lock,至于如何選擇synchronized和lock的使用場景,synchronized關(guān)鍵字是由JVM來控制內(nèi)部執(zhí)行的,每個object都有一個monitor,synchronized關(guān)鍵字就是去獲得這個monitor對象,有點(diǎn)類似于操作系統(tǒng)的PV操作,這個鎖是非公平鎖,適合競爭不激烈的情況,競爭激烈的時候性能沒有l(wèi)ock高。Lock是Concurrent包里面提供的,由JDK提供的鎖,有多種實(shí)現(xiàn),這個使用非常靈活,適合復(fù)雜的業(yè)務(wù)場景,但是這個lock一定要在try-finally中關(guān)閉,防止鎖死。所以一些簡單的業(yè)務(wù)場景,可以使用synchronized關(guān)鍵字,復(fù)雜的業(yè)務(wù)場景可以考慮使用lock,具體的使用方式網(wǎng)上資料一大把,這里不細(xì)說了。
總結(jié)
之前一直對高并發(fā)應(yīng)用和性能情有獨(dú)鐘,也做過高并發(fā)產(chǎn)品,總之這條路太深了,涉及到的知識點(diǎn)很多,坑也很多,且行且珍惜。