本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
本節以及接下來的幾節,我們探討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。
(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。