做Java开发的同学,几乎每天都要和集合打交道,而ArrayList更是家常便饭——简单、高效、易用,大部分业务场景下用它都没毛病。但最近做项目迭代时,我踩了个大坑:线上环境突然报出ConcurrentModificationException,排查了大半天,最后发现居然是ArrayList在多线程下“翻车”了。

相信很多小伙伴也遇到过类似问题,要么是调试时没复现,上线就出问题;要么是知道ArrayList不安全,却不知道该用哪种方式替代,或者替代后性能又出了新问题。今天就结合我实际踩过的坑,跟大家好好聊聊ArrayList的线程安全问题,没有复杂的源码堆砌,全是项目里能用得上的干货,新手也能看懂。

先跟大家说下我遇到的场景:项目里有个异步任务,多线程同时往一个ArrayList里添加数据,然后主线程遍历这个集合做批量处理。本地测试时,用少量数据跑,一切正常;上线后,用户量上来了,并发一高,就频繁抛出ConcurrentModificationException,甚至偶尔会出现数据丢失、数组越界的情况。

一开始我以为是代码逻辑有问题,反复检查了遍历和添加的时机,确认没有手动修改集合的地方,最后才反应过来——ArrayList本身就不是线程安全的,多线程环境下,添加和遍历操作同时进行,就会出现并发修改异常。

为什么ArrayList线程不安全?

这里不跟大家扯太多源码细节,只说最核心的点,大家能理解就行。ArrayList的底层是数组,它的add、remove等方法都没有加锁,也没有任何并发控制机制。

举个简单的例子:当两个线程同时往ArrayList里添加元素时,线程A正在执行扩容操作,线程B同时执行add,就可能导致数组下标越界;而当一个线程在遍历集合(比如用for-each循环),另一个线程在修改集合(add/remove),就会触发fast-fail机制,抛出ConcurrentModificationException。

可能有同学会说:“我平时用for循环遍历,然后在循环里remove元素,也没报错啊?”那是因为单线程环境下,没有并发冲突,fast-fail机制不会触发;但多线程环境下,这种操作的风险极高,只是你没遇到而已。

项目中常用的3种解决方案

遇到问题不可怕,关键是找到适合自己项目场景的解决方案。我整理了3种项目中最常用的方式,各有优缺点,大家可以根据自己的并发量、性能要求来选择。

方案1:使用Vector

Vector是Java早期提供的集合类,它和ArrayList用法几乎一样,区别在于Vector的所有方法都加了synchronized锁,是线程安全的。

用法很简单,直接把ArrayList换成Vector就行:

1// 原来的ArrayList
2List<String> list =newArrayList<>();
3// 替换成Vector
4List<String> list =newVector<>();

优点:无需修改太多代码,上手成本极低,适合快速临时解决问题。

缺点:性能太差。Vector的锁是加在方法上的,不管是add还是get,都会加锁,即使是读操作,也会阻塞其他线程,并发量高的时候,会严重影响接口响应速度。所以除非是并发量极低的场景,否则不推荐使用。

方案2:使用Collections.synchronizedList

这是我目前项目中用得最多的方式,它是Java提供的工具类,能把一个非线程安全的List包装成线程安全的List。

用法也很简单:

1// 包装ArrayList,得到线程安全的List
2List<String> list =Collections.synchronizedList(newArrayList<>());

这里有个注意点:虽然synchronizedList是线程安全的,但遍历的时候,还是需要手动加锁,否则依然可能抛出ConcurrentModificationException。

正确的遍历方式:

1synchronized(list){
2for(String str : list){
3// 遍历操作
4System.out.println(str);
5}
6}

优点:比Vector性能好很多,它的锁是对象锁,只有修改操作(add、remove)会加锁,读操作(get)不会阻塞,适合大部分并发场景;而且用法简单,几乎不用修改原有业务逻辑。

缺点:遍历的时候需要手动加锁,稍微有点麻烦;如果并发量极高(比如每秒上万次修改),性能可能不够理想。

方案3:使用CopyOnWriteArrayList

CopyOnWriteArrayList是Java并发包(java.util.concurrent)提供的类,它的核心思想是“读写分离”,也是线程安全的,而且遍历的时候不需要加锁。

用法和ArrayList完全一样:

1List<String> list =newCopyOnWriteArrayList<>();

它的原理很简单:当执行add、remove等修改操作时,会先复制一份原数组,在复制后的数组上进行修改,修改完成后,再把引用指向新数组;而读操作(get、遍历)直接操作原数组,不需要加锁。

优点:读操作性能极高,不会被修改操作阻塞;遍历的时候不需要加锁,代码更简洁;适合高并发、读多写少的场景(比如日志收集、数据展示等)。

缺点:修改操作性能较差,因为每次修改都会复制数组,占用额外的内存;而且修改操作和读操作之间存在延迟,可能会读取到旧数据(如果业务允许数据有轻微延迟,这不是问题)。

总结:3种方案怎么选?

结合我自己的项目经验,给大家一个明确的选择建议,避免大家踩坑:

  • 如果并发量极低(比如单线程或偶尔几个线程),只是临时解决问题,用Vector(最简单,但不推荐长期使用);

  • 如果是普通并发场景,读多写少,想兼顾简单和性能,用Collections.synchronizedList(最推荐,大部分项目都适用);

  • 如果是高并发场景,读操作非常多,写操作很少,且能接受轻微的数据延迟,用CopyOnWriteArrayList。

最后再提醒大家一句:ArrayList虽然好用,但一定要注意它的线程安全问题,尤其是在异步、多线程场景下,不要想当然地使用。我这次就是因为疏忽,没考虑到多线程并发,导致线上出了问题,排查了大半天,耽误了上线进度,大家一定要引以为戒。

另外,大家在项目中还遇到过哪些ArrayList的坑?或者有其他更好的线程安全解决方案,欢迎在评论区留言交流,一起避坑,一起进步~

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐