滥用java8 parallelStream

背景

对于java开发从业人员来说,并发编程是绕不开的话题,juc并发包下提供了一系列多线程场景解决方案。
随着jdk1.8的普及,多线程处理问题,除了使用使用线程池(ExecutorService),很多人选择了parallelStream() 并行流,底层使用forkjoin实现并行处理。
那么并行和并发又有什么区别?究竟改如何选择?滥用时又会有什么影响?

存在问题

线程不安全

使用线程不安全的ArrayList,导致:java.lang.ArrayIndexOutOfBoundsException

测试的代码:

public class Stream {
    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;


        public Integer getCityId() {
            return cityId;
        }

        public void setCityId(Integer cityId) {
            this.cityId = cityId;
        }

        public Integer getId() {
            return id;
        }

        public void setId(Integer id) {
            this.id = id;
        }
    }
}

多次运行结果如下:

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

orilist size:1000
copeList size:0
compare copeList and list,result:false

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.sunhui.thread.CompletableFuture.Java8.Stream.main(Stream.java:16)
Caused by: java.lang.ArrayIndexOutOfBoundsException: 823
	at java.util.ArrayList.add(ArrayList.java:463)
	at com.sunhui.thread.CompletableFuture.Java8.Stream.lambda$main$0(Stream.java:20)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184)
	at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1382)
	at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:482)
	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不是线程安全的容器(ArrayList),并发调用add就会发生线程安全的问题,这里改用CopyOnWriteArrayList就不会有问题了。

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

分析:

那么,针对上面的输出结果,你就没有任何疑问么,又为什么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,解析这行代码,分解为几个原子操作:

  • 首先将e添加到size的位置,即elementData[size] = e
  • 读取size
  • 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的实际值就会大于数组的容量,这样就会发生数组越界异常。

公共commonPool导致程序卡顿

cpu和内存都正常的情况下,生产环境遇到一次,脚本卡住几个小时才执行完,线程堆栈分析发现在下面代码线程进入waiting状态,而且是偶发。
 parallelStream()底层用的ForkJoinPool.commonPool();进行并行计算。代码中多个脚本同时用到parallelStream时,会共用线程池,一个脚本io慢,其他脚本都等等,用到的脚本越多,卡的时间越长。
 这一点非常重要:大部分人使用parallelStream不知道底层原理,把parallelStream当做多线程使用,这个非常危险,用的地方越多,程序卡顿时间越长。

List<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        list.add(4);
        list.add(5);
        list.add(6);
        list.stream().forEach(item->{
            System.out.println(Thread.currentThread().getName());
            System.out.println(item);
        });

        list.parallelStream().forEach(item->{
            System.out.println(Thread.currentThread().getName());
            System.out.println(item);
        });

控制台输出:

main
1
main
2
main
3
main
4
main
5
main
6
main
4
ForkJoinPool.commonPool-worker-2
6
ForkJoinPool.commonPool-worker-11
1
ForkJoinPool.commonPool-worker-2
5
ForkJoinPool.commonPool-worker-4
3
ForkJoinPool.commonPool-worker-9
2

正确使用

  • cpu密集型的运算
    parallelStream底层使用和cpu核数一样多的ForkJoinPool并行计算,适合cpu密集型业务直接使用。
  • io密集的多线程场景 首先io密集场景适合使用线程池,不建议使用parallelStream() 如果非要使用,可以做如下优化:
    io密集的业务,消耗cpu较少,出现慢sql等场景会导致线程等待,不能使用默认ForkJoinPool.commonPool(),自定义ForkJoinPool。
final List<RiderDto> copeList=new ArrayList<RiderDto>();

        ForkJoinPool forkJoinPool = new ForkJoinPool();
        forkJoinPool.execute(() -> {
            orilist.parallelStream().forEach(rider -> {
                // 正常编写业务代码
                RiderDto t = new RiderDto();
                t.setId(rider.getId());
                t.setCityId(rider.getCityId());
                copeList.add(t);
            });
        });

注意事项

  • 并行代码块中需要使用AtomicInteger、ConcurrentHashMap等线程安全类。
  • parallelStream默认使用的commonPool,在io密集场景下不可大量使用 数量级小的计算就别用并行了,cpu切换耗时反而慢
  • 在用到threadlocal的情景下,谨慎使用parallelStream和线程池,多线程中无法获取主线程的threadlocal。
  • 如果完全不懂parallelStream底层原理,建议不要使用
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

技术杠精

你的鼓励是我创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值