Stream并行流parallelStream()导致的并发问题:list空指针和size大小异常

问题描述

为了效率,使用Stream并行流parallelStream来遍历源list往宿list添加元素,后面在遍历宿list(LinkedList)的时候会偶发性报NullPointerException空指针异常或list size大小异常。(如果宿list使用的是ArrayList,那么还可能会报ArrayIndexOutOfBoundsException数组越界异常)

测试源码

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

/**
 * @ClassName : ThreadSafe
 * @Description : 线程安全相关的
 * @Author : THQ
 * @Date: 2022-04-24 14:36
 * @Version V1.0
 */
public class ThreadSafe {
    public static void main(String[] args) {
        //执行开始时间(记录耗时)
        Long start = System.currentTimeMillis();
        //先造数据
        //ArrayList和LinkedList选用:查询多用ArrayList增删多用LinkedList
        //为了后面并行流parallelStream可以更快执行,这里选用ArrayList,因为流的可拆分性能上ArrayList比LinkedList好很多
        List sourceList = new ArrayList<>();
        //可自行调整i大小测试
        for (int i=1 ; i<=20 ; i++){
            sourceList.add(i);
        }
        //◆◆◆◆◆◆◆这里使用线程安全的并发容器ConcurrentLinkedQueue,配合并行流,防止多线程修改共享变量触发多线程安全问题◆◆◆◆◆◆◆
        List targetList = new LinkedList<>();//遍历时可能会报空指针异常且size异常
        //List targetList = new ArrayList<>();//遍历时可能会报空指针异常且size异常
        //解决方案
        //ConcurrentLinkedQueue targetList = new ConcurrentLinkedQueue<>();
        //LinkedBlockingQueue targetList = new LinkedBlockingQueue<>();
        //CopyOnWriteArrayList targetList = new CopyOnWriteArrayList<>();
        //实际使用时尽量使用LongStream/ IntStream/DoubleStream 等原始数据流代替 Stream 来处理数字,以避免频繁拆装箱带来的额外开销要考虑流的操作流水线的总计算成本
        //并行流内部使用了默认的 ForkJoinPool 线程池
        try {
            sourceList.parallelStream().forEach(
                    item -> {
                        if (item != null) {
                            //如果是ArrayList,多线程时可能会报数组越界异常
                            targetList.add(item);
                        }
                    });
        } catch (Exception e) {
            System.out.println("出现异常了");
            e.printStackTrace();
            throw e;
        }

        Iterator iterator=targetList.iterator();
        while(iterator.hasNext()){
            try {
                Integer item = (Integer)iterator.next();
                System.out.println(item);
            } catch (Exception e) {
                System.out.println("出现异常了");
                e.printStackTrace();
                throw e;
            }
        }
        //执行结束时间
        Long end = System.currentTimeMillis();
        System.out.println("完成,本次任务耗时"+(end - start) / (1000)+"秒,targetList的size:"+targetList.size());
    }
}

运行结果

如果源list用的LinkedList ,可能会出现以下情况(NullPointerException空指针异常或list size大小也有问题)

Stream并行流parallelStream()导致的并发问题:list空指针和size大小异常_第1张图片

 如果源list用的ArrayList ,除可能出现上述LinkedList的问题,还可能会出现以下情况(ArrayIndexOutOfBoundsException数组越界异常)

Stream并行流parallelStream()导致的并发问题:list空指针和size大小异常_第2张图片

源list用的LinkedList时:

原因分析

先总结:parallelStream并行流实际上是多线程操作,如果多线程操作共享变量就很容易出现线程安全问题。(ArrayList和LinkedList都是线程不安全的!)

不开启多线程的时候targetList的size一定是sourceList的size大小,而且不会出现空指针异常。

这两个问题其实都和size++这句话有关

1. size大小为什么不是1000

分析源码:

add是尾插,源码如下

void linkLast(E e) {
    final Node l = last;
    final Node newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

模拟一个场景

  1. size现在的值为100
  2. 线程a拿到了size大小100,此时cpu让出执行权给线程b
  3. 线程b拿到了size大小也为100
  4. 那么无论他们谁先加1,最后size的值会被101覆盖两次,导致size的大小不会是102

2. 再次遍历target的时候为什么会报空指针异常问题

其实也和尾插法这段代码有关

模拟场景:

  1. 设现在的size为100

  2. 线程a拿到了链表尾部元素last 之后让出执行权给线程b

  3. 线程b也拿到了相同的last之后一直执行完了add操作,此时size = 101

  4. 线程a也执行了 l.next = newNode;覆盖了线程b的在size = 101 位置上的值,之后也进行了size++的操作,size = 102

  5. 此时出现的问题就是size = 101 位置上的值被覆盖了两次,但是102位置上的值是null。

  6. 所以遍历的时候会报空指针异常。

  7. 因为last = newNode;这一步的重复覆盖,可以预测所有的null都会是链表末尾。(参考上面的图)

3. 关于为什么foreach增强for循环为什么会报空指针的问题

Linkedlist的foreach循环,是依赖Iterator的,LinkedList也有自己的迭代器,源码如下

Stream并行流parallelStream()导致的并发问题:list空指针和size大小异常_第3张图片

 可以看出hasnext的判断并不是node.next != null 而是 nextIndex < size 所以即使null都在末尾也会在 next = next.next的时候报空指针异常。

源list用的ArrayList时:

原因分析

其实也是和size++有关,与LinkedList不同的是由于实现尾插的方式不一致,所以导致null可能在中间。(参考上面的图)

分析源码:

ArrayList的add方法如下:

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

线程不安全问题的关键在于这行代码:elementData[size++] = e

其原子操作如下:

1. elementData[size] = e
2. 读取 size
3. size += 1

在多线程环境下,当两个线程同时执行ensureCapacityInternal(size + 1)得到了相同的size(假设此时size恰好为数组最后一位),没有触发扩容,此时线程A先一步执行完size+1,而后线程B读取到这个新的size,而后再次size+1,此时就会出现数组越界异常。

解决办法

使用线程安全的ArrayList:CopyOnWriteArrayList

使用线程安全的LinkedList:ConcurrentLinkedQueue、LinkedBlockingQueue

让需要进行add操作的list,转换成线程安全的:

List target = Collections.synchronizedList(new LinkedList<>())

SynchronizedList:

SynchronizedList是通过对读写方法使用synchronized修饰来实现同步的,即便只是多个线程在读数据,也不能进行,如果是读比较多的场景下,会性能不高,所以适合读写均匀的情况。

ConcurrentLinkedQueue、LinkedBlockingQueue:

LinkedBlockingQueue是使用锁机制,ConcurrentLinkedQueue是使用CAS算法,虽然LinkedBlockingQueue的底层获取锁也是使用的CAS算法;

关于取元素,ConcurrentLinkedQueue不支持阻塞去取元素,LinkedBlockingQueue支持阻塞的take()方法,如若大家需要ConcurrentLinkedQueue的消费者产生阻塞效果,需要自行实现;

关于插入元素的性能,从字面上和代码简单的分析来看ConcurrentLinkedQueue肯定是最快的,但是这个也要看具体的测试场景,我做了两个简单的demo做测试,测试的结果如下,两个的性能差不多,但在实际的使用过程中,尤其在多cpu的服务器上,有锁和无锁的差距便体现出来了,ConcurrentLinkedQueue会比LinkedBlockingQueue快很多。

CopyOnWriteArrayList:

CopyOnWriteArrayList是读写分离的,只对写操作加锁,但是每次写操作(添加和删除元素等)时都会复制出一个新数组,完成修改后,然后将新数组设置到旧数组的引用上,所以在写比较多的情况下,会有很大的性能开销,所以适合读比较多的应用场景。

你可能感兴趣的:(多线程,stream,集合,java,list)