淺談java中的併發控制

frapples發表於2019-01-19

併發指在巨集觀上的同一時間內同時執行多個任務。為了滿足這一需求,現代的作業系統都抽象出 執行緒 的概念,供上層應用使用。

這篇博文不打算詳細展開分析,而是對java併發中的概念和工具做一個梳理。
沿著併發模型、併發要解決的問題、基本工具、衍生工具這一思路展開。

<!– more –>

執行緒

首先執行緒是什麼?執行緒是由OS抽象並實現的,我們知道OS的職責是管理併合理分配硬體資源,那麼OS為了更好的管理、分配CPU資源,同時也為了滿足同時執行任務這一需求,設計了執行緒這一概念。

雖然java程式執行在JVM虛擬機器上,但是java的執行緒仍然是對作業系統原生執行緒的封裝,同時,jvm對執行緒實現時也將jvm的執行棧設計成執行緒私有記憶體,因此,java執行緒和原生執行緒在理解上實際上沒太大區別。

執行緒的五種狀態:

graph LR
新建 --> 就緒;
就緒 --> 執行;
執行 --> 就緒;
執行 --> 阻塞;
阻塞 --> 就緒;
執行 --> 死亡;

先來看上面的就緒狀態和執行狀態。我們知道執行緒雖然巨集觀上是同時執行的,但是微觀上使用如時間片輪轉演算法使得執行緒依次執行。那麼,同一時間只有一個執行緒執行,其它需要執行的執行緒處於 就緒佇列 中,等待自己被排程到。

而如果執行緒想要暫時放棄在CPU上執行的權利,就會阻塞自己。這時對應著阻塞狀態,同時執行緒會從就緒佇列中移除,進入等待佇列。
很顯然,阻塞執行緒被喚醒肯定是進入就緒佇列等待排程,而不可能是直接分配到CPU上執行。

線上程同步時,執行緒可能由於以下情況被阻塞:

  1. 同步阻塞。就是被鎖阻塞。
  2. 等待阻塞。被條件變數阻塞。
  3. 其它。呼叫sleep(), join()或等待IO操作時的阻塞。

併發需要解決的問題

功能性問題

執行緒同步面臨兩個問題,想象下有兩個執行緒在協作工作完成某項任務。那麼需要解決以下問題:

  1. 執行緒兩個執行緒之間互動資料,必然涉及到資料共享。而某些資料資源無法被多個執行緒同時使用(臨界區),這時需要,即執行緒互斥問題。
  2. 假如一個執行緒進行的太快,另外一個執行緒就需要等等它,即執行緒同步問題。

效能和可用性問題

在多執行緒程式的效能問題上,如果是對於同樣一段臨界區的多執行緒訪問,那麼則有以下幾個思路:

  1. 互斥鎖。互斥鎖即保證同一時間只有一個執行緒訪問臨界區並完整執行完,其它執行緒在臨界區外面等待。
  2. 無障礙或無鎖。執行緒們一開始直接進入臨界區執行,注意其中不能修改共享資料。執行完後再判斷剛才這段時間是否有其它執行緒執行,沒有的話才修改共享資料,如果有的話就回滾重來。
  3. 降低鎖粒度。也即將這個大的臨界區拆分成幾個小的臨界區,分別加互斥鎖控制,這樣提高了執行緒同時訪問的臨界區的機會變多,效能提高。顯然這要對程式碼仔細推敲,考慮如何拆分鎖粒度而不影響整體的語義。

以上三種思路的效能優劣沒有一個普適的結果,和具體的場景相關。

併發中還會出現以下幾種情況導致系統不可用:

  1. 死鎖。不解釋。
  2. 飢餓。執行緒排程演算法如果不是平等分配的,那麼就可能出現優先順序高的執行緒長時間佔用CPU,導致優先順序低的執行緒無法得到執行機會。
  3. 活鎖。這個我解釋不來。。。

併發程式碼的幾個性質

併發程式設計中需要考慮的幾個概念:

  1. 原子性:指某個操作一旦被某個執行緒執行,直到該操作執行完畢都不會有其它執行緒來干擾。
  2. 可見性:指某個變數或某塊記憶體如果被A執行緒修改,B執行緒能否馬上讀取到修改後的值。
  3. 有序性:A執行緒執行的程式碼序列,在B執行緒看來是否是有序的。

從我個人的理解來看,原子性屬於由併發和執行緒這一理論概念自然而然推導衍生而來的概念,而可見性和有序性是具體的工程實踐中產生的。
實際中,jvm並不能實現的特別完美,總會有工程上的妥協。理論模型與實際模型無法完美契合,總存在一定的偏差。
比如說,jvm為了向效能妥協使用了快取機制,犧牲了資料一致性,這就產生了可見性的概念,需要程式設計師程式設計時自己控制。
jvm為了指令更高效率的執行進行了指令重排優化,則產生了有序性的問題。印象裡以前大學裡學過的CPU的流水線技術,為了指令能夠更好的被CPU流水線利用,減少流水線的空閒時間,編譯器編譯時也會在不影響 序列語義 的前提下,進行指令重排。
總而言之,這是在效能和理論模型完整性之間的一種妥協。

併發的工具

技術上的工具、概念繁多複雜,但是如果我們能理解技術設計上無時無刻的不運用抽象和分層的手段,
那麼,我們可以把技術上的工具分為兩種:

  1. 最基本的、原生的工具。
  2. 在原生提供的工具上,進行封裝得到的更高層次的工具。

更高層次的工具對基礎工具進行了抽象和封裝,遮蔽了其中的實現細節。
這裡想強調的是,工具的介面實現是分開的,兩者可以沒有關係。
如java的監視器鎖從介面上來看,其語義和互斥鎖一樣。然而它並不一定使用互斥鎖實現,而是可以為了效能存在優化,只要最終的行為與介面相同即可。

基本工具

鎖、條件變數、訊號量

有三種用於執行緒同步的工具:

  1. 鎖。鎖可用於規定一個 臨界區,同一時間臨界區內僅能由一個執行緒訪問。其他執行緒則在臨界區外等待(阻塞)。

    • 互斥鎖。使用訊號量實現。臨界區外等待的執行緒會被阻塞。
    • 自旋鎖。臨界區外等待的執行緒會忙等。
  2. 條件變數(Condition)。執行緒在某種條件不滿足時阻塞自己,等待其它的執行緒條件滿足時再喚醒它們。很顯然所有等待的執行緒要放入一個資料結構中,這個資料結構就在條件變數內。
  3. 訊號量。作業系統原生的機制。實際上,鎖 + 條件變數可完成所有訊號量可以完成的邏輯。

在java中,Object類有wait()、notify()和notifyAll()之類的方法。
這些方法可以認為每個物件都內建了一個條件變數,而這些方法是對這些條件變數的操作,因此,可以使用這些方法將物件當作條件變數使用,從而做到執行緒的同步。

無狀態程式設計

底層機制直接對應得到的

底層機制的特點直接得到的:

1. java中的volatile關鍵字。
2. CAS。

volatile關鍵字能夠保證變數的可見性,或者說是讀或寫的原子性。

CAS即compareAndSwap,原子操作
CAS操作直接能夠對應到單條CPU指令,因此天然具有原子性。java中是通過JNI呼叫C語言從而呼叫CPU底層指令實現。

CAS的行為和以下程式碼一致:

int cas(long *addr, long old, long new)
{
    if (*addr == old) {
        *addr = new;
        return 1;
    } else {
        return 0; //*
    }
}

那麼CAS可以做什麼呢?很多樂觀併發控制可以基於CAS實現。
比如說,通過一個標記變數來記錄臨界區被誰佔有,執行緒進入臨界區前不斷的使用CAS操作判斷標記變數是否為空同時將其記錄為自己,來實現鎖機制。這就是自旋鎖的思路。

除此之外,樂觀鎖也能用CAS實現,比如java的Atomic系列,就是這樣實現的。

由基本工具封裝、優化而成的衍生工具

synchronized關鍵字

前面說到可以認為每個物件內建一個條件變數,同樣,每個物件也內建一個鎖。這個內建鎖在Java中被抽象為監視器鎖(monitor)。
synchronized關鍵字的使用實際上就相當於使用監視器鎖定義了一個臨界區。使用這種語法也特別直觀簡單,所以java經常用synchronizd來進行執行緒的同步。

JDK1.6之後,為了提升監視器鎖的效能,java通過某些手段進行了優化。其中包含鎖優化機制,對應三種鎖:

1. 偏向鎖
2. 輕量級鎖
3. 重量級鎖

一開始只有一個執行緒使用執行緒時使用偏向鎖,當存在多個執行緒使用時膨脹為輕量級鎖,而出現比較多的執行緒競爭時再膨脹為重量級鎖。

併發的資料結構

  1. 執行緒安全的容器,如VectorConcurrentHashMap等。
  2. 讀寫鎖,即java中的ReentrantReadWriteLock
    讀寫鎖又可以看做一個讀鎖和一個寫鎖組成的鎖系統,讀鎖和寫鎖又叫共享鎖和排它鎖。
  3. BlockedQueue,阻塞佇列。
  4. Atomic。 java提供的atomic包封裝了一組常用原子類,使用無鎖方式實現。
  5. ThreadLocal。每個執行緒都擁有一份物件拷貝,互相不干擾。

其它:

  1. 雙重檢查鎖

    1. 實際上是一種對於執行緒安全的懶漢單例模式的一種優化。

鎖的屬性

為了表達某種鎖的特點,也會有著很多的概念。
但是這種概念對應的不是某一種鎖,而是一類擁有特定屬性的鎖。如:

  1. 悲觀鎖和樂觀鎖。
    悲觀鎖假設發生衝突,並使用各種方式保證一次只有一個執行緒使用臨界區。
    樂觀鎖放任執行緒去使用資源,在執行完後判斷剛才是否有其它執行緒用過(破壞了資料完整性),如果是則撤回重試。
  2. 公平鎖和非公平鎖。
    公平鎖是指多個執行緒在等待同一個鎖時,必須按照申請鎖的先後順序來一次獲得鎖。java中的ReentrantLock是公平鎖。
  3. 遞迴鎖(可重入鎖)/非遞迴鎖(不可重入鎖)
    同一個執行緒可以多次獲取同一個遞迴鎖,不會產生死鎖。
    如果一個執行緒多次獲取同一個非遞迴鎖,則會產生死鎖。

相關文章