当前位置: 首页 > 科技观察

学完CopyOnWriteArrayList,可以和面试官再聊三分钟

时间:2023-03-14 22:37:43 科技观察

ArrayList是大家比较熟悉的一个集合,而这个集合一开始是为了高效而设计的,没有考虑多线程的场景,所以也是用多线程下的CopyOnWriteArrayList的集合。回忆一下ArrayList集合的fail-fast机制和fail-safe机制:fail-fast快速失败机制,当一个线程A正在用迭代器遍历集合时,另一个线程B此时会修改集合,这会导致A快速失败并抛出ConcurrentModificationException异常。java.util中的集合类都是快速失败的。fail-safe安全失效机制在遍历的时候不在原集合上,而是先复制一个集合,在复制的集合上遍历。java.util.concurrent包中的容器类是安全失败的,建议在并发环境中使用该包中的集合类。ArrayList定义:publicclassArrayListextendsAbstractListimplementsList,RandomAccess,Cloneable,java.io.Serializable{}ArrayList简介:ArrayList是一个可变数组,实现了List接口,允许包含null的重复元素实现底层数组。扩容时,将旧数组元素复制到新数组中,每次扩容是其容量的1.5倍,操作成本高。采用Fail-Fast机制。面对并发修改,迭代器会很快彻底失败,而不是冒着未来不确定时间任意不确定行为的风险多线程可以选择关注问题:ArrayList的默认大小(为什么是这个?),扩展机制?ArrayList默认初始化大小为10(新建时还是空,只有第一个元素放入时才会变成10),如果知道ArrayList的大概容量,可以在初始化时指定大小,并且可以适当地使用它来最大限度地减少扩容的性能消耗(见下一个问题分析)。至于为什么是10,据说是因为sun程序员对一系列广泛使用的程序代码进行了研究,结果是长度为10的数组最常用,效率最高。也有人说它只是一个随机数,没有8和12的区别,只是因为10的数组比较完整。ArrayList的扩展机制,添加元素时数组为空时,直接给出一个10长度的数组。当需要的数组长度大于数组当前长度时,扩容new=old+old>>1(即new=old的1.5倍),当扩容后的size还是不够的时候对于所需的长度,将数组大小直接设置为所需的长度(记住这一点!)。ArrayList特性访问速度块,为什么?插入和删除一定很慢吗?适合排队吗?ArrayList从结构上看属于数组,即内存中的一块连续空间。当我们得到(index)时,我们可以直接根据第一个地址和偏移量计算出我们想要的元素的位置,我们可以直接访问该地址处的元素,所以查询速度是O(1)级别的.我们通常说ArrayList的插入删除速度慢,查询速度快,但也不是绝对的。当数组很大时,插入和删除的位置决定了速度。假设当前数组大小为1000万,我们在数组索引为0处插入或删除一个元素,需要将后面的元素全部移动,消耗很大。但是,如果在数组末尾进行索引操作,只会移动少量元素,速度还是相当快的(如果在插入时扩充数组,会消耗更多的内存)。个人认为不适合做队列。综合以上分析,队列会涉及到大量的增删改查(也就是移位操作),在ArrayList中效率还是不高。ArrayList的底层实现是一个数组,本身访问速度就非常快。为什么要实施RandomAccess?RandomAccess是一个空接口,空接口一般只作为标识使用,比如Serializable接口。JDK文档中说RandomAccess是一个标记接口(Markerinterface),用于List接口的实现类,说明这个实现类支持快速随机访问功能(比如ArrayList)。程序在遍历List的实现类时,可以根据这个标志来选择效率更高的遍历方式。优缺点上面说的查询速度快,自然也是优点之一。此外,还可以存储相同的元素。底层数据结构属于数组,和数组一样有优缺点。数组属于线性表,比较适合经常在末尾添加数据的场景,但不适合在整个列表的各个位置随机添加较多元素的情况。.因为可能涉及到很多元素位置的移动。ArrayList的另一大缺点是它不适合多线程环境。这种设计一开始并不是为多线程环境设计的。常见的比如ArrayList、LinkedList、HashMap都是优先考虑效率的,不考虑。线程安全自然不是线程安全的。而这恰恰是本文的重点,也是面试官最喜欢的一道菜。ArrayList中的Fail-fast机制fail-fast快速失败机制,当一个线程A正在用迭代器遍历集合时,如果此时另一个线程B修改了集合,会导致线程A快速失败,然后线程会抛出..ConcurrentModificationException异常。java.util中的集合类是fast-failure的,fast-failure机制是为了应对多线程的场景。Vector真的安全吗如何使用安全的ArrayList?很多人可能会回答Vector,Vector的实现其实很简单。让我给你看一段代码。是的,原因很简单,就是直接给每个方法加上synchronized关键字。publicclassCaptainTest{privatestaticVectorvector=newVector();publicstaticvoidmain(String[]args){while(true){for(inti=0;i<10;i++){vector.add(i);//向vector添加元素}ThreadremoveThread=newThread(newRunnable(){@Overridepublicvoidrun(){for(inti=0;i20);}}}让我们执行上面的代码,会产生两种线程,一种是remove移除元素,一种是get获取元素,但是都是调用size方法获取大小。执行完会报越界异常。为什么是这样?Vector的每一个方法不是都加了synchronized关键字吗?怎么会出现这个错误?添加关键字保证其他线程不能同时调用这些方法,即两个或多个线程不能同时调用这些同步方法。图中报错的原因是:例子中的线程连续调用了两个或多个同步方法。听起来很奇怪吗?让我解释。例子中的removeThread线程会先调用size方法获取大小,然后调用remove方法移除对应位置的元素,而printThread线程也是先调用size方法获取大小,再调用get方法获取对应位置的元素。假设向量大小为5,当printThread线程执行到i=4时,进入for循环但还未执行??输出,线程的CPU时间片到达,此时printThread转为就绪状态时间。此时removeThread线程获得了CPU的执行权,然后将vector中的5个元素全部删除。这时候removeThread的CPU时间片就到了。此时printThread再次获得CPU的执行权,此时执行输出中的get(4)方法会出现越界错误,因为vector中的元素已经被删除了此时取下螺纹。synchronized关键字保证在同一个时间片内只有一个线程进入方法执行,但是不能保证多个线程之间的数据同步,即remove线程在删除vector元素后不能通知print线程。聪明的你应该已经明白这个场景了,所以多线程使用vector并不是绝对安全的。CopyOnWriteArrayList就是为了解决多线程下的ArrayList而诞生的。它位于java.util.cocurrent包下,专为并发而设计。这个名字我们其实很容易理解,就是写的时候会复制一份新的数据,但事实是每一次数据的变化都会伴随着这一份数据。设计的重点其实是读写分离。这个想法大家都很熟悉。读的时候没有加锁,但是写的时候复制了一份新的数据,加了锁之后再修改。老规矩,先看一段代码,通过调试来学习。publicstaticvoidmain(String[]args){CopyOnWriteArrayListlist=newCopyOnWriteArrayList();list.add("test1");ThreadaddThread=newThread(newRunnable(){@Overridepublicvoidrun(){list.add("test4");try{线程。sleep(1000);}catch(InterruptedExceptione){e.printStackTrace();}}});addThread.start();}来,一起调试看看流程,顺便看看源码:ReentrantLock是用来加锁的,记得用完后手动释放锁,继续:add的过程也比较简单,先加锁,加锁后调用getArray,这个就是获取当前数组,然后获取数组的大小。然后将原数组复制到一个更大的数组,大小加一,再将要添加的元素复制到最后一个位置,最后调用SetArray赋值完成替换。从地址我们可以清楚的看出,新数组是一块新的内存空间,与原来的数组完全不同。其实这意味着add每次添加一个元素,都需要一个数组copy。获取元素没有太多需要注意的地方。这里面没有额外的操作,也没有复制一个新数组之类的操作,只是简单的从原数组中取值。这也意味着在多线程运行时,线程读取的数据可能不是我们想要的最新数据,但这种情况需要我们考虑,必须在可接受的条件下使用。remove和iterator分析remove过程:去indexOf看:这个其实很好理解,就是循环遍历,然后通过equals判断,相同就返回定位的位置。当我们要删除一个不存在的元素时,这里会得到false,因为无法定位底层,会返回-1。我们进入remove方法看看,这是重点。我们再来看看remove的源码。刚才的调试没有到这里,所以我们把注意力放在了这段代码上。快照就是刚才的镜像数据。这里考虑多线程的情况,即原数组可能被其他线程修改过,快照有过期数据,本节处理数组是否被其他线程修改。如果有,如何处理。其实根本目的是重新定位index的值,防止误删其他元素。首先,找到索引和当前长度之间的最小值,并进行遍历。FindIndex就是这样做的,在里面再找对应的元素,找到了就直接跳出来,重新判断。如果没有找到元素下标,则进行如下判断。当索引大于len时,表示该元素被删除或不存在。不难理解,看完这段就明白了。迭代器中的迭代器与原始ArrayList中的迭代器的区别在于增加了快照机制。这个快照记录了链表在遍历过程中的最新状态。这个快照数组在迭代器的生命周期内不会发生变化,所以不可能发生冲突,保证迭代器不会抛出并发修改异常。迭代器创建后,迭代器不会反映列表的增删改查等修改操作,但同时也带来了一个小问题,遍历得到的数据可能不是最新的数据。需要注意的一件事是ArrayList不允许更改迭代器上的元素,例如删除、设置和添加操作。这些方法将抛出UnsupportedOperationException。CopyOnWriteArrayList优缺点分析优点读操作性能高,不需要任何同步措施,更适合读多写少的并发场景。利用读写分离的思想,读的时候读取镜像数据,写的时候复制一份新的数据进行修改,这样就不会抛出并发修改异常。存储的数据是有序的。刚才看源码的时候应该已经注意到了。它首先复制原始数据,然后将要添加的数据分配到最后一个位置。缺点内存使用问题。每次写操作都需要复制原始容器数据。当数据量比较大的时候,对内存的压力会比较大,也可能会引起频繁的GC。读的时候不能保证实时性,这也是读写分离付出的代价。vector可以保证读写的强一致性,但是缺点上面已经说了。不同的场景使用不同的容器。哦对了,以后的文章都会在这里更新。https://github.com/DayuMM2021/Java本文转载自微信公众号“Java贼船”,可通过以下二维码关注。转载本文请联系Java盗贼公众号。