記一次java8 parallelStream使用不當引發的血案

最近遇到類似的問題了,所以轉載了一些文章吧

總所周知,Stream是Java 8 的一大亮點,很受開發人員的青睞, 其中包括筆者在內。Stream 大大增強了集合對象功能,它專注於對集合對象進行各種非常便利、高效的聚合操作,或者大批量數據操作。Stream API 藉助於java8中新出現Lambda 表達式,極大的提高編程效率和程序可讀性。so,還有什麼理由拒絕使用呢?然而,這種不明真相的濫用,最終也會自食惡果。

有一天,收到郵件,線上環境拋出ArrayIndexOutOfBoundsException,部分異常堆棧如下

java.lang.ArrayIndexOutOfBoundsException
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[?:1.8.0_77]
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62) ~[?:1.8.0_77]
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[?:1.8.0_77]
at java.lang.reflect.Constructor.newInstance(Constructor.java:423) ~[?:1.8.0_77]
at java.util.concurrent.ForkJoinTask.getThrowableException(ForkJoinTask.java:598) ~[?:1.8.0_77]
at java.util.concurrent.ForkJoinTask.reportException(ForkJoinTask.java:677) ~[?:1.8.0_77]
at java.util.concurrent.ForkJoinTask.invoke(ForkJoinTask.java:735) ~[?:1.8.0_77]
at java.util.stream.ForEachOps$ForEachOp.evaluateParallel(ForEachOps.java:160) ~[?:1.8.0_77]
at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(ForEachOps.java:174) ~[?:1.8.0_77]
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:233) ~[?:1.8.0_77]
at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418) ~[?:1.8.0_77]
at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:583) ~[?:1.8.0_77]

拋出異常的位置正是parallelStream()所在行,先貼出線上代碼:

List<RiderDto> riderSubList = riderSearchProviderClient.getBatchRiderInfo(riderIdSub);
List<RiderBaseDto> subRiderBaseDTOs = Lists.newArrayList();
riderSubList.parallelStream().forEach(rider -> {//異常堆棧指示的位置
	RiderBaseDto subRiderBaseDTO = new RiderBaseDto();
	BeanUtils.copyProperties(rider, subRiderBaseDTO);
	subRiderBaseDTOs.add(subRiderBaseDTO);
});

雖然懷疑是parallelStream的問題,但是對其內部原理不甚瞭解,決定寫個demo測試下,代碼如下:

public class ParallelStreamTest {
	private static final int COUNT = 1000;
	public static void main(String[] args) {
		List<RiderDto> orilist=new ArrayList<RiderDto>();
        for(int i=0;i<COUNT;i++){
        	orilist.add(init());
        }
        final List<RiderDto> copeList=new ArrayList<RiderDto>();
        orilist.parallelStream().forEach(rider -> {
        	RiderDto t = new RiderDto();
        	t.setId(rider.getId());
    		t.setCityId(rider.getCityId());
        	copeList.add(t);
		});
        System.out.println("orilist size:"+orilist.size());
        System.out.println("copeList size:"+copeList.size());
        System.out.println("compare copeList and list,result:"+(copeList.size()==orilist.size())); 
	}
	private static RiderDto init() {
		RiderDto t = new RiderDto();
		Random random = new Random();
		t.setId(random.nextInt(2 ^ 20));
		t.setCityId(random.nextInt(1000));
		return t;
	}
	static class RiderDto implements Serializable{
		private static final long serialVersionUID = 1;
		//城市Id
	    private Integer cityId;
	    //騎手Id
	    private Integer id;
		......
	}
}

多次運行輸出如下:

orilist size:1000
copeList size:998
compare copeList and orilist,result:false

orilist size:1000
copeList size:981
compare copeList and orilist,result:false

orilist size:1000
copeList size:1000
compare copeList and orilist,result:true
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
	at java.util.concurrent.ForkJoinTask.getThrowableException(ForkJoinTask.java:598)
	at java.util.concurrent.ForkJoinTask.reportException(ForkJoinTask.java:677)
	at java.util.concurrent.ForkJoinTask.invoke(ForkJoinTask.java:735)
	at java.util.stream.ForEachOps$ForEachOp.evaluateParallel(ForEachOps.java:160)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(ForEachOps.java:174)
	at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:233)
	at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418)
	at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:583)
	at com.dianwoba.test.ParallelStreamTest.main(ParallelStreamTest.java:17)
Caused by: java.lang.ArrayIndexOutOfBoundsException: 244
	at java.util.ArrayList.add(ArrayList.java:459)
	at com.dianwoba.test.ParallelStreamTest.lambda$0(ParallelStreamTest.java:21)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184)
	at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1374)
	at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)
	at java.util.stream.ForEachOps$ForEachTask.compute(ForEachOps.java:291)
	at java.util.concurrent.CountedCompleter.exec(CountedCompleter.java:731)
	at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
	at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1056)
	at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692)
	at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:157)

結果讓人很意外,每次輸出的結果不一樣,同時,確實拋出了異常ArrayIndexOutOfBoundsException,異常堆棧也和線上環境一樣,由此斷定是parallelStream使用不當造成的問題。下面探究下parallelStream的運行原理。

parallelStream是一個並行執行的流,其使用 fork/join (ForkJoinPool)並行方式來拆分任務和加速處理過程。研究parallelStream之前,搞清楚ForkJoinPool是很有必要的。

ForkJoinPool的核心是採用分治法的思想,將一個大任務拆分爲若干互不依賴的子任務,把這些子任務分別放到不同的隊列裏,併爲每個隊列創建一個單獨的線程來執行隊列裏的任務。同時,爲了最大限度地提高並行處理能力,採用了工作竊取算法來運行任務,也就是說當某個線程處理完自己工作隊列中的任務後,嘗試當其他線程的工作隊列中竊取一個任務來執行,直到所有任務處理完畢。所以爲了減少線程之間的競爭,通常會使用雙端隊列,被竊取任務線程永遠從雙端隊列的頭部拿任務執行,而竊取任務的線程永遠從雙端隊列的尾部拿任務執行。

到這裏,我們知道parallelStream使用多線程並行處理數據,關於多線程,有個老生常談的問題,線程安全。正如上面的分析,demo中orilist會被拆分爲多個小任務,每個任務只負責處理一小部分數據,然後多線程併發地處理這些任務。問題就在於copeList不是線程安全的容器,併發調用add就會發生線程安全的問題,這裏改用CopyOnWriteArrayList就不會有問題了。

final List<RiderDto> copeList=new CopyOnWriteArrayList<RiderDto>();

實際這裏也沒必要使用parallelStream,因此直接去掉parallelStream發到線上了。

那麼,針對上面的輸出結果,你就沒有任何疑問麼,又爲什麼copeList的長度會小?又爲什麼多線程調用ArrayList.add會發生數組越界異常呢?還是從源碼解答吧。

public boolean add(E e) {
        ensureCapacityInternal(size + 1); 
        elementData[size++] = e;
        return true;
    }

將size+1後調用ensureCapacityInternal確定ArrayList內部數組的容量。

private void ensureCapacityInternal(int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }

        ensureExplicitCapacity(minCapacity);
    }

如果當前數組爲空,則DEFAULT_CAPACITY作爲數組新的容量,繼續跟蹤ensureExplicitCapacity:

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

如果新的容量值大於數組的實際值,需要調用grow進行擴容。

    private void grow(int minCapacity) {
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

由實現可知,grow會自動擴容爲原始容量的1.5倍,然後將原始數組中的元素重新拷貝一份到新的數組中,至此完成擴容。

相信到這裏,都不是導致copeList長度會小的源頭,真正發生問題的是elementData[size++] = e,解析這行代碼,分解爲幾個原子操作:

  1. 首先將e添加到size的位置,即elementData[size] = e
  2. 讀取size
  3. size加1

由於這裏存在內存可見性問題,當線程A從內存讀取size後,將size加1,然後寫入內存,過程中可能有線程B也修改了size並寫入內存,那麼線程A寫入內存的值就會丟失線程B的更新,這也解釋了爲什麼parallelStream運行完成後,會出現copeList的長度比原始數組要小的情況。

數組越界異常則主要發生在數組擴容前的臨界點。下面開始分析:

假設當前數組剛好只能添加一個元素,兩個線程同時進入: ensureCapacityInternal(size + 1),同時讀取的size值,加1後進入ensureCapacityInternal都不會導致擴容,退出ensureCapacityInternal後,兩個線程同時elementData[size] = e,線程B的size++先完成,假設此刻線程A讀取到了線程B的更新,線程A再執行size++,此時size的實際值就會大於數組的容量,這樣就會發生數組越界異常。

發佈了42 篇原創文章 · 獲贊 89 · 訪問量 28萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章