写点什么

如果非要在多线程中使用 ArrayList 会发生什么?

用户头像
看山
关注
发布于: 2021 年 02 月 04 日
如果非要在多线程中使用ArrayList会发生什么?

你好,我是看山。


我们都知道,Java 中的 ArrayList 是非线程安全的,这个知识点太熟了,甚至面试的时候都很少问了。


但是我们真的清楚原理吗?或者知道多线程情况下使用 ArrayList 会发生什么?


前段时间,我们就踩坑了,而且直接踩了两个坑,今天就来扒一扒。


翠花,上源码


上代码之前先说下ArrayListadd逻辑:


  1. 检查队列中数组是否还没有添加过元素

  2. 如果是,设置当前需要长度为 10,如果否,设置当前需要长度为当前队列长度+1

  3. 判断需要长度是否大于数组大小

  4. 如果是,需要扩容,将数组长度扩容 1.5 倍(第一次扩容会从 0 直接到 10,后续会按照 1.5 倍的步幅增长)

  5. 数组中添加元素,队列长度+1


附上代码,有兴趣的可以在看看源码。


/** * Appends the specified element to the end of this list. * * @param e element to be appended to this list * @return <tt>true</tt> (as specified by {@link Collection#add}) */public boolean add(E e) {    // 判断数组容量是否足够,如果不足,增加1.5倍,size是当前队列长度    ensureCapacityInternal(size + 1);  // Increments modCount!!    // 给下标为size的赋值,同时队列长度+1,下标从0开始    elementData[size++] = e;    return true;}
private void ensureCapacityInternal(int minCapacity) { ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));}
private static int calculateCapacity(Object[] elementData, int minCapacity) { // 判断是否首次添加元素,如果是,返回默认队列长度,现在是10 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } // 如果不是首次添加元素,就返回当前队列长度+1 return minCapacity;}
private void ensureExplicitCapacity(int minCapacity) { modCount++;
// overflow-conscious code // 如果需要的长度大于队列中数组长度,扩容,如果可以满足需求,就不用扩容 if (minCapacity - elementData.length > 0) grow(minCapacity);}
/** * Increases the capacity to ensure that it can hold at least the * number of elements specified by the minimum capacity argument. * * @param minCapacity the desired minimum capacity */private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; // 这里就是扩容1.5倍的代码 int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) 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);}
复制代码


就是这么不安全


从上面代码可以看出,ArrayList中一丁点考虑多线程的元素都没有,完全的效率优先。


奇怪的 ArrayIndexOutOfBoundsException


先做一个假设,此时数组长度达到临界边缘,比如目前容量是 10,现在已经有 9 个元素,也就是 size=9,然后有两个线程同时向队列中增加元素:


  1. 线程 1 开始进入add方法,获取 size=9,调用ensureCapacityInternal方法进行容量判断,此时数组容量是 10,不需要扩容

  2. 线程 2 也进入add方法,获取 size=9,调用ensureCapacityInternal方法进行容量判断,此时数组容量还是 10,也不需要扩容

  3. 线程 1 开始赋值值了,也就是elementData[size++] = e,此时 size 变成 10,达到数组容量极限

  4. 线程 2 此次开始执行赋值操作,使用的 size=10,也就是elementData[10] = e,因为下标从 0 开始,目前数组容量是 10,直接报数组越界ArrayIndexOutOfBoundsException


仅仅差了一步,线程 2 就成为了抛异常的凶手。但是抛出异常还是好的,因为我们知道出错了,可以沿着异常


诡异的 null 元素


这种情况不太容易从代码中发现,得对代码稍加改造,elementData[size++] = e这块代码其实执行了两步:


elementData[size] = e;size++;
复制代码


假设还是有两个线程要赋值,此时数组长度还比较富裕,比如数组长度是 10,目前 size=5:


  1. 线程 1 开始进入add方法,获取 size=5,调用ensureCapacityInternal方法进行容量判断,此时数组容量是 10,不需要扩容

  2. 线程 2 也进入add方法,获取 size=5,调用ensureCapacityInternal方法进行容量判断,此时数组容量还是 10,也不需要扩容

  3. 线程 1 开始赋值,执行elementData[size] = e,此时 size=5,在执行size++之前,线程 2 开始赋值了

  4. 线程 2 开始赋值,执行elementData[size] = e,此时 size 还是 5,所以线程 2 把线程 1 赋的值覆盖了

  5. 线程 1 开始执行size++,此时 size=6

  6. 线程 2 开始执行size++,此时 size=7


也就是说,添加了 2 个元素,队列长度+2,但是真正加入队列的元素只有 1 个,有一个被覆盖了。


这种情况不会立马报错,排查起来就很麻烦了。而且随着 JDK 8 的普及,可能随手使用 filter 过滤空元素,这样就不会立马出错,直到出现业务异常之后才能发现,到那时,错误现场已经不见了,排查起来一头雾水。


有同学会问,源码中是elementData[size++] = e,是一行操作,为什么会拆成两步执行呢?其实这得从 JVM 字节码说起了。


通过 JVM 字节码说说第二种异常出现的原因


先来一段简单的代码:


public class Main {    public static void main(String[] args) {        int[] nums = new int[3];        int index = 0;        nums[index++] = 5;    }}
复制代码


通过javac Main.javajavap -v -l Main.class组合操作得到字节码:


下面那些中文是我后加的备注,备注中还列出了局部变量表和栈值的变化,需要有点耐心。


public class Main  minor version: 0  major version: 52  flags: ACC_PUBLIC, ACC_SUPERConstant pool:   #1 = Methodref          #3.#12         // java/lang/Object."<init>":()V   #2 = Class              #13            // Main   #3 = Class              #14            // java/lang/Object   #4 = Utf8               <init>   #5 = Utf8               ()V   #6 = Utf8               Code   #7 = Utf8               LineNumberTable   #8 = Utf8               main   #9 = Utf8               ([Ljava/lang/String;)V  #10 = Utf8               SourceFile  #11 = Utf8               Main.java  #12 = NameAndType        #4:#5          // "<init>":()V  #13 = Utf8               Main  #14 = Utf8               java/lang/Object{  public Main();    descriptor: ()V    flags: ACC_PUBLIC    Code:      stack=1, locals=1, args_size=1         0: aload_0         1: invokespecial #1                  // Method java/lang/Object."<init>":()V         4: return      LineNumberTable:        line 1: 0
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=3, args_size=1 局部变量表 栈 0: iconst_3 // 将int型(3)推送至栈顶 args 3 1: newarray int // 创建一个指定原始类型(如int, float, char…)的数组,并将其引用值压入栈顶 args 数组引用 3: astore_1 // 将栈顶引用型数值存入第二个本地变量 args, nums=数组引用 null 4: iconst_0 // 将int型(0)推送至栈顶 args, nums=数组引用 0 5: istore_2 // 将栈顶int型数值存入第三个本地变量 args, nums=数组引用, index=0 null 6: aload_1 // 将第二个引用类型本地变量推送至栈顶 args, nums=数组引用, index=0 数组引用 7: iload_2 // 将第三个int型本地变量推送至栈顶 args, nums=数组引用, index=0 0, 数组引用 8: iinc 2, 1 // 将指定int型变量增加指定值(i++, i--, i+=2),也就是第三个本地变量增加1 args, nums=数组引用, index=1 0, 数组引用 11: iconst_5 // 将int型(5)推送至栈顶 args, nums=数组引用, index=1 5, 0, 数组引用 12: iastore // 将栈顶int型数值存入指定数组的指定索引位置 args, nums=数组引用, index=1 null 13: return // 从当前方法返回void LineNumberTable: line 3: 0 // int[] nums = new int[3]; line 4: 4 // int index = 0; line 5: 6 // nums[index++] = 5; line 6: 13 // 方法结尾默认的return}
复制代码


从上面的字节码可以看到,nums[index++] = 5这一句会被转为 5 个指令,是从 6 到 12。大体操作如下:


  1. 将数组、下标压入栈

  2. 给下标加值

  3. 将新值压入栈

  4. 取栈顶三个元素开始给元素指定下标赋值


也即是说,错误出在数组赋值操作时先将数组引用和下标同时压入栈顶,与下标赋值是两步,在多线程环境中,就有可能出现上面说到的 null 值存在。


解法


其实解法也很简单,就是要意识到多线程环境,然后不使用 ArrayList。可以使用Collections.synchronizedList()返回的同步队列,也可以使用CopyOnWriteArrayList这个队列,或者自己扩展ArrayList,将 add 方法做成同步方法。


文末总结


ArrayList整个类的操作都是非线程安全的,一旦在多线程环境中使用,就可能会出现问题。上面提到add操作就会有两种异常行为,一个是数组越界异常,一个是出现丢数且出现空值。这还只是最简单的add操作,如果addaddAllget混合使用使用时,异常情况就更多了。所以,使用的时候一定要注意是不是单线程操作,如果不是,果断使用其他队列防雷。




你好,我是看山,公众号:看山的小屋,10 年老后端,Apache Storm、WxJava、Cynomys 开源贡献者。主业:程序猿,兼职:架构师。游于码界,戏享人生。


发布于: 2021 年 02 月 04 日阅读数: 41
用户头像

看山

关注

公众号:看山的小屋 2017.10.26 加入

游于码界,戏享人生。

评论

发布
暂无评论
如果非要在多线程中使用ArrayList会发生什么?