Stream并行流parallelStream()导致的并发问题:list空指针和size大小异常,踩坑记录 -- 2021-8-24 ,parallelStream遇上threadlocal
问题描述
为了效率,使用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<Integer> sourceList = new ArrayList<>();
-
//可自行调整i大小测试
-
for (int i=1 ; i<=20 ; i++){
-
sourceList.add(i);
-
}
-
//◆◆◆◆◆◆◆这里使用线程安全的并发容器ConcurrentLinkedQueue,配合并行流,防止多线程修改共享变量触发多线程安全问题◆◆◆◆◆◆◆
-
List<Integer> targetList = new LinkedList<>();//遍历时可能会报空指针异常且size异常
-
//List<Integer> targetList = new ArrayList<>();//遍历时可能会报空指针异常且size异常
-
//解决方案
-
//ConcurrentLinkedQueue<Integer> targetList = new ConcurrentLinkedQueue<>();
-
//LinkedBlockingQueue<Integer> targetList = new LinkedBlockingQueue<>();
-
//CopyOnWriteArrayList<Integer> 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大小也有问题)
如果源list用的ArrayList ,除可能出现上述LinkedList的问题,还可能会出现以下情况(ArrayIndexOutOfBoundsException数组越界异常)
源list用的LinkedList时:
原因分析
先总结:parallelStream并行流实际上是多线程操作,如果多线程操作共享变量就很容易出现线程安全问题。(ArrayList和LinkedList都是线程不安全的!)
不开启多线程的时候targetList的size一定是sourceList的size大小,而且不会出现空指针异常。
这两个问题其实都和size++这句话有关
1. size大小为什么不是1000
分析源码:
add是尾插,源码如下
-
void linkLast(E e) {
-
final Node<E> l = last;
-
final Node<E> newNode = new Node<>(l, e, null);
-
last = newNode;
-
if (l == null)
-
first = newNode;
-
else
-
l.next = newNode;
-
size++;
-
modCount++;
-
}
模拟一个场景
- size现在的值为100
- 线程a拿到了size大小100,此时cpu让出执行权给线程b
- 线程b拿到了size大小也为100
- 那么无论他们谁先加1,最后size的值会被101覆盖两次,导致size的大小不会是102
2. 再次遍历target的时候为什么会报空指针异常问题
其实也和尾插法这段代码有关
模拟场景:
-
设现在的size为100
-
线程a拿到了链表尾部元素last 之后让出执行权给线程b
-
线程b也拿到了相同的last之后一直执行完了add操作,此时size = 101
-
线程a也执行了 l.next = newNode;覆盖了线程b的在size = 101 位置上的值,之后也进行了size++的操作,size = 102
-
此时出现的问题就是size = 101 位置上的值被覆盖了两次,但是102位置上的值是null。
-
所以遍历的时候会报空指针异常。
-
因为last = newNode;这一步的重复覆盖,可以预测所有的null都会是链表末尾。(参考上面的图)
3. 关于为什么foreach增强for循环为什么会报空指针的问题
Linkedlist的foreach循环,是依赖Iterator的,LinkedList也有自己的迭代器,源码如下
可以看出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<String> 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是读写分离的,只对写操作加锁,但是每次写操作(添加和删除元素等)时都会复制出一个新数组,完成修改后,然后将新数组设置到旧数组的引用上,所以在写比较多的情况下,会有很大的性能开销,所以适合读比较多的应用场景。
问题描述
获取授权列表时,有时成功,有时400
问题起因
需求:用户查看授权列表时,不显示自己的被授权记录
修改方案:查询到授权列表时,通过stream流式计算进行过滤,过滤掉当前用户(ThreadLocal中存放当前登陆用户信息),
问题排查
1. 查看日志
通过日志可以看到是UserRoleServiceImpl
中第108行发生了空指针异常
2. 在108行处添加断点调试并在断点处设置条件,当threadlocal为空或用户信息为空时进入断点,开始访问接口测试(多试几次,有不为空的情况)
UserThreadLocal.get()==null||u.getUserCode()==null
3. 当进入断点时,发现threadlocal为空,排查threadLocal.set(),有没有调用
4. 当我看到线程运行状态时发现,当前运行的线程是ForkJoin,知道了问题出在哪里,parallelStream()在进行计算时调用了forkjoin,看一下threadlocal的原理
问题解决方案
parallelStream改为stream解决问题