併發編程專題十-其他的併發容器

首先祝大家建黨98週年快樂。今天把自己的書房傢俱添置完畢,非常的開心,一直都希望能擁有一間大書房,今天終於實現了。oh,應該說實現了一半,因爲這個書房有些小,哈哈。以後爭取努力換個大的。

上一章給大家講了ConcurrentHashMap的源碼,相信大家對java中使用加鎖,cas等操做實現其同步功能,對字段使用final進行標記,使用volatile等方法保證其內存可見性,都有較爲深刻的理解。今天主要給大家帶來其他的一些併發容器。如果看懂了ConcurrentHashMap的源碼,其實以下的容器的源碼也很容易看懂,就不再進行分析,主要概述其作用。好讓大家在實現業務的時候有更多的選擇。

一、ConcurrentSkipList系列

ConcurrentSkipListMap  有序Map

ConcurrentSkipListSet   有序Set

TreeMap和TreeSet使用紅黑樹按照key的順序(自然順序、自定義順序)來使得鍵值對有序存儲但是隻能在單線程下安全使用;多線程下想要使鍵值對按照key的順序來存儲,則需要使用ConcurrentSkipListMap和ConcurrentSkipListSet,分別用以代替TreeMap和TreeSet,存入的數據按key排序。在實現上,ConcurrentSkipListSet 本質上就是ConcurrentSkipListMap。

1.1 瞭解什麼是SkipList?

1.1.1 二分查找和AVL樹查找

二分查找要求元素可以隨機訪問,所以決定了需要把元素存儲在連續內存。這樣查找確實很快,但是插入和刪除元素的時候,爲了保證元素的有序性,就需要大量的移動元素了。

如果需要的是一個能夠進行二分查找,又能快速添加和刪除元素的數據結構,首先就是二叉查找樹,二叉查找樹在最壞情況下可能變成一個鏈表。

於是,就出現了平衡二叉樹,根據平衡算法的不同有AVL樹,B-Tree,B+Tree,紅黑樹等,但是AVL樹實現起來比較複雜,平衡操作較難理解,這時候就可以用SkipList跳躍表結構。

1.1.2 什麼是跳錶

傳統意義的單鏈表是一個線性結構,向有序的鏈表中插入一個節點需要O(n)的時間,查找操作需要O(n)的時間。

如果我們使用上圖所示的跳躍表,就可以減少查找所需時間爲O(n/2),因爲我們可以先通過每個節點的最上面的指針先進行查找,這樣子就能跳過一半的節點。

比如我們想查找50,首先和20比較,大於20之後,在和40進行比較,然後在和70進行比較,發現70大於50,說明查找的點在40和50之間,從這個過程中,我們可以看出,查找的時候跳過了30。

跳躍表其實也是一種通過“空間來換取時間”的一個算法,令鏈表的每個結點不僅記錄next結點位置,還可以按照level層級分別記錄後繼第level個結點。此法使用的就是“先大步查找確定範圍,再逐漸縮小迫近”的思想進行的查找。跳躍表在算法效率上很接近紅黑樹。

跳躍表又被稱爲概率,或者說是隨機化的數據結構,目前開源軟件 Redis 和 lucence都有用到它。

都是線程安全的Map實現,ConcurrentHashMap的性能和存儲空間要優於ConcurrentSkipListMap,但是ConcurrentSkipListMap有一個功能: 它會按照鍵的順序進行排序。

二、BlockingQueue阻塞隊列系列

1.1、隊列

隊列是一種特殊的線性表,特殊之處在於它只允許在表的前端(front)進行刪除操作,而在表的後端(rear)進行插入操作,和棧一樣,隊列是一種操作受限制的線性表。進行插入操作的端稱爲隊尾,進行刪除操作的端稱爲隊頭。

在隊列中插入一個隊列元素稱爲入隊,從隊列中刪除一個隊列元素稱爲出隊。因爲隊列只允許在一端插入,在另一端刪除,所以只有最早進入隊列的元素才能最先從隊列中刪除,故隊列又稱爲先進先出(FIFO—first in first out)線性表。

1.2、什麼是阻塞隊列

1)支持阻塞的插入方法:意思是當隊列滿時,隊列會阻塞插入元素的線程,直到隊列不滿。

2)支持阻塞的移除方法:意思是在隊列爲空時,獲取元素的線程會等待隊列變爲非空。

在併發編程中使用生產者和消費者模式能夠解決絕大多數併發問題。該模式通過平衡生產線程和消費線程的工作能力來提高程序整體處理數據的速度。

在線程世界裏,生產者就是生產數據的線程,消費者就是消費數據的線程。在多線程開發中,如果生產者處理速度很快,而消費者處理速度很慢,那麼生產者就必須等待消費者處理完,才能繼續生產數據。同樣的道理,如果消費者的處理能力大於生產者,那麼消費者就必須等待生產者。

爲了解決這種生產消費能力不均衡的問題,便有了生產者和消費者模式。生產者和消費者模式是通過一個容器來解決生產者和消費者的強耦合問題。生產者和消費者彼此之間不直接通信,而是通過阻塞隊列來進行通信,所以生產者生產完數據之後不用等待消費者處理,直接扔給阻塞隊列,消費者不找生產者要數據,而是直接從阻塞隊列裏取,阻塞隊列就相當於一個緩衝區,平衡了生產者和消費者的處理能力。

阻塞隊列常用於生產者和消費者的場景,生產者是向隊列裏添加元素的線程,消費者是從隊列裏取元素的線程。阻塞隊列就是生產者用來存放元素、消費者用來獲取元素的容器。

·拋出異常:當隊列滿時,如果再往隊列裏插入元素,會拋出IllegalStateException("Queuefull")異常。當隊列空時,從隊列裏獲取元素會拋出NoSuchElementException異常。

·返回特殊值:當往隊列插入元素時,會返回元素是否插入成功,成功返回true。如果是移除方法,則是從隊列裏取出一個元素,如果沒有則返回null。

·一直阻塞:當阻塞隊列滿時,如果生產者線程往隊列裏put元素,隊列會一直阻塞生產者線程,直到隊列可用或者響應中斷退出。當隊列空時,如果消費者線程從隊列裏take元素,隊列會阻塞住消費者線程,直到隊列不爲空。

·超時退出:當阻塞隊列滿時,如果生產者線程往隊列裏插入元素,隊列會阻塞生產者線程一段時間,如果超過了指定的時間,生產者線程就會退出。

1.3、常用阻塞隊列

·ArrayBlockingQueue:一個由數組結構組成的有界阻塞隊列。

·LinkedBlockingQueue:一個由鏈表結構組成的有界阻塞隊列。

·PriorityBlockingQueue:一個支持優先級排序的無界阻塞隊列。

·DelayQueue:一個使用優先級隊列實現的無界阻塞隊列。

·SynchronousQueue:一個不存儲元素的阻塞隊列。

·LinkedTransferQueue:一個由鏈表結構組成的無界阻塞隊列。

·LinkedBlockingDeque:一個由鏈表結構組成的雙向阻塞隊列。

以上的阻塞隊列都實現了BlockingQueue接口,也都是線程安全的。

1.3.1 有界無界

有限隊列就是長度有限,滿了以後生產者會阻塞,無界隊列就是裏面能放無數的東西而不會因爲隊列長度限制被阻塞,當然空間限制來源於系統資源的限制,如果處理不及時,導致隊列越來越大越來越大,超出一定的限制致使內存超限,操作系統或者JVM幫你解決煩惱,直接把你 OOM kill 省事了。

無界也會阻塞,爲何?因爲阻塞不僅僅體現在生產者放入元素時會阻塞,消費者拿取元素時,如果沒有元素,同樣也會阻塞。

1.3.2 ArrayBlockingQueue

是一個用數組實現的有界阻塞隊列。此隊列按照先進先出(FIFO)的原則對元素進行排序。默認情況下不保證線程公平的訪問隊列,所謂公平訪問隊列是指阻塞的線程,可以按照阻塞的先後順序訪問隊列,即先阻塞線程先訪問隊列。非公平性是對先等待的線程是非公平的,當隊列可用時,阻塞的線程都可以爭奪訪問隊列的資格,有可能先阻塞的線程最後才訪問隊列。初始化時有參數可以設置

1.3.3 LinkedBlockingQueue

是一個用鏈表實現的有界阻塞隊列。此隊列的默認和最大長度爲Integer.MAX_VALUE。此隊列按照先進先出的原則對元素進行排序。

1.3.4 Array實現和Linked實現的區別

1. 隊列中鎖的實現不同

ArrayBlockingQueue實現的隊列中的鎖是沒有分離的,即生產和消費用的是同一個鎖;

LinkedBlockingQueue實現的隊列中的鎖是分離的,即生產用的是putLock,消費是takeLock

2. 在生產或消費時操作不同

ArrayBlockingQueue實現的隊列中在生產和消費的時候,是直接將枚舉對象插入或移除的;

LinkedBlockingQueue實現的隊列中在生產和消費的時候,需要把枚舉對象轉換爲Node<E>進行插入或移除,會影響性能

3. 隊列大小初始化方式不同

 ArrayBlockingQueue實現的隊列中必須指定隊列的大小;

LinkedBlockingQueue實現的隊列中可以不指定隊列的大小,但是默認是Integer.MAX_VALUE

1.3.5 PriorityBlockingQueue

PriorityBlockingQueue是一個支持優先級的無界阻塞隊列。默認情況下元素採取自然順序升序排列。也可以自定義類實現compareTo()方法來指定元素排序規則,或者初始化PriorityBlockingQueue時,指定構造參數Comparator來對元素進行排序。需要注意的是不能保證同優先級元素的順序。

1.3.6 DelayQueue

是一個支持延時獲取元素的無界阻塞隊列。隊列使用PriorityQueue來實現。隊列中的元素必須實現Delayed接口,在創建元素時可以指定多久才能從隊列中獲取當前元素。只有在延遲期滿時才能從隊列中提取元素。

DelayQueue非常有用,可以將DelayQueue運用在以下應用場景。

緩存系統的設計可以用DelayQueue保存緩存元素的有效期,使用一個線程循環查詢DelayQueue,一旦能從DelayQueue中獲取元素時,表示緩存有效期到了。還有訂單到期,限時支付等等

1.3.7 SynchronousQueue

是一個不存儲元素的阻塞隊列。每一個put操作必須等待一個take操作,否則不能繼續添加元素。SynchronousQueue可以看成是一個傳球手,負責把生產者線程處理的數據直接傳遞給消費者線程。隊列本身並不存儲任何元素,非常適合傳遞性場景。SynchronousQueue的吞吐量高於LinkedBlockingQueue和ArrayBlockingQueue。

1.3.8 LinkedTransferQueue

多了tryTransfer和transfer方法,

(1)transfer方法

如果當前有消費者正在等待接收元素(消費者使用take()方法或帶時間限制的poll()方法時),transfer方法可以把生產者傳入的元素立刻transfer(傳輸)給消費者。如果沒有消費者在等待接收元素,transfer方法會將元素存放在隊列的tail節點,並等到該元素被消費者消費了才返回。

(2)tryTransfer方法

tryTransfer方法是用來試探生產者傳入的元素是否能直接傳給消費者。如果沒有消費者等待接收元素,則返回false。和transfer方法的區別是tryTransfer方法無論消費者是否接收,方法立即返回,而transfer方法是必須等到消費者消費了才返回。

1.3.9 LinkedBlockingDeque

LinkedBlockingDeque是一個由鏈表結構組成的雙向阻塞隊列。所謂雙向隊列指的是可以從隊列的兩端插入和移出元素。雙向隊列因爲多了一個操作隊列的入口,在多線程同時入隊時,也就減少了一半的競爭。

多了addFirst、addLast、offerFirst、offerLast、peekFirst和peekLast等方法,以First單詞結尾的方法,表示插入、獲取(peek)或移除雙端隊列的第一個元素。以Last單詞結尾的方法,表示插入、獲取或移除雙端隊列的最後一個元素。另外,插入方法add等同於addLast,移除方法remove等效於removeFirst。但是take方法卻等同於takeFirst,不知道是不是JDK的bug,使用時還是用帶有First和Last後綴的方法更清楚。在初始化LinkedBlockingDeque時可以設置容量防止其過度膨脹。另外,雙向阻塞隊列可以運用在“工作竊取”模式中。

1.4 瞭解阻塞隊列的實現原理

使用了等待通知模式實現。所謂通知模式,就是當生產者往滿的隊列裏添加元素時會阻塞住生產者,當消費者消費了一個隊列中的元素後,會通知生產者當前隊列可用。通過查看JDK源碼發現ArrayBlockingQueue使用了Condition來實現。其餘隊列的實現,大家可以自行查看,隊列的實現的代碼總體來說,並不複雜。

三、ConcurrentLinkedQueue 無界非阻塞隊列

無界非阻塞隊列,它是一個基於鏈表的無界線程安全隊列。該隊列的元素遵循先進先出的原則。頭是最先加入的,尾是最近加入的。插入元素是追加到尾上。提取一個元素是從頭提取。

大家可以看成是LinkedList的併發版本,常用方法:

concurrentLinkedQueue.add("c");    

 concurrentLinkedQueue.offer("d"); // 將指定元素插入到此隊列的尾部。    

concurrentLinkedQueue.peek(); // 檢索並不移除此隊列的頭,如果此隊列爲空,則返回 null。    

concurrentLinkedQueue.poll(); // 檢索並移除此隊列的頭,如果此隊列爲空,則返回 null。

四、寫時複製容器

4.1 什麼是寫時複製容器

CopyOnWriteArrayList和CopyOnWriteArraySet

CopyOnWrite容器即寫時複製的容器。通俗的理解是當我們往一個容器添加元素的時候,不直接往當前容器添加,而是先將當前容器進行Copy,複製出一個新的容器,然後新的容器裏添加元素,添加完元素之後,再將原容器的引用指向新的容器。

這樣做的好處是我們可以對CopyOnWrite容器進行併發的讀,而不需要加鎖,因爲當前容器不會添加任何元素。所以CopyOnWrite容器也是一種讀寫分離的思想,讀和寫不同的容器。如果讀的時候有多個線程正在向CopyOnWriteArrayList添加數據,讀還是會讀到舊的數據,因爲寫的時候不會鎖住舊的CopyOnWriteArrayList。

CopyOnWrite併發容器用於對於絕大部分訪問都是讀,且只是偶爾寫的併發場景。比如白名單,黑名單,商品類目的訪問和更新場景,假如我們有一個搜索網站,用戶在這個網站的搜索框中,輸入關鍵字搜索內容,但是某些關鍵字不允許被搜索。這些不能被搜索的關鍵字會被放在一個黑名單當中,黑名單每天晚上更新一次。當用戶搜索時,會檢查當前關鍵字在不在黑名單當中,如果在,則提示不能搜索。

使用CopyOnWriteMap需要注意兩件事情:

1. 減少擴容開銷。根據實際需要,初始化CopyOnWriteMap的大小,避免寫時CopyOnWriteMap擴容的開銷。

2. 使用批量添加。因爲每次添加,容器每次都會進行復制,所以減少添加次數,可以減少容器的複製次數。

4.2 寫時複製容器的問題

4.2.1 性能問題

每次修改都創建一個新數組,然後複製所有內容,如果數組比較大,修改操作又比較頻繁,可以想象,性能是很低的,而且內存開銷會很大。

4.2.2 數據一致性問題

CopyOnWrite容器只能保證數據的最終一致性,不能保證數據的實時一致性。所以如果你希望寫入的的數據,馬上能讀到,不要使用CopyOnWrite容器。.

 

總結,本節主要講述跳錶,阻塞隊列,非阻塞隊列,以及寫時複製容器,功能都很好理解,但學會在適當的場景使用最合適的方法,纔是真正的掌握。希望以後大家寫業務的時候可以多一些思考,使其代碼實現更優雅。

其他閱讀   

併發編程專題九-併發容器ConcurrentHashMap源碼分析

併發編程專題八-hashMap死循環分析

併發編程專題七-什麼是線程安全

併發編程專題六-線程池的使用與原理

併發編程專題五-AbstractQueuedSynchronizer源碼分析

併發編程專題四-原子操作和顯示鎖

併發編程專題三-JAVA線程的併發工具類

併發編程專題二-線程間的共享和協作

併發編程專題一-線程相關基礎概念

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章