「这是我参与11月更文挑战的第15天,活动详情查看:2021最后一次更文挑战」
本文被《从小工到专家的 Java 进阶之旅》收录。
你好,我是看山。
我们都知道,Java中的ArrayList是非线程安全的,这个知识点太熟了,甚至面试的时候都很少问了。
但是我们真的清楚原理吗?或者知道多线程情况下使用ArrayList会发生什么?
前段时间,我们就踩坑了,而且直接踩了两个坑,今天就来扒一扒。
翠花,上源码
上代码之前先说下ArrayList
的add
逻辑:
- 检查队列中数组是否还没有添加过元素
- 如果是,设置当前需要长度为10,如果否,设置当前需要长度为当前队列长度+1
- 判断需要长度是否大于数组大小
- 如果是,需要扩容,将数组长度扩容1.5倍(第一次扩容会从0直接到10,后续会按照1.5倍的步幅增长)
- 数组中添加元素,队列长度+1
附上代码,有兴趣的可以在看看源码。
1 | java复制代码/** |
就是这么不安全
从上面代码可以看出,ArrayList
中一丁点考虑多线程的元素都没有,完全的效率优先。
奇怪的ArrayIndexOutOfBoundsException
先做一个假设,此时数组长度达到临界边缘,比如目前容量是10,现在已经有9个元素,也就是size=9,然后有两个线程同时向队列中增加元素:
- 线程1开始进入
add
方法,获取size=9,调用ensureCapacityInternal
方法进行容量判断,此时数组容量是10,不需要扩容 - 线程2也进入
add
方法,获取size=9,调用ensureCapacityInternal
方法进行容量判断,此时数组容量还是10,也不需要扩容 - 线程1开始赋值值了,也就是
elementData[size++] = e
,此时size变成10,达到数组容量极限 - 线程2此次开始执行赋值操作,使用的size=10,也就是
elementData[10] = e
,因为下标从0开始,目前数组容量是10,直接报数组越界ArrayIndexOutOfBoundsException
。
仅仅差了一步,线程2就成为了抛异常的凶手。但是抛出异常还是好的,因为我们知道出错了,可以沿着异常
诡异的null元素
这种情况不太容易从代码中发现,得对代码稍加改造,elementData[size++] = e
这块代码其实执行了两步:
1 | java复制代码elementData[size] = e; |
假设还是有两个线程要赋值,此时数组长度还比较富裕,比如数组长度是10,目前size=5:
- 线程1开始进入
add
方法,获取size=5,调用ensureCapacityInternal
方法进行容量判断,此时数组容量是10,不需要扩容 - 线程2也进入
add
方法,获取size=5,调用ensureCapacityInternal
方法进行容量判断,此时数组容量还是10,也不需要扩容 - 线程1开始赋值,执行
elementData[size] = e
,此时size=5,在执行size++
之前,线程2开始赋值了 - 线程2开始赋值,执行
elementData[size] = e
,此时size还是5,所以线程2把线程1赋的值覆盖了 - 线程1开始执行
size++
,此时size=6 - 线程2开始执行
size++
,此时size=7
也就是说,添加了2个元素,队列长度+2,但是真正加入队列的元素只有1个,有一个被覆盖了。
这种情况不会立马报错,排查起来就很麻烦了。而且随着JDK 8的普及,可能随手使用filter过滤空元素,这样就不会立马出错,直到出现业务异常之后才能发现,到那时,错误现场已经不见了,排查起来一头雾水。
有同学会问,源码中是elementData[size++] = e
,是一行操作,为什么会拆成两步执行呢?其实这得从JVM字节码说起了。
通过JVM字节码说说第二种异常出现的原因
先来一段简单的代码:
1 | java复制代码public class Main { |
通过javac Main.java
和javap -v -l Main.class
组合操作得到字节码:
下面那些中文是我后加的备注,备注中还列出了局部变量表和栈值的变化,需要有点耐心。
1 | less复制代码public class Main |
从上面的字节码可以看到,nums[index++] = 5
这一句会被转为5个指令,是从6到12。大体操作如下:
- 将数组、下标压入栈
- 给下标加值
- 将新值压入栈
- 取栈顶三个元素开始给元素指定下标赋值
也即是说,错误出在数组赋值操作时先将数组引用和下标同时压入栈顶,与下标赋值是两步,在多线程环境中,就有可能出现上面说到的null值存在。
解法
其实解法也很简单,就是要意识到多线程环境,然后不使用ArrayList。可以使用Collections.synchronizedList()
返回的同步队列,也可以使用CopyOnWriteArrayList
这个队列,或者自己扩展ArrayList
,将add方法做成同步方法。
文末总结
ArrayList
整个类的操作都是非线程安全的,一旦在多线程环境中使用,就可能会出现问题。上面提到add
操作就会有两种异常行为,一个是数组越界异常,一个是出现丢数且出现空值。这还只是最简单的add
操作,如果add
、addAll
和get
混合使用使用时,异常情况就更多了。所以,使用的时候一定要注意是不是单线程操作,如果不是,果断使用其他队列防雷。
推荐阅读
- JDK中居然也有反模式接口常量
- java import 导入包时,我们需要注意什么呢?
- Java 并发基础(一):synchronized 锁同步
- Java 并发基础(二):主线程等待子线程结束
- Java 并发基础(三):再谈 CountDownLatch
- Java 并发基础(四):再谈 CyclicBarrier
- Java 并发基础(五):面试实战之多线程顺序打印
- 如果非要在多线程中使用ArrayList会发生什么?
- 重新认识 Java 中的队列
- Java 中 Vector 和 SynchronizedList 的区别
- 如果非要在多线程中使用 ArrayList 会发生什么?(第二篇)
- 一文掌握 Java8 Stream 中 Collectors 的 24 个操作
- 一文掌握 Java8 的 Optional 的 6 种操作
你好,我是看山。游于码界,戏享人生。如果文章对您有帮助,请点赞、收藏、关注。欢迎关注公众号「看山的小屋」,发现不一样的世界。
本文转载自: 掘金