java的各種集合為什麼不安全(List、Set、Map)?

我們已經知道多線程下會有各種不安全的問題,都知道併發的基本解決方案,這裡對出現錯誤的情況進行一個實際模擬,因此能夠聯想到具體的生產環境中。

1

|

0一、List 的不安全

1

|

11.1 問題

看一段代碼:

<code>public static void main(String[] args) { ArrayList list = new ArrayList<>(); for (int i = 0; i < 3; i++){ new Thread(()->{ list.add(UUID.randomUUID().toString().substring(0,8)); System.out.println(list); },String.valueOf(i)).start(); } } /<code>

過程很簡單,只有 3 個線程而已,對同一個 list 進行 add 的寫操作,並隨後進行輸出的讀操作。

輸出結果,多執行幾次,驚喜多多。

那麼,情況不嚴重的時候,這裡顯然還正常運行結束了,只是導致了還沒來得及寫的時候,就已經讀出了數據。

如果把線程數增加試試,可能還會看到這樣的奇觀:

報錯了:重點異常:java.util.ConcurrentModificationException,翻譯過來就是併發修改異常

1

|

21.2 產生原因

普通的 ArrayList 集合裡面沒有任何特殊處理,在多線程情況下,他們可以共同進行訪問。

那麼在多線程同時操作的時候,按照操作的情況就有這幾種:

各個線程都讀。不影響,前提是隻有讀;各個線程都寫。會出現問題,這裡的點有兩種情況:值覆蓋問題,因為 ArrayList 的底層數組,寫入值的時候要先計算到一個下標位置,然後給對應的位置去賦值,多線程就會出現值覆蓋的問題;空指針異常,因為 ArrayList 的底層數組,寫入值在數組滿的時候需要擴容,在擴容還沒完成的時候,新的下標卻已經計算出來並且要去插入,那麼就會出現空指針異常。有的讀有的寫。那麼顯然對於多個線程來說,2 裡面各個線程寫的情況對應的問題就會出現。除此之外:如果多線程有的讀有的寫,對於 ArrayList 底層,某些情況下,對象是不允許進行修改的,如果修改了,後面調用某些方法時,就會檢測到,然後就直接拋出ConcurrentModificationException。具體一下,因為源碼裡,寫操作對集合修改是寫,而next、remove等 Itr 的遍歷讀操作的時候會
通過當前集合的修改次數與 Itr 對象創建時記錄的次數校驗集合是否被修改,如果修改了,不一致就說明正讀的時候還有別的線程在改,就會拋出異常。JDK作者說了,會拋這個異常的都叫fail-fast iterator。

第 3 種情況就是對應了我們上面的代碼在線程多起來的情況,因為輸出 list 的時候需要遍歷的讀,而此時還有別的線程在進行 add 的修改操作。

1

|

31.3 解決方法

注意:當然不能自己加鎖,因為集合類已經在演變過程有線程安全的替代品,自己的代碼加鎖的粒度已經在集合的外層再加一層了,粒度太大。

同樣能夠完成 ArrayList 功能的,可以使用 Vector,查看源碼就會發現,Vector 的基本結構是一個叫 elementData 的 Object 類型的數組,和 ArrayList 類似,但是對應的操作方法,基本都加上了 synchronized 關鍵字,因此它是線程安全的集合。數據量小的時候,使用 Collections.synchronizedList(new ArrayList())這種方式,來包裹這個集合,跟 Collections 裡面 synchronizedMap包裹hashmap 是一樣的,更多的,還有:

顯然能傳入參數的這些基本集合類都是線程不安全的。

第三種就是,直接使用 juc 包裡面的,CopyOnWriteArrayList() 類,這個類就是併發包給我們提供的線程安全的列表類。1.4裡介紹了這個集合。

1

|

41.4 CopyOnWriteArrayList

對於 CopyOnWriteArrayList 類,名字上就可以聽的出來,寫時複製的列表。

首先,按照前面的我們的分析,只要涉及了寫的操作,和讀或者寫搭配的多線程情況,就會出現問題,那麼多線程同時讀卻不會出現問題,因此相比較於直接都加上 synchronized 的方式,他的思想就是:讀寫分離。這個思想在數據庫對於高併發的架構層面也有一樣的設計。

這樣一來,對於這個 List 集合來說,分為不同操作的保證線程安全的策略,就能夠保證更好的性能。

寫的方法,我們首先可以看 add 方法源碼:

步驟很清楚,如果有了寫操作,需要加鎖:

加鎖獲取到當前的集合數組;計算長度;調用 Arrays.copyOf 方法進行添加操作,每次只添加一個元素進去;修改引用,更新最新的集合;return true。解鎖

其中的 lock 在源碼裡就是一個:

可以看到是一個普通的 Object。

那麼加鎖的時候就用 synchronized 對 Object 進行加鎖,沒有采用 juc 的 ReetrantLock,註釋li也寫了,偏向於使用內置的 monitor 也就是 synchronized 底層 monitor 鎖,這一點也充分說明了 synchronized 的性能更新使得源碼作者使用它。

這個方法是處理最直接的,其他對應的寫操作:remove、set等等也是一樣的基礎流程。

我們再來看看讀操作 get 方法:

2

|

0二、HashSet 的不安全

2

|

12.1 問題及原因

我們還是用 List 一樣的測試代碼;

<code>public class TestSet { public static void main(String[] args) { HashSet set = new HashSet<>(); for (int i = 0; i < 100; i++){ new Thread(()->{ set.add(UUID.randomUUID().toString().substring(0,8)); System.out.println(set); },String.valueOf(i)).start(); } } } /<code>

就會看到一樣的錯誤:

2

|

22.2 出現問題的原因

其實從出現 ConcurrentModificationException 異常來看,我們可以猜測是和 List 類似的原因導致的異常。

可以看到,源碼裡面,Set 的底層維護的是一個 HashMap 來實現。對於遍歷操作來說,都是一樣的使用了 fail-fast iterator 迭代器,因此會出現這個異常。

另外,因為 HashSet 的底層是 HashMap ,本質上,對於每一個 key ,保證唯一,使用了一個 value 為 PRESENT 常量的鍵值對進行存儲。

put 的過程也是調用 map 的 put 方法。

2

|

32.3 解決方案

List 有對應的 Vector 可用,本來就是線程安全的集合,但是 Set 沒有;數據量小的時候,使用 Collections.synchronizedSet(new HashSet<>()) 這種方式,來包裹這個集合,上面我們使用 List 的時候也有類似的方法;同樣的,juc包為我們提供了新的線程安全集合 CopyOnWriteArraySet()。

2

|

42.4 CopyOnWriteArraySet

按照前面的思路,List 的對應線程安全集合是在 List 集合的數組基礎上進行加鎖的相關操作。那麼 Set 既然底層是 HashMap,對應的線程安全集合就應該是對 HashMap 的線程安全集合進行加鎖,或者說直接用 ConcurrentHashMap 集合來實現 CopyOnWriteArraySet 。但事實上,源碼並不是這麼做的

從名字來看,和 ConcurrentHashMap 也沒有什麼關係,而是類似 CopyOnWriteArrayList 的命名,說明是讀寫單獨處理,來讓他成為線程安全的集合,那為什麼是 ArraySet 多一個 array 修飾語呢?

可以看到,他的思路沒有順延 util 包的 HashSet 的實現思路,而是直接使用了 CopyOnWriteArrayList 作為底層數據結構。也就是說沒有利用 Map 的鍵值對映射的特性來保證 set 的唯一性,而是用一個數組為基底的列表來實現。(那顯然在去重方面就要做額外的操作了。)

然後每一個實現的方法都很簡單,基本是直接調用了 CopyOnWriteArrayList 的方法:

我們最擔心的可能 產生問題的 remove 和 add 方法,也是使用了 CopyOnWriteArrayList 的方法:

而保證 set 的不重複性質的關鍵,顯然就在於 CopyOnWriteArrayList 的 addIfAbsent 方法,我們還是點進 CopyOnWriteArrayList 源碼看一看這個方法的實現:

其中的 indexOfRange 方法:

可以看到,也是加了 Monitor 鎖來進行的,整個過程是這樣的:

獲取本來的 set ,是一個數組,以快照形式返回當前的數組;indexOfRange 方法通過遍歷查找查找元素出現位置,addIfAbsent方法完成不存在則加入,如果前一個為 false 後一個就不會執行;加鎖;current 再次獲取一次當前的快照,因為有可能第一次判斷的過程有了其他線程的插入或者修改操作,此時已經不像等,就進入分支進行判斷是否存在;否則就要加入這個元素,和 CopyOnWriteArrayList 添加元素的最後操作是一樣的;
解鎖

總結一下就是,線程安全的 Set 集合完全利用了 CopyOnWriteArrayList 集合的方法,對應的操作也是讀寫分別處理,寫時複製的策略,通過 jvm 層面的鎖來保證安全,那麼保證不重複的方法就是遍歷進行比較。

這樣看來,相比於基於 HashMap 的去重方法,效率肯定會降低,不過如果基於線程安全的 HashMap ,插入操作從hash、比較、到考慮擴容各方面會因為加鎖的過程更復雜,而對於一個不重複的 Set 來說,完全沒必要,所以應該綜合考慮之下采用了 List 為基礎,暴力循環去重。

3

|

0三、HashMap 的線程不安全

關於 HashMap 的相關問題,源碼裡已經分析過,大體是這樣的。

不安全:

普通讀寫不一致問題;死循環問題;ConcurrentModificationException 異常。

解決:

util包的Hashtable集合線程安全;用 synchronizedMap(new HashMap())包裝;使用 juc 包的 ConcurrentHashMap。