本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》,由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買,京東自營鏈接:http://item.jd.com/12299018.html
本節以及接下來的幾節,我們探討Java並發包中的容器類。本節先介紹兩個簡單的類CopyOnWriteArrayList和CopyOnWriteArraySet,討論它們的用法和實現原理。它們的用法比較簡單,我們需要理解的是它們的實現機制,Copy-On-Write,即寫時拷貝或寫時復制,這是解決並發問題的一種重要思路。
CopyOnWriteArrayList
基本用法
CopyOnWriteArrayList實現了List接口,它的用法與其他List如ArrayList基本是一樣的,它的區別是:
- 它是線程安全的,可以被多個線程並發訪問
- 它的迭代器不支持修改操作,但也不會拋出ConcurrentModificationException
- 它以原子方式支持一些復合操作
我們在66節提到過基於synchronized的同步容器的幾個問題。迭代時,需要對整個列表對象加鎖,否則會拋出ConcurrentModificationException,CopyOnWriteArrayList沒有這個問題,迭代時不需要加鎖。在66節,示例部分代碼為:
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方法,看個例子:
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直接支持兩個原子方法:
//不存在才添加,如果添加了,返回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。
(與其他章節一樣,本節所有代碼位於 https://github.com/swiftma/program-logic)
----------------
未完待續,查看最新文章,敬請關注微信公眾號“老馬說編程”(掃描下方二維碼),從入門到高級,深入淺出,老馬和你一起探索Java編程及計算機技術的本質。用心原創,保留所有版權。