一次 parallelStream 引發的線程安全問題思考

前言

之前 業務開發中 parallelStream用的很少,書中對他的介紹印象中也停留在線程安全中,所以在使用parallelStream的中途就沒有考慮線程安全問題,然後就出現瞭如下詭異的線程安全問題

問題代碼

 List<ClearProduct> clears = Lists.newArrayListWithExpectedSize(sizeCount);
        source.stream().parallel().forEach(s -> {
            //elasticsearch 查詢返回結果集
            ClearProduct clearProduct = clearProduct(s, print);
            if (ObjectUtil.isNotEmpty(clearProduct)) {
                clears.add(clearProduct);
            }
        });
        if (CollectionUtils.isNotEmpty(clears)) {
            Lists.partition(clears, 1000).parallelStream().forEach(s -> clearProductDao.saveBatch(s));
        }

開始以爲 clearProduct方法由線程安全問題,仔細看了下發現不過是普通的業務代碼,問題出在哪呢?
仔細發現是 clears 這個不安全的List集合導致的

我們通過看ArrayList的源碼發現他的 add 方法如下

public boolean add(E e) {

    /**
     * 添加一個元素時,做了如下兩步操作
     * 1.判斷列表的capacity容量是否足夠,是否需要擴容
     * 2.真正將元素放在列表的元素數組裏面
     */
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

ensureCapacityInternal()這個方法的詳細代碼我們可以暫時不看,它的作用就是判斷如果將當前的新元素加到列表後面,列表的elementData數組的大小是否滿足,如果size + 1的這個需求長度大於了elementData這個數組的長度,那麼就要對這個數組進行擴容。

由此看到add元素時,實際做了兩個大的步驟:

判斷elementData數組容量是否滿足需求
在elementData對應位置上設置值
這樣也就出現了第一個導致線程不安全的隱患,在多個線程進行add操作時可能會導致elementData數組越界。具體邏輯如下:

列表大小爲9,即size=9
線程A開始進入add方法,這時它獲取到size的值爲9,調用ensureCapacityInternal方法進行容量判斷。
線程B此時也進入add方法,它獲取到size的值也爲9,也開始調用ensureCapacityInternal方法。
線程A發現需求大小爲10,而elementData的大小就爲10,可以容納。於是它不再擴容,返回。
線程B也發現需求大小爲10,也可以容納,返回。
線程A開始進行設置值操作, elementData[size++] = e 操作。此時size變爲10。
線程B也開始進行設置值操作,它嘗試設置elementData[10] = e,而elementData沒有進行過擴容,它的下標最大爲9。於是此時會報出一個數組越界的異常ArrayIndexOutOfBoundsException.
另外第二步 elementData[size++] = e 設置值的操作同樣會導致線程不安全。從這兒可以看出,這步操作也不是一個原子操作,它由如下兩步操作構成:

elementData[size] = e;
size = size + 1;
在單線程執行這兩條代碼時沒有任何問題,但是當多線程環境下執行時,可能就會發生一個線程的值覆蓋另一個線程添加的值,具體邏輯如下:

列表大小爲0,即size=0
線程A開始添加一個元素,值爲A。此時它執行第一條操作,將A放在了elementData下標爲0的位置上。
接着線程B剛好也要開始添加一個值爲B的元素,且走到了第一步操作。此時線程B獲取到size的值依然爲0,於是它將B也放在了elementData下標爲0的位置上。
線程A開始將size的值增加爲1
線程B開始將size的值增加爲2
這樣線程AB執行完畢後,理想中情況爲size爲2,elementData下標0的位置爲A,下標1的位置爲B。而實際情況變成了size爲2,elementData下標爲0的位置變成了B,下標1的位置上什麼都沒有。並且後續除非使用set方法修改此位置的值,否則將一直爲null,因爲size爲2,添加元素時會從下標爲2的位置上開始。

  • 總結

根本原因是,兩個線程調傳入了同一個ArrayList,這個參數在jvm內以地址方式存在棧內,指向堆區的(size和object[]數組),本質上調用
ArrayList add() { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; }
時,是通過this.size獲取堆內size,這時候兩個線程操作同一個堆內變量,就會出現讀取時的值是對的,但是使用時值已經被修改了,在此this.size,就是髒數據

解決方式

解決很簡單,要麼給 add 方法加鎖 要麼把ArrayList變爲線程安全的集合

List<ClearProduct> clears = Collections.synchronizedList(Lists.newArrayListWithExpectedSize(sizeCount));
//或者
 synchronized (this) {
                clears.add(clearProduct);
            }

Collections.synchronizedList()底層也是用 synchronized 實現的,給你返回的 SynchronizedCollection的方法都是全部加鎖了
在這裏插入圖片描述

案例復現

這裏提供一個簡單的案例給大家去復現

 private static List<Integer> list1 = new ArrayList<>();
    private static List<Integer> list2 = new ArrayList<>();
    private static List<Integer> list3 = new ArrayList<>();
    private static Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        IntStream.range(0, 100000).forEach(list1::add);

        IntStream.range(0, 100000).parallel().forEach(list2::add);

        IntStream.range(0, 100000).forEach(i -> {
            lock.lock();
            try {
                list3.add(i);
            }finally {
                lock.unlock();
            }
        });

        System.out.println("串行執行的大小:" + list1.size());
        System.out.println("並行執行的大小:" + list2.size());
        System.out.println("加鎖並行執行的大小:" + list3.size());
    }

需要多試幾次

  • 參考
    https://blog.csdn.net/u012859681/article/details/78206494
    https://www.cnblogs.com/puyangsky/p/7608741.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章