計算機程序的思惟邏輯 (73) - 併發容器 - 寫時拷貝的List和Set

本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連接 html

本節以及接下來的幾節,咱們探討Java併發包中的容器類。本節先介紹兩個簡單的類CopyOnWriteArrayList和CopyOnWriteArraySet,討論它們的用法和實現原理。它們的用法比較簡單,咱們須要理解的是它們的實現機制,Copy-On-Write,即寫時拷貝或寫時複製,這是解決併發問題的一種重要思路。java

CopyOnWriteArrayList

基本用法

CopyOnWriteArrayList實現了List接口,它的用法與其餘List如ArrayList基本是同樣的,它的區別是:git

  • 它是線程安全的,能夠被多個線程併發訪問
  • 它的迭代器不支持修改操做,但也不會拋出ConcurrentModificationException
  • 它以原子方式支持一些複合操做

咱們在66節提到過基於synchronized的同步容器的幾個問題。迭代時,須要對整個列表對象加鎖,不然會拋出ConcurrentModificationException,CopyOnWriteArrayList沒有這個問題,迭代時不須要加鎖。在66節,示例部分代碼爲:github

public static void main(String[] args) {
    final List<String> list = Collections
            .synchronizedList(new ArrayList<String>());
    startIteratorThread(list);
    startModifyThread(list);
}
複製代碼

將list替換爲CopyOnWriteArrayList,就不會有異常,如:編程

public static void main(String[] args) {
    final List<String> list = new CopyOnWriteArrayList<>();
    startIteratorThread(list);
    startModifyThread(list);
}
複製代碼

不過,須要說明的是,在Java 1.8以前的實現中,CopyOnWriteArrayList的迭代器不支持修改操做,也不支持一些依賴迭代器修改方法的操做,好比Collections的sort方法,看個例子:swift

public static void sort(){
    CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
    list.add("c");
    list.add("a");
    list.add("b");
    Collections.sort(list);
}
複製代碼

執行這段代碼會拋出異常:數組

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.concurrent.CopyOnWriteArrayList$COWIterator.set(CopyOnWriteArrayList.java:1049)
    at java.util.Collections.sort(Collections.java:159)
複製代碼

爲何呢?由於Collections.sort方法依賴迭代器的set方法,其代碼爲:安全

public static <T extends Comparable<? super T>> void sort(List<T> list) {
    Object[] a = list.toArray();
    Arrays.sort(a);
    ListIterator<T> i = list.listIterator();
    for (int j=0; j<a.length; j++) {
        i.next();
        i.set((T)a[j]);
    }
}
複製代碼

基於synchronized的同步容器的另外一個問題是複合操做,好比先檢查再更新,也須要調用方加鎖,而CopyOnWriteArrayList直接支持兩個原子方法:bash

//不存在才添加,若是添加了,返回true,不然返回false
public boolean addIfAbsent(E e) //批量添加c中的非重複元素,不存在才添加,返回實際添加的個數 public int addAllAbsent(Collection<? extends E> c) 複製代碼

基本原理

CopyOnWriteArrayList的內部也是一個數組,但這個數組是以原子方式被總體更新的。每次修改操做,都會新建一個數組,複製原數組的內容到新數組,在新數組上進行須要的修改,而後以原子方式設置內部的數組引用,這就是寫時拷貝。微信

全部的讀操做,都是先拿到當前引用的數組,而後直接訪問該數組,在讀的過程當中,可能內部的數組引用已經被修改了,但不會影響讀操做,它依舊訪問原數組內容。

換句話說,數組內容是隻讀的,寫操做都是經過新建數組,而後原子性的修改數組引用來實現的。咱們經過代碼具體來看下。

內部數組聲明爲:

private volatile transient Object[] array;
複製代碼

注意,它聲明爲了volatile,這是必需的,保證內存可見性,寫操做更改了以後,讀操做能看到。有兩個方法用來訪問/設置該數組:

final Object[] getArray() {
    return array;
}

final void setArray(Object[] a) {
    array = a;
}
複製代碼

在CopyOnWriteArrayList中,讀不須要鎖,能夠並行,讀和寫也能夠並行,但多個線程不能同時寫,每一個寫操做都須要先獲取鎖,CopyOnWriteArrayList內部使用ReentrantLock,成員聲明爲:

transient final ReentrantLock lock = new ReentrantLock();
複製代碼

默認構造方法爲:

public CopyOnWriteArrayList() {
    setArray(new Object[0]);
} 
複製代碼

就是設置了一個空數組。

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();
    }
}
複製代碼

代碼也容易理解,add方法是修改操做,整個過程須要被鎖保護,先拿到當前數組elements,而後複製了個長度加1的新數組newElements,在新數組中添加元素,最後調用setArray原子性的修改內部數組引用。

查找元素indexOf的代碼爲:

public int indexOf(Object o) {
    Object[] elements = getArray();
    return indexOf(o, elements, 0, elements.length);
}
複製代碼

也是先拿到當前數組elements,而後調用另外一個indexOf進行查找,其代碼爲:

private static int indexOf(Object o, Object[] elements, int index, int fence) {
    if (o == null) {
        for (int i = index; i < fence; i++)
            if (elements[i] == null)
                return i;
    } else {
        for (int i = index; i < fence; i++)
            if (o.equals(elements[i]))
                return i;
    }
    return -1;
}
複製代碼

這個indexOf方法訪問的全部數據都是經過參數傳遞進來的,數組內容也不會被修改,不存在併發問題。

迭代器方法爲:

public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}
複製代碼

COWIterator是內部類,傳遞給它的是不變的數組,它也只是讀該數組,不支持修改。

其餘方法的實現思路是相似的,咱們就不贅述了。

小結

每次修改都建立一個新數組,而後複製全部內容,這聽上去是一個難以使人接受的方案,若是數組比較大,修改操做又比較頻繁,能夠想象,CopyOnWriteArrayList的性能是很低的。事實確實如此,CopyOnWriteArrayList不適用於數組很大,且修改頻繁的場景。它是以優化讀操做爲目標的,讀不須要同步,性能很高,但在優化讀的同時就犧牲了寫的性能。

以前咱們介紹了保證線程安全的兩種思路,一種是鎖,使用synchronized或ReentrantLock,另一種是循環CAS,寫時拷貝體現了保證線程安全的另外一種思路。對於絕大部分訪問都是讀,且有大量併發線程要求讀,只有個別線程進行寫,且只是偶爾寫的場合,這種寫時拷貝就是一種很好的解決方案。

寫時拷貝是一種重要的思惟,用於各類計算機程序中,好比常常用於操做系統內部的進程管理和內存管理。在進程管理中,子進程常常共享父進程的資源,只有在寫時在複製。在內存管理中,當多個程序同時訪問同一個文件時,操做系統在內存中可能只會加載一份,只有程序要寫時纔會拷貝,分配本身的內存,拷貝可能也不會所有拷貝,而只會拷貝寫的位置所在的頁,頁是操做系統管理內存的一個單位,具體大小與系統有關,典型大小爲4KB。

CopyOnWriteArraySet

CopyOnWriteArraySet實現了Set接口,不包含重複元素,使用比較簡單,咱們就不贅述了。內部,它是經過CopyOnWriteArrayList實現的,其成員聲明爲:

private final CopyOnWriteArrayList<E> al;
複製代碼

在構造方法中被初始化,如:

public CopyOnWriteArraySet() {
    al = new CopyOnWriteArrayList<E>();
}
複製代碼

其add方法代碼爲:

public boolean add(E e) {
    return al.addIfAbsent(e);
}
複製代碼

就是調用了CopyOnWriteArrayList的addIfAbsent方法。

contains方法代碼爲:

public boolean contains(Object o) {
    return al.contains(o);
}
複製代碼

因爲CopyOnWriteArraySet是基於CopyOnWriteArrayList實現的,因此與以前介紹過的Set的實現類如HashSet/TreeSet相比,它的性能比較低,不適用於元素個數特別多的集合。若是元素個數比較多,能夠考慮ConcurrentHashMap或ConcurrentSkipListSet,這兩個類,咱們後續章節介紹。

ConcurrentHashMap與HashMap相似,適用於不要求排序的場景,ConcurrentSkipListSet與TreeSet相似,適用於要求排序的場景。Java併發包中沒有與HashSet對應的併發容器,但能夠很容易的基於ConcurrentHashMap構建一個,利用Collections.newSetFromMap方法便可。

小結

本節介紹了CopyOnWriteArrayList和CopyOnWriteArraySet,包括其用法和原理,它們適用於讀遠多於寫、集合不太大的場合,它們採用了寫時拷貝,這是計算機程序中一種重要的思惟和技術。

下一節,咱們討論一種重要的併發容器 - ConcurrentHashMap。

(與其餘章節同樣,本節全部代碼位於 github.com/swiftma/pro…)


未完待續,查看最新文章,敬請關注微信公衆號「老馬說編程」(掃描下方二維碼),從入門到高級,深刻淺出,老馬和你一塊兒探索Java編程及計算機技術的本質。用心原創,保留全部版權。

相關文章
相關標籤/搜索