一、写在开头
我们在学习集合或者说容器的时候了解到,很多集合并非线程安全的,在并发场景下,为了保障数据的安全性,诞生了并发容器,广为人知的有ConcurrentHashMap、ConcurrentLinkedQueue、BlockingQueue等,那你们知道ArrayList也有自己对应的并发容器嘛?
作为使用频率最高的集合类之一,ArrayList线程不安全,我们在并发环境下使用,一般要辅以手动上锁、或者通过Collections.synchronizedList()转一手,为了解决这一问题,Doug Lea(道格.利)大师为我们提供了它的并发类——CopyOnWriteArrayList
。
二、认知CopyOnWriteArrayList
CopyOnWriteArrayList 是java.util.concurrent的并发类,线程安全,遵循写时复制的原则(CopyOnWrite),什么意思呢?就是我们在对列表进行增删改时,会先创建一个列表的副本,在副本中完成增删改操作后,再将副本替换原列表,整个过程旧的列表并没有锁定,因此原来的读取操作仍可继续。
看到这里细心的同学应该已经发现了它的“弊端”了,先赋值副本,写完再替换,这是有时间差的,没错,这就是CopyOnWrite的延时更新策略,我们在发生写的同时,不阻塞读,但读取的只是旧列表中的数据,直到引用替换完成,可以保证数据的最终一致性,无法保证实时性。
三、实现原理(源码)
我们来看一下CopyOnWriteArrayList底层的源码实现,首先在内部维护了一个数组,用volatile关键字修饰,保证了数据的内存可见性
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
读取:get()方法
public E get(int index) {
return get(getArray(), index);
}
/**
* Gets the array. Non-private so as to also be accessible
* from CopyOnWriteArraySet class.
*/
final Object[] getArray() {
return array;
}
private E get(Object[] a, int index) {
return (E) a[index];
}
这段源码没什么,很好理解,就是普通的读取数组的操作,这也能看出CopyOnWriteArrayList的读是不阻塞的。
新增:add()方法
public boolean add(E e) {
final ReentrantLock lock = this.lock;
//1. 使用Lock,保证写线程在同一时刻只有一个
lock.lock();
try {
//2. 获取旧数组引用
Object[] elements = getArray();
int len = elements.length;
//3. 创建新的数组,并将旧数组的数据复制到新数组中
Object[] newElements = Arrays.copyOf(elements, len + 1);
//4. 往新数组中添加新的数据
newElements[len] = e;
//5. 将旧数组引用指向新的数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
final void setArray(Object[] a) {
array = a;
}
通过这段源码,我们就能够感知到前面描述的实现原理了,首先,新增元素时,内部通过可重入锁进行锁定,说明写时会独占,然后,再将原数组赋值到一个新数组中,最后,将旧数组的引用指向新数组。
四、使用注意事项,用不好坑死你
对于CopyOnWriteArrayList的日常使用,和ArrayList几乎一模一样,在这里就不用过多介绍了,但它的使用还是需要注意的,虽然可以保证线程安全,但因其特性所致,仅适应于读多写少的并发环境,对于频繁写入或者写入的对象较大,一定不要使用CopyOnWriteArrayList容器,不然会坑死你的!
【举个例子】
之前在这篇文章中:EasyExcel导入导出百万数据量
采用了CopyOnWriteArrayList,以此来保证在多线程写入数据库时的线程安全,由于写入的excel文件中有100万的数据量,再导入的时候非常之慢,用了514秒!
核心实现代码如下,具体内容实现可去看那篇文章哈
@Slf4j
@Service
public class EasyExcelImportHandler implements ReadListener<User> {
/*成功数据*/
private final CopyOnWriteArrayList<User> successList = new CopyOnWriteArrayList<>();
/*单次处理条数*/
private final static int BATCH_COUNT = 20000;
@Resource
private ThreadPoolExecutor threadPoolExecutor;
@Resource
private UserMapper userMapper;
@Override
public void invoke(User user, AnalysisContext analysisContext) {
if(StringUtils.isNotBlank(user.getName())){
successList.add(user);
return;
}
if(successList.size() >= BATCH_COUNT){
log.info("读取数据:{}", successList.size());
saveData();
}
}
/**
* 采用多线程读取数据
*/
private void saveData() {
List<List<User>> lists = ListUtil.split(successList, 20000);
CountDownLatch countDownLatch = new CountDownLatch(lists.size());
for (List<User> list : lists) {
threadPoolExecutor.execute(()->{
try {
userMapper.insertSelective(list.stream().map(o -> {
User user = new User();
user.setName(o.getName());
user.setId(o.getId());
user.setPhoneNum(o.getPhoneNum());
user.setAddress(o.getAddress());
return user;
}).collect(Collectors.toList()));
} catch (Exception e) {
log.error("启动线程失败,e:{}", e.getMessage(), e);
} finally {
//执行完一个线程减1,直到执行完
countDownLatch.countDown();
}
});
}
// 等待所有线程执行完
try {
countDownLatch.await();
} catch (Exception e) {
log.error("等待所有线程执行完异常,e:{}", e.getMessage(), e);
}
// 提前将不再使用的集合清空,释放资源
successList.clear();
lists.clear();
}
/**
* 所有数据读取完成之后调用
* @param analysisContext
*/
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
//读取剩余数据
if(CollectionUtils.isNotEmpty(successList)){
log.info("读取数据:{}条",successList.size());
saveData();
}
}
}
而将这段代码中的CopyOnWriteArrayList换为ArrayList。
/*成功数据*/
// private final CopyOnWriteArrayList<User> successList = new CopyOnWriteArrayList<>();
private final List<User> successList = new ArrayList<>();
导入100万数据量的耗时,直接从分钟降为秒级,由此可见CopyOnWriteArrayList在写入大对象时的性能非常之差!
五、总结
通过以上的学习,我们进行总结:CopyOnWriteArrayList的优势在于可以保证线程安全的同时,不阻塞读操作,但是这仅限于读多写少的情况;
在写多读少的情况下,或者写入的对象占用内容较大时,不建议使用CopyOnWriteArrayList;CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器,最好通过 ReentrantReadWriteLock 自定义一个的列表。
六、结尾彩蛋
如果本篇博客对您有一定的帮助,大家记得留言+点赞+收藏呀。原创不易,转载请联系Build哥!
如果您想与Build哥的关系更近一步,还可以关注“JavaBuild888”,在这里除了看到《Java成长计划》系列博文,还有提升工作效率的小笔记、读书心得、大厂面经、人生感悟等等,欢迎您的加入!