前言
ArrayList
是線程不安全的,這點毋庸置疑。因爲ArrayList
的所有方法既沒有加鎖,也沒有進行額外的線程安全處理。而Vector
作爲線程安全版的ArrayList
,存在感總是比較低。因爲無論是add
、remove
還是get
方法都加上了synchronized鎖,所以效率低下。
JDK1.5引入的J.U.C包中,又實現了一個線程安全版的ArrayList
——CopyOnWriteArrayList
。
成員變量
先來看下CopyOnWriteArrayList
類的定義和底層數據結構
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private static final long serialVersionUID = 8673264195747942595L;
/** The lock protecting all mutators */
transient final ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
// 存儲數據的array數組,注意此處是用volatile修飾的
private volatile transient Object[] array;
}
根據定義來看,比ArrayList
多了一個ReentrantLock
成員變量,存儲數據的數組用volatile
修飾,其餘的並沒有多少區別。存儲數據的結構依然是數組。
構造方法
/**
* Sets the array.
* 語法糖
*/
final void setArray(Object[] a) {
array = a;
}
/**
* Creates an empty list.
*/
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
/**
* Creates a list holding a copy of the given array.
* 創建一個保存給定數組副本的list(把參數給的數組拷貝給成員變量)
*
* @throws NullPointerException if the specified array is null
* 參數數組爲null,拋出NullPointerException
*/
public CopyOnWriteArrayList(E[] toCopyIn) {
setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}
看完構造方法依然有些疑惑,成員變量和構造方法看起來比ArrayList
還要簡單,到底是如何保證線程安全的呢。或許add
方法會給我們答案。
核心方法
add(E e)
add(E e)
方法用於往list尾部添加元素,CopyOnWriteArrayList
中add(E e)
方法源碼如下:
/**
* Appends the specified element to the end of this list.
* 往list尾部添加指定元素
*
* @param e element to be appended to this list
* @return <tt>true</tt> (as specified by {@link Collection#add})
*/
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 加鎖
lock.lock();
try {
// 獲取成員變量array[]
Object[] elements = getArray();
int len = elements.length;
// 原數組拷貝給新數組(即將添加一個元素,所以 len + 1)
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 新數組替換原數組
setArray(newElements);
return true;
} finally {
// 解鎖
lock.unlock();
}
}
從這段代碼中可以得出如下信息:
- add方法通過
ReentrantLock
保證同一時刻最多隻有一個線程向list中添加元素,肯定是線程安全的 - 並不是直接往數組中添加元素,而是開闢新數組,把元素插入新數組,再用新數組替換舊數組
既然ReentrantLock
已經保證了線程安全,爲什麼還需要開闢新數組?
因爲volatile
修飾數組時,僅能保證數組的引用具有volatile
語義。也就是說volatile
修飾的數組,即使數組中的元素被改變了,也不會觸發可見性。想要解決這個問題有兩種辦法
- 使用
AtomicIntegerArray
或者AtomicLongArray
- 修改數組的內存地址,也就是對數組進行重新賦值
除了volatile
語義的問題,還有一個原因就是爲了get
方法,下文會詳細介紹這個方法。
add(int index, E element)
add(int index, E element)
方法用於往list指定位置添加元素,源碼如下:
/**
* 指定位置添加元素
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public void add(int index, E element) {
final ReentrantLock lock = this.lock;
// 加鎖
lock.lock();
try {
// 獲取原數組
Object[] elements = getArray();
int len = elements.length;
if (index > len || index < 0)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+len);
Object[] newElements;
// 計算需要移動的元素的個數
int numMoved = len - index;
if (numMoved == 0)
// 在尾部新增
newElements = Arrays.copyOf(elements, len + 1);
else {
// 開闢新數組
newElements = new Object[len + 1];
// 拷貝index之前的元素到新數組,拷貝前後,元素下標不變
System.arraycopy(elements, 0, newElements, 0, index);
// 拷貝index之後的元素到新數組,拷貝之後,下標+1
// 因爲新數組index處需要空出來留給新增元素
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
newElements[index] = element;
// 新數組替換原數組
setArray(newElements);
} finally {
// 解鎖
lock.unlock();
}
}
這兩個add
方法完成的功能不一樣,但是實現步驟和原理都差不多,都可以抽象成5步:
1、加鎖
2、開闢新數組
3、拷貝元素
4、新數組替換舊數組
5、解鎖
CopyOnWriteArrayList
雖然底部也是數組實現,但是沒有擴容這個說法。因爲每次add
都會開闢新的數組。況且每次add
都會加鎖,所以效率是比較低的。
remove(int index)
remove(int index)
方法用於刪除並返回指定位置的元素,其源碼如下:
/**
* 刪除並返回指定位置的元素
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E remove(int index) {
final ReentrantLock lock = this.lock;
// 加鎖
lock.lock();
try {
// 獲取原數組
Object[] elements = getArray();
int len = elements.length;
// 獲取指定位置的值,用於返回
E oldValue = get(elements, index);
// 需要移動的元素的個數
int numMoved = len - index - 1;
if (numMoved == 0)
// 刪除的恰好是尾部元素
setArray(Arrays.copyOf(elements, len - 1));
else {
// 開闢新數組
Object[] newElements = new Object[len - 1];
// 拷貝index之前的元素到新數組,拷貝前後下標不變
System.arraycopy(elements, 0, newElements, 0, index);
// 拷貝index之後的元素到新數組,拷貝之後下標-1
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
// 新數組替換原數組
setArray(newElements);
}
// 返回刪除的值
return oldValue;
} finally {
// 解鎖
lock.unlock();
}
}
從源碼可以看出,不管是add
也好,還是remove
也好。都是通過ReentrantLock + volatile + 數組拷貝來實現線程安全的。
寫到這裏,也並沒有看出來CopyOnWriteArrayList
比Vector
高效到哪裏去,況且前者每次add/remove
操作都會開闢新數組,相當於浪費了一倍的空間。
那麼,接下來就是見證奇…
咳咳,沒有奇蹟,來看看CopyOnWriteArrayList
的優點。
vector
效率低就低在get
也加上了synchronized
鎖,但是CopyOnWriteArrayList
的get
方法就不用了加鎖
get(int index)
get(int index)
方法用於獲取指定位置的元素,源碼如下:
/**
* {@inheritDoc}
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E get(int index) {
// 調用內部get方法
return get(getArray(), index);
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
可以看到get(int index)
不需要加鎖,因爲CopyOnWriteArrayList
在add/remove
操作時,不會修改原數組,所以讀操作不會存在線程安全問題。這其實就是讀寫分離的思想,只有寫入的時候才加鎖,複製副本來進行修改。CopyOnWriteArrayList
也叫寫時複製容器。
而且在迭代過程中,即使數組的結構被改變也不會拋出ConcurrentModificationException
異常。因爲迭代的始終是原數組,而所有的變化都發生在原數組的副本上。所以對於迭代器來說,迭代的集合結構不會發生改變。
優缺點
CopyOnWriteArrayList
的優點主要有兩個:
- 線程安全
- 大大的提高了“讀”操作的併發度(相比於
Vector
)
缺點也很明顯:
- 每次“寫”操作都會開闢新的數組,浪費空間
- 無法保證實時性,因爲“讀”和“寫”不在同一個數組,且“讀”操作沒有加互斥鎖,所以不能保證強一致性,只能保證最終一致性
add/remove
操作效率低,既要加鎖,還要拷貝數組
所以CopyOnWriteArrayList
比較適合讀多寫少的場景。
注意:千萬千萬不要在循環中對CopyOnWriteArrayList
進行add/remove
操作,CopyOnWriteArrayList
提供了對應的批量處理方法addAll
和removeAll
。
以下是在循環中進行add
操作和addAll
操作對比:
/**
* 循環 + add vs addAll
*/
public class CopyOnWriteArrayListDemo {
private static final int COUNT = 100000;
private static final List<Integer> list1 = new CopyOnWriteArrayList<>();
private static final List<Integer> list2 = new CopyOnWriteArrayList<>();
public static void main(String[] args) {
List<Integer> dataList = new ArrayList<>(COUNT);
for (int i = 0; i < COUNT; i++) {
dataList.add(i);
}
testCopyOnWriteArrayList(dataList);
}
private static void testCopyOnWriteArrayList(List<Integer> dataList) {
long time1 = System.currentTimeMillis();
for (Integer data : dataList) {
list1.add(data);
}
long time2 = System.currentTimeMillis();
System.out.println("循環+add 耗時:" + (time2 - time1) / 1000.0 + " 秒");
list2.addAll(dataList);
long time3 = System.currentTimeMillis();
System.out.println("addAll 耗時:" + (time3 - time2) / 1000.0 + " 秒");
}
}
執行結果
循環+add 耗時:2.604 秒
addAll 耗時:0.001 秒
這樣很直觀的看到了兩者的效率差異。
總結
CopyOnWriteArrayList
利用ReentrantLock + volatile + 數組拷貝實現了線程安全的ArrayList
。在特定的場景下使用CopyOnWriteArrayList
既能保證線程安全,又能有較好的表現。
參考
- https://www.javamex.com/tutorials/volatile_arrays.shtml
- http://ifeve.com/volatile-array-visiblity/