ArrayList并发写出现Null值

时间:2022-07-23
本文章向大家介绍ArrayList并发写出现Null值,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

ArrayList并非线程安全的容器,这一点大家可能都非常清楚,但是在并发写入的情况下,不安全的情况具体有哪些,大家是否很清楚呢?本篇文章重点聊一下出现null的情况,然后对于其他并发写的安全做一个简单的叙述

我们看下面的代码,打印List的元素数量以及打印存储的元素

        List<Integer> list = new ArrayList<>();
        for (int i=0;i<10;i++) {
            int finalI = i;
            new Thread(()->{
                list.add(finalI +1);
            }).start();

        }
        System.out.println(list.size());
        System.out.println(list.toString());

最理想的情况下,打印结果应该如下:

10
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

但是有可能出现一些其他问题,就像下面结果List元素出现null值的结果

10
[null, 1, 3, 4, 5, 6, 7, 8, 9, 10]
或者
10
[null, 2, 3, 4, 5, 6, 7, 8, 9, 10]
或者
10
[null, null, null, 1, 5, 6, 7, 8, 9, 10]
......

在我看百度看到的所有答案中,关于并发写出现Null值,几乎都是将原因归咎到add方法中的size++上,这里我个人认为这种回答应该是错误的,出现null值的原因应该是扩容所造成的。

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

首先说一下为什么我觉得网上的答案是错误的,我们模拟add方法,然后使用javap命令拿到class的字节码看一下:

#### Java程序
int size = 0;
int[] elementDate = new int[5];
public void add() {
        elementDate[size++] = 10;
}
#### Javap 得到的字节码    
public void add();
  Code:
       0: aload_0
       1: getfield      #3 // Field elementDate:[I
       4: aload_0
       5: dup
       6: getfield      #2// Field size:I
       9: dup_x1
      10: iconst_1
      11: iadd
      12: putfield      #2// Field size:I
      15: bipush        10
      17: iastore
      18: return

在add方法的字节码中,通过getfield拿到elementDate数组放入栈顶(操作数栈),然后dup命令复制栈顶的数组并将复制值压入栈顶,然后再通过getfield获取size数值,下一步dup_x1命令会将栈顶的数值size复制两份,并将两个复制值压入栈顶,然后iconst_1命令将数值1压入栈顶,再使用iadd命令对栈顶的两个元素进行相加,并通过putfield将size更新,最后iastore更新数组(因为dup_x1复制了两份,所以数组的索引仍然是更新前的size)。大家可以好好想一下这个操作,无论size++多么不安全,因为索引复制两份被保存的操作数栈中,所以不可能在list中出现null值,只会出现覆盖的可能。

如果大家理解了上面的过程,我们思考下为什么null值出现了呢?由于ArrayList是基于数组实现,由于数组大小一旦确定就无法更改,所以其每次扩容都是将旧数组容器的元素拷贝到新大小的数组中(Arrays.copyOf函数),由于我们通过new ArrayList<>()实例的对象初始化的大小是0,所以第一次插入就会扩容,由于ArrayList并非线程安全,第二次插入时,第一次扩容可能并没完成,于是也会进行一次扩容(第二次扩容),这次扩容所拿到list的elementDate是旧的,并不是第一次扩容后对象,于是会因为第一次插入的值并不在旧的elementDate中,而将null值更新到新的数组中。这里我们举一个详细的例子:

现在有线程A和B分别要插入元素1和2,当线程A调用add方法时size是0,于是会进行一次扩容,此时线程B调用add方法时size仍然是0,所以也会进行扩容,假设此时线程A比线程B扩容先完成,此时list的elementDate是新的数组对象(由线程A构建),然后开始执行elementDate[size++] = 1的程序,这个过程中线程B扩容拿到的数组仍然是旧的elementDate,于是线程B构造一个新的数组(数据全部为null),然后使list的elementDate指向线程B构造的对象,那么线程A之前构造的elementDate也就被丢掉了,但是由于size已经自增,所以线程B会在索引为1的位置赋予2,那么此时数组元素就成了[null,2],当然如果线程B扩容比线程A先完成那么就可能为[null,1]。

大家如果在初始化的时候就已经开辟好足够大的容量,那么就不会出现上面的问题,关于上面的解释大家可以作为参考,因为不同的编译器可能javap得到的字节码可能会不同吧(这里我编译结果是size被复制两份,然后使用其中的一份加一更新到size中,然后用复制的另一份作为索引更新数组,但是网上得到信息大家都认为是数组先赋值,然后size自增)。

除了上面元素为null的情况外,还会有其他错误

  • 数量错误,集合数据正确
9
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

大家是不是第一反应是不是觉得这种结果是由ArrayList本身的不安全特效造成的呢?实际上这种结果和ArrayList本身没有关系,只是因为我们打印不具有原子性所造成的。因为我们启用了多线程,主线程调用size方法时,可能多线程内部对list还在继续执行增加元素的操作,当主线程调用toString方法时,多线程已经执行完毕,所以元素数量正确,当然也有可能你调用toString方法时,多线程仍然未执行完,此时size和toString结果都不正确,如下:

8
[1, 2, 3, 4, 5, 6, 7, 8, 9]
  • 覆盖,这种情况的原因在上面的分析中以及提到,因为size++并不是原子性的,所以可能线程A自增的时候,线程B也进行一次自增,但是两次自增的结果是一样的,所以先完成的线程更新的数据会被后完成的线程覆盖掉