ArrayList并发场景下出现NULL元素

起因

早上收到通知,负责的模块中有一个借口出现了借口超时的问题,通过阿里云的开源工具Arthas监控接口的调用情况,发该接口中有一段代码循环了4000次,每次循环耗时在0.3ms到6ms之间,最终导致了接口的整体超时

List<Result> results = new ArrayList<>();
ids.forEach(
       it -> {
           Result result = findSomeThingFromRedis(it);
           results.add(result);
       }
);
results.stream().filter(it -> it.getType() != 0).collect(Collectors.toList());

出现问题的代码段如上所示,其中的ids就是一个含有Long类型元素的列表,然后对每个id,通过Redis进行具体的数据的查询

分析

首先通过查看上述代码,首先想到的是实现一个批量查询的方法,将所有的id作为一个入参直接查询,只进行一次查询。简单翻阅资料发现redis并不支持这种查询方式,毕竟不是关系型数据库。而且id的组合很多,不可能进行所有场景的缓存,所以只能放弃这个方法。

然后思考能不能去掉缓存直接用MYSQL进行一次批量查询,但是这个想法在一瞬间也被打消了,作为一个被频繁调用的方法,如果只是为了应对一些极端场景下的id特别多的查询条件,而去掉缓存,会导致其他场景下的调用该方法的接口响应性能大大降低。

最后决定通过并发的操作来缩短查询时间,因为该方法耗时比较长的主要原因是服务器与redis的IO开销,是一个IO密集型的方法,所以通过并发是可以大大缩短执行时间的。

第一次修改

修改后的代码如下图所示,直接利用stream的并发流进行并发查询。
List<Result> results = new ArrayList<>();
ids.parallelStream().forEach(
        it -> {
            Result result = findSomeThingFromRedis( it);
            results.add(result);
        }
);
results.stream().filter(it -> it.getType() != 0).collect(Collectors.toList());

将代码部署到线上后,测试了几次,发现确实大大的缩短了查询时间,在同等级别ID数量的情况下,大概从原来的3S耗时降低到了1S左右。但是不好的消息是,测试过程中发生了几次空指针的问题,而且复现的比较问题,几乎调用几次接口就会出现一次空指针。

空指针问题分析

通过对异常栈信息的查看发现问题主要发生在这句代码上
results.stream().filter(it -> it.getType() != 0).collect(Collectors.toList());
这句代码有两个地方可能会出现空指针
  • results.stream() 当results为空时,会出现空指针
  • 当list容器中存在null元素时,导致it.getType() 出现空指针。
    首先由于一开始就对容器进行过初始化,所以排除第一种情况
    那么就只可能是由于容器中包含NULL元素导致的了。

所以猜测是由于容器中被加入了NULL元素导致调用it.getType()时出现了空指针。

空指针问题排查

两段代码的区别就是是否进行了并发查询的,所以推测是由于并发查询导致的空指针问题。然后检查代码发现确实用到了非并发安全的ArrayList,遂猜测是由于ArrayList非线程安全,在添加元素时并未加锁,所以导致添加元素时出现了某种错误,导致了NULL元素的出现。所以对代码进行如下修改。

List<Result> results = new CopyOnWriteArrayList<>();
ids.parallelStream().forEach(
        it -> {
            Result result = findSomeThingFromRedis( it);
            results.add(result);
        }
);
results.stream().filter(it -> it.getType() != 0).collect(Collectors.toList());

将ArrayList改为了线程安全CopyOnWriteArrayList,然后提交代码并部署,果然空指针问题解决了。不过由于CopyOnWriteArrayList的添加操作加了锁,所以执行效率又从1S提升到了2S。

ArrayList中NULL问题分析(其实ArrayList在并发下不止这一个问题,通过下面的例子我将逐一分析ArrayList在并发下可能出现的所有问题)

arrayList进行元素添加是进行了两步操作

  • 判断是否需要扩容,若需要则扩容。
  • 将元素插入到容器中。

我自己用下面这段代码测试

final List<Integer> list = new ArrayList<Integer>();

final int[] a = {0};
// 线程A将0-100添加到list
new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 100 ; i++) {
            list.add(a[0]++);

            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}).start();

// 线程B将100-200添加到列表
new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 100; i < 200 ; i++) {
            list.add(a[0]++);

            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}).start();

Thread.sleep(1000);

// 打印所有结果
for (int i = 0; i < list.size(); i++) {
    System.out.println("第" + (i + 1) + "个元素为:" + list.get(i));
}

正常情况下,应输出第1个元素为:0 — 第200个元素为:199

但当我指定了容器大小是200时,会出现元素数量出现重复,容器大小 < 200等问题的问题(元素丢失)

不指定容器大小时,多执行几遍,会出现数组越界,空元素,元素重复,容器大小 < 200等问题(元素丢失)

所以理性猜测是由于容器扩容出现空元素问题,而元素插入会导致元素重复。

查看ArrayList 的add源码,确实进行了扩容和元素添加的操作

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}
先分析元素重复及丢失的问题

因为size++不是个原子操作,他可以理解为 size = size + 1 ,也就是可以分为两步 现货区size的值 ,然后对size 的值 +1。
同理a[0]++
那么假设存在两个线程A,B,A先获取size = 4,此时A中断了。B获取size = 4,通过对size++的理解,此时将发生两步,先将 elementData[size(4)] 赋值为4 ,然后对size进行+1操作,即容器下表右移。然后B中断,A线程重新获取执行权,此时A线程中的size=4,所以他将数据插入elementData中的下标为4的位置,导致元素数量减少。
通过元素重复,是由于A线程获取a[0]时,还未进行++ 操作,B线程也获取a[0],导致向容器中插入了相同的元素。

然后分析NULL问题
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length; 旧数组大小
    int newCapacity = oldCapacity + (oldCapacity >> 1); 新数组大小先设置成就数组大小的1.5if (newCapacity - minCapacity < 0) 若扩容1.5倍后还不够,直接扩容成现在需要的大小
        newCapacity = minCapacity; 
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity); 将旧数据拷贝入扩容后的新数组中。
}

通过分析源码可知,扩容流程当要发生扩容操作时,是先获取新的elementData的大小,然后将旧元素拷贝入新的数组中。所以如果两个线程同时出发扩容,可能就会导致出现NULL元素的问题,如下图所示
在这里插入图片描述

最后分析下标越界的问题(ArrayList默认大小为10)

存在A,B两个线程。此时容器中有9个元素。然后A,B都对容器进行元素插入,本来A与B执行插入操作会导致容器中有11个元素,但是在某些极端场景下。A获取容器size,发现size = 9,不执行扩容操作,此时A被打断了。B后去容器size,发现size=9,不执行扩容操作,插入元素。然后A重新获取执行权。由于已经判断过容器大小,所以A线程会直接进行插入操作,但此时容器中已经有10个元素了,然后A元素就将元素插入到第11个位置导致出现下标越界问题。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值