對于一個對象來說,我們為了保證它的并發性,通常會選擇使用聲明式加鎖方式交由我們的 Java 虛擬機來完成自動的加鎖和釋放鎖的操作,例如我們的 synchronized。也會選擇使用顯式鎖機制來主動的控制加鎖和釋放鎖的操作,例如我們的 ReentrantLock。但是對于容器這種經常發生讀寫操作的類型來說,頻繁的加鎖和釋放鎖必然是影響性能的,基于此,jdk 中為我們集成了很多適用于不同并發場景下的優秀容器類,本篇以及接下來的幾篇文章,我們將學習這些并發容器類的基本使用以及實現原理。本篇的主要內容如下:
- 同步容器的幾種實現及其核心缺陷
- 并發容器之 CopyOnWriteArrayList
- 并發容器之 CopyOnWriteArraySet
一、同步容器的幾種實現及其核心缺陷
在介紹并發容器之前,我們想先簡單介紹下 jdk 中幾種常見的同步容器并通過對比同步容器的缺陷來凸顯我們并發容器相對于它的優勢點。
//返回一個線程安全的 Collection 集合
public static <T> Collection<T> synchronizedCollection(Collection<T> c)
//返回一個線程安全的 List 集合
public static <T> List<T> synchronizedList(List<T> list)
//返回一個線程安全的 Map 集合
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)
這三個容器就隸屬于我們的同步容器,它們是線程安全的,區別于原生的 List 和 Map 以及 Collection。當然它的線程安全特性的實現也是粗暴的,我們跟進去看看:
public static <T> Collection<T> synchronizedCollection(Collection<T> c) {
return new SynchronizedCollection<>(c);
}
static class SynchronizedCollection<E> implements Collection<E>, Serializable {
final Collection<E> c; // Backing Collection
final Object mutex; // Object on which to synchronize
SynchronizedCollection(Collection<E> c) {
this.c = Objects.requireNonNull(c);
mutex = this; //信號量指向當前容器對象本身
}
SynchronizedCollection(Collection<E> c, Object mutex) {
this.c = Objects.requireNonNull(c);
this.mutex = Objects.requireNonNull(mutex);
}
public int size() {
synchronized (mutex) {return c.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return c.isEmpty();}
}
public boolean contains(Object o) {
synchronized (mutex) {return c.contains(o);}
}
..........省略其他方法
}
很明顯,Collections 給我們返回的同步容器是 Collections 的子類實現,而在該子類的實現中并沒有增加額外的任何一個方法,僅僅將父類中所有方法增加 synchronized 關鍵字修飾。這樣,所有想要訪問該容器的線程都需要首先獲得該 Collections 實例的鎖,進而保證了線程安全。那么這么做也不能完全保證容器的線程安全特性,例如在以下的幾種情況下,線程的安全特性是得不到保證的:
- 復合操作
- 迭代操作
1、復合操作
//自定義一個類
public class CompoundOperations {
private List list;
public CompoundOperations(List list) {
this.list = Collections.synchronizedList(list);
}
public void addIfAbsent(Object obj) {
int size = list.size();
if(size == 0) {
list.add(obj);
}
}
}
如上,我們定義了一個 CompoundOperations 類,在該類創建時,我們會為其 list 屬性注入一個線程安全的同步容器 List 實例。現在模擬多個線程同時訪問同一個 CompoundOperations 實例的 addIfAbsent 方法,原先線程安全的 list,現在還安全嗎?
線程 A 和線程 B 同時獲取到 list 的 size 屬性的值,假設都為 0,然后各自都往容器中添加一個元素,原本要求只有在容器為空的時候才能向其中添加元素,在多線程的情況下,該條件顯然已經不足以成為限制。雖然我能保證 list 集合的所有操作都是線程安全的,但是我不能保證你對 list 復合操作下的線程依然安全。這就是復合操作下對同步容器線程安全特性的一個沖擊。
2、迭代操作
public static void main(String[] args) {
List<String> list = Collections.synchronizedList(new ArrayList());
list.add("single");
list.add("walker");
list.add("hello");
Thread thread1 = new Thread() {
@Override
public void run() {
//迭代容器
for(String value : list) {
System.out.println(value);
}
}
};
Thread thread2 = new Thread() {
@Override
public void run() {
//更改容器結構
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
list.add("world");
}
};
thread1.start();
thread2.start();
}
程序運行輸出的結果如下:
這個 ConcurrentModificationException 異常,我們以前的分析 ArrayList 源碼的時候也曾經提及過。這是容器迭代的時候由于其他線程將該容器的內部結構更改導致的,也就是說容器在迭代的時候是不允許發生 add,remove 操作的。顯然,無論是我們原生的 List 集合或是這里的同步 List 集合都沒有解決這樣的一個問題。這是另一個對同步容器線程安全特性的沖擊。
上述簡單的介紹了同步容器的一些簡單的實現原理,以及存在一些不足缺陷,下面我們將詳細看看 jdk 中都分別有哪些并發容器,各自又都具有怎樣的適用場景。
二、并發容器之 CopyOnWriteArrayList
CopyOnWriteArrayList 是一款基于寫時拷貝的并發容器,其基本操作和 ArrayList 一樣,我們主要來分析下它是如何支持并發操作的。首先看讀取操作:
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
和 ArrayList 一樣,內部封裝了一個 Object 數組,通過索引可以隨機訪問集合中的元素。但是與 ArrayList 不同的是,ArrayList 中調用 get 方法將直接返回相應的數組元素,而我們的 CopyOnWriteArrayList 拷貝了一份當前數組并調用另一個 get 方法根據傳入的數組及索引進行返回。
也就是說,在 CopyOnWriteArrayList 中,所有的讀操作都是先拷貝一份當前數組調用另一個方法進行數據的返回。但是所有的寫操作都是需要加鎖的,CopyOnWriteArrayList 使用顯式鎖 ReentrantLock 來加鎖所有的寫操作。例如:
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
可以看到,寫操作雖然是加了鎖了,但是進行更新的時候還是基于整個數組進行更新的。寫操作之前,先拷貝一份當前數組:
Object[] elements = getArray();
寫操作完成之時,整體上重置原數組:
setArray(newElements);
那么這樣看來,多線程之間可以并發的讀取,可以并發的寫入,并且多線程之間還可以讀寫并發進行。
對于同步容器不能保證復合操作下的線程安全的情況,CopyOnWriteArrayList 做了一些解決,但并不徹底。例如,它提供了兩個原子性的復合操作:
//如果容器為空才添加元素
public boolean addIfAbsent(E e)
//批量添加c中的非重復元素,不存在才添加,返回實際添加的個數
public int addAllAbsent(Collection<? extends E> c)
這兩個方法內部是使用的顯式鎖進行實現的,所以整體上看這兩個方法也是線程安全的。
另外需要說的一點就是 CopyOnWriteArrayList 的迭代器,它的迭代器是不支持修改操作的。例如:
public void remove() {
throw new UnsupportedOperationException();
}
public void set(E e) {
throw new UnsupportedOperationException();
}
public void add(E e) {
throw new UnsupportedOperationException();
}
也就是說,在迭代 CopyOnWriteArrayList 的時候,你只能調用他的 next 方法返回下一個元素的值,而不能進行 add ,remove 等操作。和原生的 ArrayList 不同的是,CopyOnWriteArrayList 直接不支持在迭代的時候對容器進行修改,而 ArrayList 本身的迭代器是支持迭代中更改容器結構的,但是前提是你得調用 iterator 中更改的方法對容器結構進行更改,一旦你調用了 ArrayList 中更改容器結構的方法,那么下一次迭代必然報錯,這就是兩者的區別。
至于我們未提到的寫時拷貝的 Set,Set 的內部是基于我們上述的 CopyOnWriteArrayList ,但是區別在于 Set 中的元素要求不可重復,其他的實現基本類似,此處不再贅述。
最后,我們對這種基于寫時拷貝思想的容器做一點小結。寫時拷貝在每次寫操作的時候都需要完全復制一份原數組,并在寫操作完成后重置原數組的引用。這種并發容器只有在寫操作不是很頻繁的場景下才具有更高的效率,一旦寫操作過于頻繁,那么程序消耗的資源也是急劇上升的。