Java併發程式設計-集合類的執行緒安全問題
集合類的執行緒安全問題
1.List集合的執行緒安全
1.1.ArrayList執行緒安全問題。
ArrayList是執行緒安全的嗎,我們不妨執行以下的程式
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list=new ArrayList<>();
for(int i=1;i<=3;i++){
new Thread(()->{
//生成一個8位的隨機不重複字串,UUID版的。寫操作
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //預設複寫了toString方法。讀操作
},String.valueOf(i)).start();;
}
}
執行第一次
執行第二次
執行第三次
可見,幾乎每次執行的結果都不一樣。也沒有報錯。這顯然是執行緒不安全的。
他們誰先寫,誰先讀是不知道的,因為太快了,納秒級別的。有時候它還沒寫進去,別的執行緒就搶著讀。讀出來是個null。從個數上來說應該是3個。從值上面來說應該是8位的字串。但是這裡每次執行效果不一樣。
如果改成30個執行緒,此時程式報錯
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一個8位的隨機不重複字串,UUID版的
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //預設複寫了toString方法。
},String.valueOf(i)).start();;
}
直接報了異常。java.util.ConcurrentModificationException異常。
分析:
-
故障現象:
java.util.ConcurrentModificationException異常(重要) -
導致原因:
ArrayList執行緒不安全,因為add方法沒有加鎖。現在30個執行緒同時操作,又來讀,又來寫,此時崩盤了。 -
解決方案
見下一小節 -
優化建議(同樣的錯誤,不會出現第二次)
1.2.ArrayList執行緒安全解決方案。
1.2.1.使用Vector
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list=new Vector<>();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一個8位的隨機不重複字串,UUID版的
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //預設複寫了toString方法。
},String.valueOf(i)).start();;
}
}
沒有報錯
我們可以看看Vector的原始碼
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
可見,Vector的add方法中添加了synchronized關鍵字。可以確保執行緒安全。
我們再看看ArrayList的原始碼
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
沒有加synchronizad關鍵字,所以執行緒不安全
執行緒安全能夠保證資料一致性,但是讀取效率會下降。Vector同一時間段只能有一個人操作,並不友好。資料一致性能夠保證,但是效能下降。
1.2.2.Collections.synchronizedList(集合引數)
我們可以把執行緒不安全的ArrayList轉換為一個執行緒安全的。在小資料量的時候,用這種方法完全可以。
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list=Collections.synchronizedList(new ArrayList<>());
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一個8位的隨機不重複字串,UUID版的
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //預設複寫了toString方法。
},String.valueOf(i)).start();;
}
}
1.2.3.CopyOnWriteArrayList類(JUC中的類)
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list=new CopyOnWriteArrayList();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一個8位的隨機不重複字串,UUID版的
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //預設複寫了toString方法。
},String.valueOf(i)).start();;
}
}
如何解釋這個原理呢?
舉個例子:
比如上課時要求的簽到,花名冊就是一個資源類。
先解決ArrayList為什麼會出錯,ArrayList沒有加synchronized。多個執行緒允許來搶。
假設情況是ArrayList。此時桌子上只有一份名單(資源類)。張三在簽到的時候,剛把張字寫完,準備寫三,李四此時過來一扯,畫了長長的一道。相當於導致了併發修改異常。
而CopyOnWriteArrayList能夠控制住多人的爭搶,是加了鎖相關的東西的。
用vector加鎖了,同一時間段內只允許一個人來寫一個人來讀。用ArrayList讀的人越來越多了,寫的人會爭壞。能不能解決一種問題能夠同時滿足寫和讀呢?
即要保證寫的時候不出錯,也要保證高併發的時候多個人來讀。
此時我們產生了第三種思想,俗稱寫時複製,也稱為讀寫分離的思想的一種。
寫時複製:
CopyOnWrite容器即寫時複製的容器。往一個容器中新增元素的時候,不直接往當前容器Object[]新增,而是先將當前容器Object[]進行copy。複製出一個新的容器Object[] newElements,然後新的容器Object[] newElements裡新增元素,新增完元素之後,再將原容器的引用指向新的容器setArray(newElements);這樣做的好處是可以對CopyOnWrite容器進行併發的讀,而不需要加鎖,因為當前容器不會新增任何元素,所以CopyOnWrite容器也是一種讀寫分離的思想,讀和寫不同的容器。
我們來看看原始碼
首先,CopyOnWriteArrayList類有一個Object型別的陣列和相應的get和set方法
private transient volatile Object[] array;
final Object[] getArray() {
return array;
}
final void setArray(Object[] a) {
array = a;
}
找到add方法
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
現在就可以解釋上述問題了,**對於ConcurrentModificationException異常。通常是對容器進行併發的讀和寫的時候會出現該異常。**比如foreach遍歷List的時候往其中新增add元素。
瞭解到ConcurrentModificationException異常後,我們就可以結合COW進行思考,如果寫操作的時候不復制一個容器,仍然是之前的容器,那麼此時併發的讀操作就是對之前容器進行的操作,一個容器在被讀的時候,又被另外一個執行緒進行了寫操作,會報出上述錯誤。
所以CopyOnWrite容器也是一種讀寫分離的思想,讀和寫不同的容器,不會發生ConcurrentModificationException異常
CopyOnWrite容器的優缺點:
優點:可以對CopyOnWrite容器進行併發的讀,而不需要加鎖,因為當前容器不會新增任何元素。最大的優勢就是CopyOnWrite容器在被寫的時候,仍然是可以讀的。而Concurrent容器在寫的時候,不能讀。
不足1:CopyOnWrite容器在寫入的時候會進行內部容器的複製,所以內部實現上多了一份核心資料的拷貝賽所需的資源,可以理解為:拿空間換時間
不足2:CopyOnWrite容器僅僅保證了資料的最終一致性,Concurrent容器保證了資料隨時的一致性。
適用場景
對資料在操作過程中的一致性要求不高
根據上述不足1進行分析可以得出:更適用於讀大於寫的場景。換言之CopyOnWrite容器中儲存的資料應該是儘可能不變化的。
2.Set集合的執行緒安全
2.1.HashSet集合的執行緒安全問題
public static void main(String[] args) {
Set<String> set=new HashSet<>();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一個8位的隨機不重複字串,UUID版的
set.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(set); //預設複寫了toString方法。
},String.valueOf(i)).start();;
}
}
同樣也報錯了。一樣的異常。
2.2.HashSet集合的執行緒安全解決方案
2.2.1.Collections.synchronizedSet(集合引數)
Set<String> set=Collections.synchronizedSet(new HashSet<>());
2.2.2.CopyOnWriteArraySet類;
Set<String> set=new CopyOnWriteArraySet<>();
都沒問題。
2.3.HashSet的底層原理分析
HashSet底層資料結構是HashMap。如果是HashMap,往裡面新增元素,需要新增兩個,即鍵值對。而HashSet只添加了一個。怎麼回事呢?
我們看看HashSet的原始碼
public HashSet() {
map = new HashMap<>();
}
底層是HashMap實錘了。
為什麼一個新增鍵值對,一個就新增一個元素呢?
因為HashSet底層add方法呼叫的就是HashMap的put方法,HashSet新增進去的一個元素就是HashMap的key,value永遠是Object的一個常量,固定寫死。
private static final Object PRESENT = new Object(); //固定常量
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
3.Map集合的執行緒安全
3.1.HashMap集合的執行緒安全問題
HashMap是執行緒不安全的。
public static void main(String[] args) {
Map<String,String> map=new HashMap<>();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一個8位的隨機不重複字串,UUID版的
map.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,8));
System.out.println(map); //預設複寫了toString方法。
},String.valueOf(i)).start();;
}
}
3.2.HashMap集合的執行緒安全問題解決方案
3.2.1.Collections.synchronizedMap(集合引數)
Map<String,String> map=Collections.synchronizedMap(new HashMap<>());
按照上面的思路此時應該有一個CopyOnWriteMap類,但是不是的。JUC提供了一個名叫ConcurrentHashMap類。
3.2.2.ConcurrentHashMap類
Map<String,String> map=new ConcurrentHashMap<>();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一個8位的隨機不重複字串,UUID版的
map.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,8));
System.out.println(map); //預設複寫了toString方法。
},String.valueOf(i)).start();;
}
3.3.HashMap的底層原理分析
HashMap底層是一個Node型別陣列+Node型別的連結串列+紅黑樹。即陣列+連結串列+紅黑樹。HashMap是Node型別的結點,HashMap裡面存的是Node,Node裡面存的是鍵值對。
final int hash;
final K key;
V value;
Node<K,V> next;
HashMap預設初始化容量為16,負載因子為0.75
比如我們寫的new HashMap()等價於new HashMap(16,0.75);
當我們建立一個空的HashMap(),陣列的初始容量是16,到了16*0.75=12就會擴容。我們可以根據我們專案的要求一次性給一個容量,避免多次擴容。
ArrayList擴容時,擴容為原來的一半。
HashMap擴容為原來的一倍。開始時是16,經過擴容,擴容到32.之後每次擴容的容量都是2的n次冪。所以優化HashMap是根據我們專案的需求,儘量把HashMap的初始化容量設定的大一點,儘量避免擴容。