看了前兩篇你肯定已經理解了 java 併發程式設計的低層構建。然而,在實際程式設計中,應該經可能的遠離低層結構,畢竟太底層的東西用起來是比較容易出錯的,特別是併發程式設計,既難以除錯,也難以發現問題,我們還是使用由併發處理的專業人員實現的較高層次的結構要方便、安全得多。
阻塞佇列
對於許多執行緒問題,都可以使用一個或多個佇列來安全、優雅的進行資料的傳遞。比如經典的生產者--消費者問題,生產者不停的生成某些資料,消費者需要處理資料,在多執行緒環境中,如何安全的將資料從生產者執行緒傳遞到消費者執行緒?
無需使用鎖和條件物件,java 自帶的阻塞佇列就能夠完美的解決這個問題。阻塞佇列中所有方法都是執行緒安全的,所以我們進行讀取、寫入操作時無需考慮併發問題。阻塞佇列主要有以下幾種方法:
方法 | 正常結果 | 異常結果 |
---|---|---|
add | 新增一個元素 | 佇列滿,丟擲 IllegalStateException 異常 |
element | 返回佇列頭元素 | 佇列空,丟擲 NoSuckElementException 異常 |
offer | 新增一個元素,返回 true | 佇列滿,返回 false |
peek | 返回佇列的頭元素 | 佇列空,返回 null |
poll | 移出並返回佇列頭元素 | 佇列空,返回 null |
put | 新增一個元素 | 佇列滿,阻塞 |
remove | 移出並返回頭元素 | 佇列空,丟擲 NoSuckElementException 異常 |
take | 移出並返回頭元素 | 佇列空,則阻塞 |
上面的方法主要分成了三類,第一類:異常情況下丟擲異常;第二類:異常情況返回 false/null;第三類:異常情況下阻塞。可以根據自身情況選擇合適的方法來操作佇列。
阻塞佇列的實現
在 java.util.concurrent 包中,提供了阻塞佇列的幾種實現,當前也可以自己實現 BlockingQueue 介面,實現自己的阻塞佇列。
- LinkdedBlockingQueue:鏈式阻塞佇列。一般情況下鏈式的結構容量都是沒有上限的,但是也可以選擇手動指定最大容量。
- LinkdedBlockingDeque:鏈式阻塞雙端佇列。
- PriorityBlockingQueue:優先順序佇列。按照優先順序移出,無容量上限。
- ArrayBlockingQueue:陣列佇列,需指定容量。可選指定是否需要公平性,如果設定了公平性,等待了最長時間的執行緒會優先得到處理,但是會降低效能。
延遲佇列
DelayQueue 也是阻塞佇列的一種,不過它要求佇列中的元素實現Delayed
介面。需要重新兩個方法:
- long getDelay(TimeUnit unit)返回延遲的時間,負值表示延遲結束,只有延遲結束的情況下,元素才能從佇列中移出。
- int compareTo(Delayed o)比較方法,DelayQueue 使用該方法對元素進行排序。
傳遞佇列
在 Java SE 7 中新增了一個 TransferQueue 介面,允許生產者等待,直到消費者消費了某個元素。原本生產者消費者是沒有關係的,生產者並不知道某個元素是否被消費者消費了。通過此介面可以讓生產者知道某個元素確實被消費了。如果生產者呼叫:
q.transer(item)
方法,這個呼叫會阻塞,知道 item 被消費執行緒取出消費。LinkedTransferQueue 實現了此介面。
執行緒安全的集合
如果多個執行緒併發的操作集合,會很容易出現問題,我們可以選擇鎖來保護共享資料,但是更好的選擇是使用執行緒安全的集合來作為替代。本節介紹 Java 類庫中提供的執行緒安全的集合(上一節介紹的阻塞佇列也在其中)。
這類集合,size 是通過便利得出的,較慢。而且如果 size 數量大於 20 億,有可能超過 int 的範圍,使用 size 方法無法獲取到大小,在 java8 中引入了 mappingCount 方法,返回值型別為 long。
對映 map
對映是日常使用中非常常見的一種資料結構。共有以下幾種執行緒安全的對映:
- ConcurrentSkipListMap:有序對映,根據鍵排序
- ConcurrentHashMap:無序對映
對映條目的原子更新
一旦涉及到多執行緒環境,做啥都比較麻煩,比如更新一個 map 中某個鍵值對的值,下面的操作顯然是不正確的:
int old = map.get(key);
map.put(key,old+1);
假如有兩個執行緒同時操作一個 key,雖然 put 方法是執行緒安全的,但是由於兩個執行緒之前讀取的 old 是一樣的,這樣就會導致某個執行緒的修改被覆蓋掉。
有以下幾種安全的更新方法:
- 使用 repalce(key,oldValue,newValue)方法,此方法會在 key,oldValue 完全匹配時將 oldValue 換為 newValue 返回 true,否則返回 false。
- 使用 AtomicLong 或者 LongAdder 作為對映的值,這兩個的操作方法是原子性的,因此可以安全的修改值。 3.使用 compute 類似方法完成更新。比如下面的:
# 如果key不再map中,v的值為null
map.compute(key,(k,v)->v==null?1:v+1);
# 如果不存在key
map.computeIfAbsent(key,key->new LongAdder())
# 如果存在key
map.computeIfPresent(key,key->key+1)
# 和compute方法類似,不過不處理鍵
map.merge(key,value,(existingValue,newValue)->existingValue+newValue+1)
批操作
java8 引入的,即使有其他執行緒在處理對映,批操作也能安全的執行。批操作會遍歷對映,處理便利過程中找到的元素,且無需凍結當前對映的快照。顯然通過批操作獲取的結果不是完全精確的,因為遍歷過程中,元素可能會被改變。
有以下三種不同的操作:
- 搜尋(search),遍歷結果直到返回一個非 null 的結果
- 歸約(reduce),組合所有鍵或值,需提供累加函式
- forEach,遍歷所有的鍵值對
每個操作都有 4 個版本: - operationKeys:處理鍵
- operationValues:處理值
- operation:處理鍵值
- operationEntries:處理需要 map.Entry 物件
併發集合
執行緒安全的 set 集合只有以下一種:
- ConcurrentSkipListSet:有序 set
如果我們想要一個 hash 結構的,執行緒安全的 set,有以下幾種辦法.
- 通過 ConcurrentHashMap.<Key>newKeySet()生成一個 Set
,比如:
Set<String> sets = ConcurrentHashMap.<String>newKeySet();
這其實只是 ConcurrentHashMap<Key,Boolean>的一個包裝器,所有的值都為 true
- 通過現有對映物件的 keySet 方法,生成這個對映的鍵集。如果刪除這個集的某個元素,對映上對於元素也會被刪除。但是不能新增元素,因為沒有相應的值。java8 新增了一個 keySet 方法,可以設定一個預設值,這樣就能為向集合中增加元素。
陣列
在 Concurrent 包中只有一個CopyOnWriteArrayList
陣列。該陣列所有的修改都會對底層陣列進行復制,也就是每插入一個元素都會將原來的陣列複製一份並加入新的元素。
當構建一個迭代器時,迭代器指向的是當前陣列的引用,如果後來陣列被修改了,迭代器指向的任然是舊的陣列。
任何集合類都可以通過使用同步包裝器變成執行緒安全的,如下:
//執行緒安全的列表
List<String> list1 = Collections.synchronizedList(new ArrayList<>());
//執行緒安全的map
Map<String,String> map1 = Collections.synchronizedMap(new HashMap<>());
//執行緒安全的set
Set<String> set1 = Collections.synchronizedSet(new HashSet<>());
並行陣列演算法
在 java 8 中,Arrays 類提供了大量的並行化操作。
- Arrays.parallelSort
對一個基本資料型別或物件的陣列進行排序
- Arrays.paralletSetAll
用一個函式計算得到的值填充一個陣列。這個函式接收元素索引,然後計算值。例如:
# 將所有值加上對於的序號
Arrays.parallelSetAll(arr,i->i+ arr[i]);
- parallelPrefix
用對應一個給定結合操作的字首的累加結果替換各個陣列元素。看文字描述不太容易看懂,這裡用一個例子說明:
int[] arr = {1,2,3,4}
Arrays.parallelPrefix(arr,(x,y)->x*y);
// arr變成:[1,1*2,1*2*3,1*2*3*4]