1. 程式人生 > 其它 >Java併發程式設計-集合類的執行緒安全問題

Java併發程式設計-集合類的執行緒安全問題

技術標籤:多執行緒併發程式設計Javajava多執行緒

集合類的執行緒安全問題

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異常。

分析:

  1. 故障現象:
    java.util.ConcurrentModificationException異常(重要)

  2. 導致原因:
    ArrayList執行緒不安全,因為add方法沒有加鎖。現在30個執行緒同時操作,又來讀,又來寫,此時崩盤了。

  3. 解決方案
    見下一小節

  4. 優化建議(同樣的錯誤,不會出現第二次)

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的初始化容量設定的大一點,儘量避免擴容。