ArrayList執行緒不安全怎麼辦?(CopyOnWriteArrayList詳解)

諾狗w發表於2021-08-29

ArrayList執行緒不安全怎麼辦?

有三種解決方法:

  • 使用對應的 Vector 類,這個類中的所有方法都加上了 synchronized 關鍵字

    • 就和 HashMap 和 HashTable 的關係一樣
  • 使用 Collections 提供的 synchronizedList 方法,將一個原本執行緒不安全的集合類轉換為執行緒安全的,使用方法如下:

    List<Integer> list = Collections.synchronizedList(new ArrayList<>());
    
    • 其實 HashMap 也可以用這招:

      Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
      
    • 這個看上去有點東西,其實也是給每個方法加上一個 synchronized,不過不是直接加在方法上,而是加在方法內部,只有當執行緒獲取到 mutex 這個物件的鎖,才能進入程式碼塊:

      public E get(int index) {
      	synchronized (mutex) {
              return list.get(index);
          }
      }
      
  • 使用 JUC 包下提供的 CopyOnWriteArrayList

    • 其實 ConcurrentHashMap 也是 JUC 包下的

這裡具體討論一下 CopyOnWriteArrayList 這個類,它採用了“寫時複製”的技術,也就是說,每當要往這個 list 中新增元素時,並不是直接就新增了,而是會先複製一份 list,然後在這個複製中新增元素,最後再修改指標的指向,看看 add 的原始碼:

public boolean add(E e) {
	synchronized (lock) {
        //得到當前的陣列
        Object[] es = getArray();
        int len = es.length;
        //複製一份並擴容
        es = Arrays.copyOf(es, len + 1);
        //把新元素新增進去
        es[len] = e;
        //修改指標的指向
        setArray(es);
        return true;
    }
}

有人可能會疑惑,這有什麼意義,這不也加了 synchronized 嗎,而且還要複製陣列,這**不是比 Vector 還要爛嗎?

確實是這樣的,在寫操作比較多的場景下,CopyOnWriteArrayList 確實比 Vector 還要慢,但它有兩個優勢:

  • 雖然寫操作爛了,但讀操作快了很多,因為在 vector 中,讀操作也是需要鎖的,而在這裡,讀操作就不需要鎖了,get 方法比較短可能不便於理解,我們看看 indexOf 這個方法:

    public int indexOf(Object o) {
        Object[] es = getArray();
        return indexOfRange(o, es, 0, es.length);
    }
    private static int indexOfRange(Object o, Object[] es, int from, int to) {
        if (o == null) {
            for (int i = from; i < to; i++)
                if (es[i] == null)
                    return i;
        } else {
            //****here****
            for (int i = from; i < to; i++)
                if (o.equals(es[i]))
                    return i;
        }
        return -1;
    }
    
    • 可以發現,這個方法先把當前陣列 array 交給了 es 這個變數,後續的所有操作都是基於 es 進行的(此時 array 和 es 都指向記憶體中的同一份陣列 a1)
    • 由於所有寫操作都是在 a1 的拷貝上進行的(我們把記憶體中的這份拷貝稱為 a2),因此不會影響到那些正在 a1 上進行的讀操作,並且就算寫操作執行完畢了,array 指向了 a2,也不會影響到 es 這個陣列,因為 es 指向的還是 a1
    • 試想,如果 vector 的讀操作不加鎖會出現什麼情況?由於 vector 中所有的讀寫操作都是基於同一個陣列的,因此雖然讀操作一開始拿到的陣列是沒問題的,但在後續遍歷的過程中(比如上面程式碼標註了 here 的地方),很可能出現其他執行緒對陣列進行了修改,誇張點說,如果有個執行緒把陣列給清空了,那麼讀操作就肯定會報錯了,而對於 CopyOnWriteArrayList 來說,就算有清空的操作,那也是在 a2 上進行的,而讀操作還是在 a1 上進行,不會有任何影響
  • 在 forEach 遍歷一個 vector 時,是不允許對 vector 進行修改的,會報出 ConcurrentModificationException 這個異常,理由很簡單,因為只有一份陣列,要是遍歷到一半有其它執行緒把陣列清空了不就出問題了嗎,因此 java 乾脆就直接禁止這種遍歷時修改陣列的行為了,但對於 CopyOnWriteArrayList 來說,它的遍歷是一直在 a1 上進行的,其它寫執行緒只能修改到 a2,這對 a1 是沒有任何影響的,我們看一段程式碼來驗證一下:

    public class Test {
        public static void main(String[] args) {
            CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
            for (int i = 0; i < 1000; i++) {
                list.add(i);
            }
            //遍歷時把陣列清空
            for (Integer i : list) {
                System.out.println(i);
                list.clear();
            }
        }
    }
    
    • 結果是沒有報錯,並且完整輸出了 0~999 所有的數字,可見這裡遍歷的就是最開始的那個陣列 a1,期間哪怕有再多的寫操作也不會影響到 a1,因為所有的寫操作都是在 a2 a3 a4 上進行的

綜上所述,CopyOnWriteArrayList 的優點就是,讀操作很快,不需要鎖,並且支援遍歷,遍歷過程中就算陣列被修改也不會報錯,在讀多寫少的場景下是很優秀的

但它的缺點也很明顯,主要有兩點:

  • 首先,寫操作的記憶體消耗非常大,每次修改陣列都會進行一次拷貝,如果陣列比較大或者修改次數比較多,很快就會消耗掉大量記憶體,觸發 GC,因此在寫多的場景下一定要慎用這個類
  • 其次,雖然讀操作和遍歷不需要上鎖,也允許遍歷時陣列被修改,但這些操作都是基於舊陣列 a1 的,在它們執行 Object[] es = getArray() 這條語句的一瞬間就決定了它們所要操作的陣列,因此它們是沒有辦法感知到最新的那些資料的,就算途中新增了一個很重要的資料,這個資料也是在 a2 中,遍歷 a1 是無法得到這個資料的

相關文章