本文轉載自https://blog.csdn.net/p_programmer/article/details/86027076
關于ArrayList,我們都知道它是線程非安全的容器,在并發(fā)環(huán)境中使用它,可能會出現無法挽回的錯誤。
并發(fā)下的ArrayList
那么它究竟會出現什么問題呢?我們寫一段簡單的代碼看一下:
public class ArrayListDemo {
static ArrayList<Integer> list=new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
for (int i = 0; i < 10000; i++) {
list.add(i);
}
};
Thread one = new Thread(runnable);
Thread two = new Thread(runnable);
one.start();
two.start();
one.join();
two.join();
System.out.println(list.size());
}
}
這段代碼中,我們創(chuàng)建了兩個線程,同時對ArrayList添加10000個元素,如果我們運行這段代碼,我們肯定期望它返回的是20000??墒俏以贘DK1.8環(huán)境中運行這段代碼,多次驗證,會出現兩種結果:
第一種:拋出數組越界異常
ava.lang.ArrayIndexOutOfBoundsException: 163
at java.util.ArrayList.add(ArrayList.java:459)
at com.release.util.container.ArrayListDemo.lambda$main$0(ArrayListDemo.java:15)
at java.lang.Thread.run(Thread.java:748)
第二種:結果<20000
這是為什么呢?我們來看看ArrayList的部分源碼:
//存放list集合元素的數組,默認容量10
transient Object[] elementData;
//list大小
private int size;
我們再來看看add源碼:
public boolean add(E e) {
//確定添加元素之后,集合的大小是否足夠,若不夠則會進行擴容
ensureCapacityInternal(size + 1); // Increments modCount!!
//插入元素
elementData[size++] = e;
return true;
}
現在我們假如有兩個線程在對list插入值,這時線程A獲取到的size大小為9,線程B獲取的size大小也為9,但是線程A在執(zhí)行完ensureCapacityInternal(size + 1)后時間片用完了,線程B得以執(zhí)行,這時線程B發(fā)現size+1=10,剛好滿足容量大小,不需要進行擴容,這時線程A得到時間片,這時它來執(zhí)行 elementData[size++] = e時,然而現在size大小為10,這時進行插入就會出現數組越界情況。另外,我們發(fā)現size字段沒有使用volatile修飾,size++本身就是非原子性的,多個線程之間訪問沖突,這時兩個線程可能對同一個位置賦值,就可能出現size小于期望值的結果。
synchronizedList()&CopyOnWriteArrayList
在實際項目中,List是我們使用非常頻繁的容器,那么如果在并發(fā)環(huán)境中,我們怎么獲取到線程安全的List容器呢?看過前一篇博客《并發(fā)容器(一)—線程安全的Map》的朋友,相信大家都知道Collections這樣的一個類,使用它我們可以獲取線程安全的List容器—Collections.synchronizedList(List<T> list),但是無論是讀取還是寫入,它都會進行加鎖,當我們并發(fā)級別特別高,線程之間在任何操作上都會進行等待,因此在某些場景中它不是最好的選擇。在很多的場景中,我們的讀取操作可能遠遠大于寫入操作,這時使用這種方式,顯然不能讓我們滿意,那么怎么辦呢?別擔心,JDK已經為我們考慮好了,為了將讀取的性能發(fā)揮到極致,提供了CopyOnWriteArrayList類,該類在使用過程中,讀讀之間不互斥并且更厲害的是讀寫也不互斥。下面,我們來看看它如何做到的:
public boolean add(E e) {
//獲取重入鎖
final ReentrantLock lock = this.lock;
//加鎖
lock.lock();
try {
//得到舊數組并獲取舊數組的長度
Object[] elements = getArray();
int len = elements.length;
//復制舊數組的元素到新的數組中并且大小在原基礎上加1
Object[] newElements = Arrays.copyOf(elements, len + 1);
//把值插入到新數組中
newElements[len] = e;
//使用新數組替換老數組
setArray(newElements);
return true;
} finally {
//釋放鎖
lock.unlock();
}
}
從源碼中,我們可以看出add操作中使用了重入鎖,但是此鎖只針對寫-寫操作。為什么讀寫之間不用互斥,關鍵就在于添加值的操作并不是直接在原有數組中完成,而是使用原有數組復制一個新的數組,然后將值插入到新的數組中,最后使用新數組替換舊數組,這樣插入就完成了。大家可以發(fā)現,使用這種方式,在add的過程中舊數組沒有得到修改,因此寫入操作不影響讀取操,另外,數組定義private transient volatile Object[] array,其中采用volatile修飾,保證內存可見性,讀取線程可以馬上知道這個修改。下面我們來看看讀取的操作:
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
final Object[] getArray() {
return array;
}
讀取操作完全沒有使用任何的同步控制或者是加鎖,這是因為array數組內部結構不會發(fā)生任何改變,只會被另外一個array所替換,因此讀取是線程安全的。
大家可以使用上面描述的兩種方式,來測試結果是否和我們預期的一樣。另外關于這兩種方式,大家可以測一測它們在讀多寫少和寫多讀少情況下的性能有何不同。
總結
在JDK中,獲取線程安全的List,我們可以使用Collections.synchronizedList(List<T> list)方式,也可以使用CopyOnWriteArrayList類。在真實環(huán)境中,使用它們可以根據我們的業(yè)務需要,在插入操作遠遠超過讀取時,建議使用第一種方式,這是因為CopyOnWriteArrayList在插入的過程中會創(chuàng)建新的數組,這樣在數據量特別大的情況下,對內存的消耗是很大的。當然,如果是讀取操作遠遠大于插入時,第二種方式肯定更占優(yōu)勢,畢竟讀取操作完全不需要加鎖。