1. 程式人生 > 實用技巧 >Java~併發容器ConcurrentHashMap、ConcurrentLinkedQueue、阻塞佇列BlockingQueue的實現原理與使用

Java~併發容器ConcurrentHashMap、ConcurrentLinkedQueue、阻塞佇列BlockingQueue的實現原理與使用

文章目錄

ConcurrentHashMap對比HashMap和HashTable

  • 在併發程式設計中使用HashMap可能導致程資料序資料出錯嚴重還會導致死迴圈。而使用執行緒安全的HashTable效率又非常低下,基於以上兩個原因,便有了ConcurrentHashMap的登場機會.
  1. HashMap在併發執行put操作時會引導致多個數據放到一個位置上造成資料丟失嚴重還會起死迴圈,是因為多執行緒會導致HashMap的陣列中的桶連結串列形成環形資料結構,一旦形成環形資料結構,連結串列的next節點永遠不為空,就會產生死迴圈獲連結串列中的最後一個節點
  2. HashTable容器使用synchronized來保證執行緒安全,但線上程競爭激烈的情況下HashTable 的效率非常低下。因為當一個執行緒訪問HashTable的同步方法,其他執行緒也訪問HashTable的同 步方法時,會進入阻塞或輪詢狀態。如執行緒1使用put進行元素新增,執行緒2不但不能使用put方法新增元素,也不能使用get方法來獲取元素,所以競爭越激烈效率越低。
  3. HashTable容器在競爭激烈的併發環境下表現出效率低下的原因是所有訪問HashTable的 執行緒都必須競爭同一把鎖,假如容器裡有多把鎖,每一把鎖用於鎖容器其中一部分資料,那麼 當多執行緒訪問容器裡不同資料段的資料時,執行緒間就不會存在鎖競爭,從而可以有效提高並 發訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術。首先將資料分成一段一段地存 儲,然後給每一段資料配一把鎖,當一個執行緒佔用鎖訪問其中一個段資料的時候,其他段的數 據也能被其他執行緒訪問。

ConcurrentHashMap的結構原理

  • ConcurrentHashMap是由Segment陣列結構和HashEntry陣列結構組成
    。Segment是一種可重 入鎖(ReentrantLock),在ConcurrentHashMap裡扮演鎖的角色;HashEntry則用於儲存鍵值對數 據。一個ConcurrentHashMap裡包含一個Segment陣列。Segment的結構和HashMap類似,是一種 陣列和連結串列結構。一個Segment裡包含一個HashEntry陣列,每個HashEntry是一個連結串列結構的元 素,每個Segment守護著一個HashEntry數組裡的元素,當對HashEntry陣列的資料進行修改時, 必須首先獲得與它對應的Segment鎖, 也就是使用concurrentHashMap的時候是使用倆次hash函式的使用才可以找到具體的元素. 在這裡插入圖片描述

get操作

  • Segment的get操作實現非常簡單和高效。先經過一次再雜湊,然後使用這個雜湊值定位到Segment,再通過雜湊演算法定位到這個Segment裡的具體元素.
  • get操作的高效之處在於整個get過程不需要加鎖,除非讀到的值是空才會加鎖重讀。get方法裡將要使用的共享變數都定義成volatile型別,如用於統計當前 Segement大小的count欄位和用於儲存值的HashEntry的value。定義成volatile的變數,能夠線上 程之間保持可見性,能夠被多執行緒同時讀,並且保證不會讀到過期的值.
  • 即使兩個執行緒同時修改和獲取 volatile變數因為根據Java記憶體模 型的happen before原則,對volatile欄位的寫入操作先於讀操作,get操作也能拿到最新的值,這是用volatile替換鎖的經典應用場景。

put操作

  • 由於put方法裡需要對共享變數進行寫入操作,所以為了執行緒安全,在操作共享變數時必 須加鎖。put方法首先定位到Segment獲取他的鎖,然後在Segment裡進行插入操作。插入操作需要經歷兩個 步驟,第一步判斷是否需要對Segment裡的HashEntry陣列進行擴容,第二步定位新增元素的位 置,然後將其放在HashEntry數組裡。最後釋放這個Segment的鎖
  1. 是否需要擴容
  • 在插入元素前會先判斷Segment裡的HashEntry陣列是否超過容量(threshold),如果超過閾 值,則對陣列進行擴容。值得一提的是,Segment的擴容判斷比HashMap更恰當,因為HashMap 是在插入元素後判斷元素是否已經到達容量的,如果到達了就進行擴容,但是很有可能擴容 之後沒有新元素插入,這時HashMap就進行了一次無效的擴容。
  1. 如何擴容
  • 在擴容的時候,首先會建立一個容量是原來容量兩倍的陣列,然後將原數組裡的元素進 行再雜湊後插入到新的數組裡。為了高效,ConcurrentHashMap不會對整個容器進行擴容,而只 對某個segment進行擴容

size操作

  • 如果要統計整個ConcurrentHashMap裡元素的大小,就必須統計所有Segment裡元素的大小 後求和。Segment裡的全域性變數count是一個volatile變數,那麼在多執行緒場景下,是不是直接把所Segment的count相加就可以得到整個ConcurrentHashMap大小了呢?不是的,雖然相加時 可以獲取每Segment的count的最新值,但是可能累加的那個時刻使用的count發生了變化,那麼統計結 果就不準了。
  • 所以,最安全的做法是在統計size的時候把所有Segment的put、remove和clean方法 全部鎖住,但是這種做法顯然非常低效。 因為在累加count操作過程中,之前累加過的count發生變化的機率非常小,所以 ConcurrentHashMap的做法是先嚐試2次通過不鎖住Segment的方式來統計各個Segment大小,如 果統計的過程中,容器的count發生了變化,則再採用加鎖的方式來統計所有Segment的大小。
  • 那麼ConcurrentHashMap是如何判斷在統計的時候容器是否發生了變化呢?使用modCount 變數,在put、remove和clean方法裡操作元素前都會將變數modCount進行加1,那麼在統計size 前後比較modCount是否發生變化,從而得知容器的大小是否發生變化。

ConcurrentLinkedQueue

  • 實現一個執行緒安全的佇列有兩 種方式:一種是使用阻塞演算法(BlockingQueue),另一種是使用非阻塞演算法。使用阻塞演算法的佇列可以用一個鎖 (入隊和出隊用同一把鎖)或兩個鎖(入隊和出隊用不同的鎖)等方式來實現。非阻塞的實現方 式則可以使用迴圈CAS的方式來實現(ConcurrentLinkedQueue)
  • ConcurrentLinkedQueue是一個基於連結節點的執行緒安全佇列,它採用先進先出的規 則對節點進行排序,當我們新增一個元素的時候,它會新增到佇列的尾部;當我們獲取一個元 素時,它會返回佇列頭部的元素。它採用了迴圈CAS演算法來實現

ConcurrentLinkedQueue的結構原理

  • ConcurrentLinkedQueue由head節點和tail節點組成,每個節點(Node)由節點元素(item)和 指向下一個節點(next)的引用組成,節點與節點之間就是通過這個next關聯起來,從而組成一 張連結串列結構的佇列。預設情況下head節點儲存的元素為空,tail節點等於head節點。

入佇列

  • 入佇列就是將入隊節點新增到佇列的尾部。
  • 多個執行緒同時進行 入隊的情況就變得更加複雜了,因為可能會出現其他執行緒插隊的情況。如果有一個執行緒正在 入隊,那麼它必須先獲取尾節點,然後設定尾節點的下一個節點為入隊節點,但這時可能有另 外一個執行緒插隊了,那麼佇列的尾節點就會發生變化,這時當前執行緒要暫停入隊操作,然後重 新獲取尾節點。
  • 迴圈CSA演算法主要體現就是迴圈找到正確的尾節點進行插入
  • ConcurrentLinkdeQueue為了減少迴圈CAS操作帶來的消耗就會導致tai指向的節點並不是總是佇列的最後一個節點, 也就是tali的更新操作並不是每次插入都執行, 當tial的next為空的時候就直接插入節點並不更新節點, 如果tali的next不為空則插到next的下一個, 然後將tail直接指向這個新的節點就可以, 這樣一來我們執行CAS的時候就會有倆種情況出現正確匹配從而減少CAS的迴圈

出佇列

  • 出佇列的就是從佇列裡返回一個第一個資料, 並且將這個資料在佇列中刪除
  • 出佇列和入佇列大同小異也是使用迴圈CAS演算法找到正確的頭結點才可以進行元素的彈出和刪除
  • 為了減少迴圈CAS的消耗出佇列也是導致 不是每次出隊時都更新head節點,當head節點裡有元素時,直接彈出head 節點裡的元素,而不會更新head節點。只有當head節點裡沒有元素時,出隊操作才會更新head 節點。這種做法也是來減少使用CAS更新head節點的消耗,從而提高出隊效率。

阻塞佇列BlockingQueue

  • 阻塞佇列(BlockingQueue)是一個特殊支援兩個附加操作的佇列。這兩個附加的操作支援阻塞的插入和阻塞的移除方法。
  1. 支援阻塞的插入方法:意思是當佇列滿時,佇列會阻塞插入元素的執行緒,直到佇列不 滿。
  2. 支援阻塞的移除方法:意思是在佇列為空時,獲取元素的執行緒會等待佇列變為非空。
    在這裡插入圖片描述

Java裡的六個阻塞佇列(瞭解)

  1. ArrayBlockingQueue是一個用陣列實現的有界不公平的阻塞佇列。此佇列按照先進先出(FIFO)的原 則對元素進行排序。 預設情況下不保證執行緒公平的訪問佇列,所謂公平訪問佇列是指阻塞的執行緒,可以按照 阻塞的先後順序訪問佇列,即先阻塞執行緒先訪問佇列。非公平性是對先等待的執行緒是非公平 的,當佇列可用時,阻塞的執行緒都可以爭奪訪問佇列的資格,有可能先阻塞的執行緒最後才訪問 佇列。
  2. LinkedBlockingQueue是一個用連結串列實現的有界阻塞佇列。此佇列的預設和最大長度為 Integer.MAX_VALUE。此佇列按照先進先出的原則對元素進行排序。
  3. PriorityBlockingQueue是一個支援優先順序的無界阻塞佇列。預設情況下元素採取自然順序 升序排列。也可以自定義類實現compareTo()方法來指定元素排序規則,或者初始化 PriorityBlockingQueue時,指定構造引數Comparator來對元素進行排序。需要注意的是不能保證 同優先順序元素的順序
  4. DelayQueue是一個支援延時獲取元素的無界阻塞佇列。佇列使用PriorityQueue來實現, 所以在其內部會進行一個排序, 將延遲時間長的放在後面。隊 列中的元素必須實現Delayed介面,在建立元素時可以指定多久才能從佇列中獲取當前元素。 只有在延遲期滿時才能從佇列中提取元素
  5. SynchronousQueue是一個不儲存元素的阻塞佇列。每一個put操作必須等待一個take操作, 否則不能繼續新增元素。 它支援公平訪問佇列。
  6. LinkedBlockingDeque是一個由連結串列結構組成的雙向阻塞佇列。所謂雙向佇列指的是可以 從佇列的兩端插入和移出元素。雙向佇列因為多了一個操作佇列的入口,在多執行緒同時入隊 時,也就減少了一半的競爭。相比其他的阻塞佇列,LinkedBlockingDeque多了addFirst、 addLast、offerFirst、offerLast、peekFirst和peekLast等方法,以First單詞結尾的方法,表示插入、 獲取(peek)或移除雙端佇列的第一個元素。以Last單詞結尾的方法,表示插入、獲取或移除雙 端佇列的最後一個元素。雙向阻塞佇列可以 運用在“工作竊取”模式中