计算机知识分享网

【java-list】源码解析多线程中list空指针和size大小异常问题

问题描述

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

测试代码源码

package com.david.jdk8;


import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

/**
 * @author David Zheng
 * @date 2022/12/08
 * @email dmcxiaobing@foxmail.com
 * <p>
 * 线程安全相关的
 */

public class ThreadSafeList {
    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大小也有问题)  LinkedList 

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

list用的LinkedList时 原因分析:

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

1、size大小为什么不是1000

分析源码:
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循环为什么会报空指针的问题

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

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

list用的ArrayList时:

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

分析源码:
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,此时就会出现数组越界异常。

解决办法

  1. 使用线程安全的ArrayList:CopyOnWriteArrayList

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

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

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

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

ConcurrentLinkedQueueLinkedBlockingQueue

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

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

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

CopyOnWriteArrayList

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

#Java-List