天天看點

【JUC】集合類多線程操作不安全的三種解決方案List集合不安全Set不安全Map不安全

現在屬于一個查漏補缺的階段,之前京東的面試中問到我,關于多線程操作集合時,集合不安全該如何解決?

當時就隻想到了(可能因為緊張,我承認比較菜 )使用使用實作安全的集合類和使用Collections。synchronizedxxx的集合安全類來解決,現在回想起來自己當時的确回答的不好,這兩種方式并不能保證“真正的線程安全”。查漏補缺,将掌握不熟練的知識一定要多練習,傳回回顧知識,熟能生巧。

而且最近越來越感受到,每當看一些曾經掌握的知識的源碼,就發現那些前輩真的很厲害,有一些設計很巧妙。

文章目錄

  • List集合不安全
    • 方式1:直接使用多線程安全的集合類
      • 如何回答Vector是否是線程安全的集合類?
    • 方式2:使用Collections工具類修飾的集合類
    • 方式3:使用集合安全類
      • CopyOnWriteArrayList是如何保證線程安全的?
      • CopyOnWriteArrayList中可能出現的問題:
      • CopyOnWriteArrayList内部時如何實作的(梳理版)?
      • synchronizedArrayList的實作和CopyOnWriteArrayList有什麼不同?
  • Set不安全
    • 使用Collections工具包下的synchronizedSet
    • 使用同步的Set集合
  • Map不安全
    • 使用Hashtable
    • 使用Collections.synchronizedMap
    • 使用同步Map

List集合不安全

目前企業的開發都會考慮到高并發的問題,部落客我在今年秋招時也被多次問到集合類在多線程中使用的問題。這部分還是很重要的。

我們熟悉的ArrayList、HashMap等等集合類很多都不是線程安全的。也就是說在多線程情況下是可能造成多線程問題,是以

有需求也必須

讓使用的集合類變安全。

通常能夠想到的使用多線程安全的集合類的方式有三種:

  1. 直接使用本身就是線程安全的集合類,比如使用Vector,HashTable等等。
  2. 使用集合類工具類Collections類下的工具類對集合類進行修飾。
  3. 使用JUC包下的多線程安全集合類,如CopyOnWriteArrayList等。

接下來,我将三種不同的多線程安全的使用集合類做代碼示範。

先示範一下普通的集合類會造成多線程安全問題

package jucTest2;

import java.util.ArrayList;
import java.util.UUID;

/**
 * @author 雷雨
 * @date 2020/12/4 17:02
 * 集合類在多線程下操作的不安全性
 */
public class UnFireCollectionDemo {

    public static void main(String[] args) {
        ArrayList<String> arrayList = new ArrayList<>();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                arrayList.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(arrayList);
            }
        },"A線程").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                arrayList.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(arrayList);
            }
        },"B線程").start();
    }
}
           
【JUC】集合類多線程操作不安全的三種解決方案List集合不安全Set不安全Map不安全

出現了

java.util.ConcurrentModificationException

異常(同步修改異常),也就是說在多線程同時操作集合時出現了多線程操作的問題。

方式1:直接使用多線程安全的集合類

package jucTest2;

import java.util.UUID;
import java.util.Vector;

/**
 * @author 雷雨
 * @date 2020/12/4 16:55
 * 直接使用多線程安全的集合類
 */
public class FireCollectionDemo1 {

    public static void main(String[] args) {

        Vector<String> vector  = new Vector<>();

        new Thread(()->{
            for (int i = 1; i <= 10 ; i++) {
                vector.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(vector);
            }
        },"線程A").start();

        new Thread(()->{
            for (int i = 1; i <= 10 ; i++) {
                vector.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(vector);
            }
        },"線程B").start();

    }
}
           
【JUC】集合類多線程操作不安全的三種解決方案List集合不安全Set不安全Map不安全

有觀察結果會發現:沒有出現異常。也就是說在集合Vector進行多線程操作時沒有發生多線程問題。

為什麼Vector是線程安全的?

簡單的講,Vector是線程安全的,因為Vector中的每個方法都使用了synchronized修飾,進而保證通路 vector 的任何方法都必須獲得對象的 intrinsic lock (或叫 monitor lock),也即,在vector内部,其所有方法不會被多線程所通路。

Vector一定不存在多線程安全問題嗎?

if (!vector.contains(element)) 
vector.add(element); 
...
}
           

其實Vector也可能存在多線程安全安全問題,雖然在Vector的内部的方法都使用了synchronized修飾,保證了在Vector内部使用時,Vector是線程安全的,但是如果如上述代碼所示,是在外部環境中使用的,仍然存在鎖競争,對應上述代碼,雖然contains和add方法都是原子性的操作,但是在if條件判斷為真之後,關于contains的鎖釋放了,在多線程的環境中,其他線程有可能與add線程競争并擷取了鎖資源後修改了其狀态,而add線程在當時正在等待,隻有其他線程釋放鎖資源後,add線程拿到了鎖,add線程才執行(而在add方法執行時,它已經是基于一個錯誤的假設了)。

單個方法的synchronized了并不代表組合方法調用的原子性。

如何回答Vector是否是線程安全的集合類?

Vector 和 ArrayList 實作了同一接口 List, 但所有的 Vector 的方法都具有 synchronized 關鍵修飾。但對于複合操作,Vector 仍然需要進行同步處理。

方式2:使用Collections工具類修飾的集合類

package jucTest2;

import java.util.*;

/**
 * @author 雷雨
 * @date 2020/12/4 17:28
 * 直接使用多線程安全的集合類
 */
public class FireCollectionDemo2 {

    public static void main(String[] args) {

        List<String> list = Collections.synchronizedList(new ArrayList<String>());

        new Thread(()->{
            for (int i = 1; i <= 10 ; i++) {
                list.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(list);
            }
        },"線程A").start();
        

        new Thread(()->{
            for (int i = 1; i <= 10 ; i++) {
                list.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(list);
            }
        },"線程B").start();


    }
}
           

結果正常輸出,沒有發生多線程安全問題。

【JUC】集合類多線程操作不安全的三種解決方案List集合不安全Set不安全Map不安全

關于Collections.synchronizedList和Vector的差別:

  1. 在源碼中Vector中線程安全的實作是使用了synchronized鎖住了整個方法(也就是使用了同步方法的方式),而在Collections.synchronizedList中是使用synchronized鎖了目前的mutex對象,而mutex對象指向的是目前的執行個體。
  2. 那麼Vector鎖的對象是調用者,而Collections.synchronizedList鎖的是synchronizedList本身的執行個體對象。

方式3:使用集合安全類

比如使用CopyOnWriteArrayList

package jucTest2;


import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * @author 雷雨
 * @date 2020年12月4日20:45:23
 * 直接使用多線程安全的集合類CopyOnWriteArratList
 */
public class FireCollectionDemo3 {

    public static void main(String[] args) {

        CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();

        new Thread(()->{
            for (int i = 1; i <= 10 ; i++) {
                list.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(list);
            }
        },"線程A").start();


        new Thread(()->{
            for (int i = 1; i <= 10 ; i++) {
                list.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(list);
            }
        },"線程B").start();




    }
}
           

多次運作,發現沒有出現異常,該集合類是一個多線程安全的類。

【JUC】集合類多線程操作不安全的三種解決方案List集合不安全Set不安全Map不安全

CopyOnWriteArrayList是如何保證線程安全的?

CopyOnWriteArrayList直接翻譯就是寫的時候複制,也就是說在寫操作的時候是建立一個新的容器進行寫操作,寫完之後,再将原容器的引用指向新容器,整個過程加鎖,保證了寫的線程安全。

整個過程都使用Lock加鎖,是線程安全的

//添加元素操作
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;
        //将array引用指向新數組
        setArray(newElements);
        return true;
    } finally {
        //解鎖
        lock.unlock();
    }
}
//删除操作
public E remove(int index) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        E oldValue = get(elements, index);
        int numMoved = len - index - 1;
        if (numMoved == 0)
            setArray(Arrays.copyOf(elements, len - 1));
        else {
            Object[] newElements = new Object[len - 1];
            System.arraycopy(elements, 0, newElements, 0, index);
            System.arraycopy(elements, index + 1, newElements, index,
                             numMoved);
            setArray(newElements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}
           

而因為讀操作的時候不會對目前容器做任何處理,是以我們可以對容器進行并發的讀,而不需要加鎖,也就是讀寫分離。

public E get(int index) {
    return get(getArray(), index);
}
           

CopyOnWriteArrayList中可能出現的問題:

CopyOnWriteArrayList雖然實作了讀寫分離,提高了效率,并且在需要寫操作的地方使用了ReentrantLock保證了線程的同步,但是仍然是存在問題的:

  1. 由于寫操作是通過複制原數組,會消耗記憶體,如果原數組的資料量較大,可能會導緻頻繁的minor GC。
  2. 不能用于

    實時性

    的場景,因為是讀寫分離的,而且在寫操作中采用的方式是通過複制寫的操作,那麼就會有耗時,可能會在寫入資料的過程中,有讀取的操作,那麼可能導緻讀取的資料還是舊的資料。

    CopyOnWriteArrayList

    能保證

    最終一緻性

    ,但是卻不能滿足實時性的要求。

從第二點也就說明了CopyOnWriteArrayList其實比較适用于

讀多寫少

的場景,但是還是慎用,不能保證每次寫入的操作的資料量,可能會導緻讀取到舊的資料的可能性。

小結:

CopyOnWriteArrayList的思想:

1、讀寫分離,讀和寫分開

2、最終一緻性

3、使用另外開辟空間的思路,來解決并發沖突

CopyOnWriteArrayList内部時如何實作的(梳理版)?

  • 寫操作的實作

再梳理一遍源碼(以添加操作為例)

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;
        //将array引用指向新數組
        setArray(newElements);
        return true;
    } finally {
        //解鎖
        lock.unlock();
    }
}
           

首先CopyOnWriteArrayList的寫操作的實作:

  1. 首先在寫操作的内部建立了一個ReentrantLock同步鎖。
  2. 另外在寫操作,在加鎖的情況下還使用了複制寫的思想。複制一個新的資料,添加元素之後,将引用指向新數組。

為什麼要使用ReentrantLock?

是為了保證多線程的同步操作。對于多線程同步,采用加鎖,能夠解決多線程的問題。

為什麼要使用複制寫的操作?

因為關于集合的操作不僅有寫操作還有讀操作,如何不采用複制寫的思想,那麼就要對讀操作也要加鎖,不然就可能會造成線程安全問題(鎖競争機制)。但是CopyOnWriteArrayList為了保證讀寫分離(保證讀的操作的效率),是以沒有對讀操作加鎖。

如果沒有複制,寫時加鎖,讀取不加鎖,那麼就會造成并發讀寫問題,産生不可預期的錯誤,造成ConcurrentModificationException問題。(是因為為了保證并發讀寫的安全性,在集合中維護了一個ModConcurrent用來計數集合修改次數)如果在寫時,進行了讀取操作,ModConcurrent變化了,就會抛出ConcurrentModificationException。

  • 可能會問,為什麼CopyOnWriteArrayList中采用寫操作,讀不加鎖?

如果寫操作加鎖,讀操作也使用ReentrantLock加鎖,那麼就退化為synchronized,讀性能大大減弱。

synchronizedArrayList的實作和CopyOnWriteArrayList有什麼不同?

synchronizedArrayList的實作是使用了synchronized關鍵字在方法的内部對操作進行加鎖(同步代碼塊)的方式實作線層同步,CopyOnWriteArrayList的内部使用的是ReentrantLock(同步鎖),CopyOnWriteArrayList底層儲存元素的數組使用了volatile保證而來線程間的可見性。

Set不安全

package jucTest2;

import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

/**
 * @author 雷雨
 * @date 2020/12/4 21:53
 * Set 集合不安全
 */
public class UnFireCollectionSet {

    public static void main(String[] args) {

        Set<String> set = new HashSet<>();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"A線程").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"B線程").start();


    }
}
           
【JUC】集合類多線程操作不安全的三種解決方案List集合不安全Set不安全Map不安全

結果可以看到發生了CurrentModifcationException異常(同步修改異常)。

使用Collections工具包下的synchronizedSet

package jucTest2;

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

/**
 * @author 雷雨
 * @date 2020/12/5 8:47
 * 使用集合安全的類  set
 * Collections工具包下的集合安全類
 */
public class FireCollectionSet2 {
    public static void main(String[] args) {
        Set<String> set = Collections.synchronizedSet(new HashSet<String>());

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"線程A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"線程B").start();
    }
}
           
【JUC】集合類多線程操作不安全的三種解決方案List集合不安全Set不安全Map不安全

使用同步的Set集合

使用CopyOnWriteSet

package jucTest2;

import java.util.UUID;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CopyOnWriteArraySet;

/**
 * @author 雷雨
 * @date 2020/12/5 8:53
 * 使用同步Set
 */
public class FireCollectionSet3 {
    public static void main(String[] args) {
        CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<String>();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"線程A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"線程B").start();
    }
}
           

CopyOnWriteSet的底層使用CopyOnWriteList來實作的,是以也能保證線程安全。

Map不安全

HashMap在多線程操作下不安全

package jucTest2;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
 * @author 雷雨
 * @date 2020/12/5 9:03
 * Map在多線程下操作不安全
 *
 */
public class UnFireCollectionMap {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"線層A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"線程B").start();
    }
}
           

使用Hashtable

使用HashTable

package jucTest2;

import java.util.Hashtable;
import java.util.UUID;

/**
 * @author 雷雨
 * @date 2020/12/5 9:10
 * 使用本身就是多線程安全的類
 */
public class FireCollectionMap1 {
    public static void main(String[] args) {
        Hashtable<String,Integer> map  = new Hashtable<>();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"線層A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"線程B").start();
    }
}
           

使用本身是集合安全的Hashtable能夠保證多線程操作的安全。

為什麼Hashtable是線程安全的?

看源碼分析一下

//寫操作上加鎖
public synchronized V put(K key, V value) {
    // Make sure the value is not null
    if (value == null) {
        throw new NullPointerException();
    }

    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }

    addEntry(hash, key, value, index);
    return null;
}
//讀操作加鎖
public synchronized V get(Object key) {
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
        if ((e.hash == hash) && e.key.equals(key)) {
            return (V)e.value;
        }
    }
    return null;
}
           

小結:Hashtable就是将讀寫方法都進行了加鎖,保證了讀寫的多線程安全性。

但是還是仍然存在多線程問題的,因為存在鎖資源競争。

使用Collections.synchronizedMap

package jucTest2;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
 * @author 雷雨
 * @date 2020/12/5 9:17
 * 使用Collections
 */
public class FireCollectionMap2 {
    public static void main(String[] args) {
        Map<Object, Object> map = Collections.synchronizedMap(new HashMap<>());
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"線層A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"線程B").start();
    }
}
           

Collections.synchronizedMap是線程安全的類。

【JUC】集合類多線程操作不安全的三種解決方案List集合不安全Set不安全Map不安全

使用同步Map

使用ConcurrentHashMap

package jucTest2;

import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
 * @author 雷雨
 * @date 2020/12/5 9:25
 * 使用同步Map
 */
public class FireCollectionMap3 {
    public static void main(String[] args) {
        ConcurrentMap<String,Integer> map = new ConcurrentHashMap<>();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"線層A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"線程B").start();
    }
}
           

ConcurrentHashMap源碼分析放在之後的部落格,因為細節比較多。