線程安全的List

本文轉載自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)勢,畢竟讀取操作完全不需要加鎖。

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容