学会了 CopyOnWriteArrayList 可以再多和面试官对线三分钟

开发 前端
ArrayList是大家用的再熟悉不过的集合了,而此集合设计之初也是为了高效率,并未考虑多线程场景下,所以也就有了多线程下的CopyOnWriteArrayList这一集合

[[439753]]

ArrayList是大家用的再熟悉不过的集合了,而此集合设计之初也是为了高效率,并未考虑多线程场景下,所以也就有了多线程下的CopyOnWriteArrayList这一集合。

回忆下ArrayList

集合的fail-fast机制和fail-safe机制:

  • fail-fast快速失败机制,一个线程A在用迭代器遍历集合时,另个线程B这时对集合修改会导致A快速失败,抛出ConcurrentModificationException 异常。在java.util中的集合类都是快速失败的。
  • fail-safe安全失败机制,遍历时不在原集合上,而是先复制一个集合,在拷贝的集合上进行遍历。在java.util.concurrent包下的容器类是安全失败的,建议在并发环境下使用这个包下的集合类。

ArrayList定义:

  1. public class ArrayList extends AbstractList 
  2.  
  3. implements List, RandomAccess, Cloneable, java.io.Serializable { } 

ArrayList简介:

  • ArrayList是实现List接口的可变数组,并允许null在内的重复元素
  • 底层数组实现,扩容时将老数组元素拷贝到新数组中,每次扩容是其容量的1.5倍,操作代价高
  • 采用了Fail-Fast机制,面对并发的修改时,迭代器很快就会完全失败,而不是冒着在将来某个不确定时间发生任意不确定行为的风险
  • ArrayList是线程不安全的,所以在单线程中才使用ArrayList,而在多线程中可以选择Vector或者CopyOnWriteArrayList

重点关注问题:

ArrayList默认大小(为什么是这个?),扩容机制?

ArrayList的默认初始化大小是10(在新建的时候还是空,只有当放入第一个元素的时候才会变成10),若知道ArrayList的大致容量,可以在初始化的时候指定大小,可以在适当程度减少扩容的性能消耗(看下一个问题解析)。

至于为何是10

据说是因为sun的程序员对一系列广泛使用的程序代码进行了调研,结果就是10这个长度的数组是最常用的最有效率的。也有说就是随便起的一个数字,8个12个都没什么区别,只是因为10这个数组比较的圆满而已。

ArrayList的扩容机制

当添加元素的时候数组是空的,则直接给一个10长度的数组。当需要长度的数组大于现在长度的数组的时候,通过新=旧+旧>>1(即新=1.5倍的旧)来扩容,当扩容的大小还是不够需要的长度的时候,则将数组大小直接置为需要的长度(这一点切记!)。

ArrayList特点访问速度块,为什么?插入删除一定慢吗?适合做队列吗?

ArrayList从结构上来看属于数组,也就是内存中的一块连续空间,当我们get(index)时,可以直接根据数组的首地址和偏移量计算出我们想要元素的位置,我们可以直接访问该地址的元素,所以查询速度是O(1)级别的。

我们平时会说ArrayList插入删除这种操作慢,查询速度快,其实也不是绝对的。

当数组很大时,插入删除的位置决定速度的快慢,假设数组当前大小是一千万,我们在数组的index为0的位置插入或者删除一个元素,需要移动后面所有的元素,消耗是很大的。但是如果在数组末端index操作,这样只会移动少量元素,速度还是挺快的(插入时如果在加上数组扩容,会更消耗内存)。

个人觉得不太适合做队列,基于上面的分析,队列会涉及到大量的增加和删除(也就是移位操作),在ArrayList中效率还是不高。

ArrayList 底层实现就是数组,访问速度本身就很快,为何还要实现 RandomAccess ?

RandomAccess是一个空的接口, 空接口一般只是作为一个标识, 如Serializable接口。

JDK文档说明RandomAccess是一个标记接口(Marker interface), 被用于List接口的实现类, 表明这个实现类支持快速随机访问功能(如ArrayList). 当程序在遍历这中List的实现类时, 可以根据这个标识来选择更高效的遍历方式。

优缺点

上面说的查询速度快自然就是其中的优点,除此之外,还可以存储相同的元素。

底层数据结构属于数组,和数组的优缺点大同小异,数组属于线性表,更适合于那种在末尾经常添加数据的场景,而对于在整个list中各个位置随机添加元素比较多的情况则不太合适。

因为可能会涉及到很多元素位置的移动。

ArrayList还有一个比较大的缺点就是不适应于多线程环境,这个设计之初也不是用于多线程环境的,像ArrayList、LinkedList、HashMap这种常见的都是以效率优先的,都是没有考虑线程安全的,也就自然不是线程安全的。

而这,恰恰也就是本文的重点,也是面试官最爱的菜。

ArrayList中的Fail-fast机制

fail-fast快速失败机制,一个线程A在用迭代器遍历集合时,此时另一个线程B如果对集合进行修改,就会导致线程A快速失败,然后线程会抛出.。ConcurrentModificationException异常。

在java.util中的集合类都是快速失败的,快速失败机制就是应对多线程场景的。

Vector真的安全吗

如何使用安全的ArrayList,很多人的答案可能是Vector,而Vector的实现其实也很简单,我给大家看段代码。

是的,道理也很简单,就是直接在每个方法加上synchronized关键字。

  1. public class CaptainTest { 
  2.  
  3.     private static Vector<Integer> vector = new Vector(); 
  4.  
  5.     public static void main(String[] args) { 
  6.         while (true) { 
  7.             for (int i = 0; i < 10; i++) { 
  8.                 vector.add(i); //往vector中添加元素 
  9.             } 
  10.             Thread removeThread = new Thread(new Runnable() { 
  11.                 @Override 
  12.                 public void run() { 
  13.                     for (int i = 0; i < vector.size(); i++) { 
  14.                         Thread.yield(); 
  15.                         //移除第i个数据 
  16.                         vector.remove(i); 
  17.                     } 
  18.                 } 
  19.             }); 
  20.             Thread printThread = new Thread(new Runnable() { 
  21.                 @Override 
  22.                 public void run() { 
  23.                     for (int i = 0; i < vector.size(); i++) { 
  24.                         Thread.yield(); 
  25.                         //获取第i个数据并打印 
  26.                         System.out.println(vector.get(i)); 
  27.                     } 
  28.                 } 
  29.             }); 
  30.             removeThread.start(); 
  31.             printThread.start(); 
  32.             //避免同时产生过多线程 
  33.             while (Thread.activeCount() > 20) ; 
  34.         } 
  35.     } 
  36.  

我们来执行上面的这段代码,这段代码会产生两种线程,一种remove移除元素,一种是get获取元素,但是都调用了size方法获取大小。

执行之后会报一个越界的异常,这是为啥呢,Vector不是每个方法都加上了synchronized关键字了吗,怎么会出现这种错误。

加上关键字保证其它线程不能同时调用这些方法了,也就是,不能出现两个及两个以上的线程在同时调用这些同步方法。

图中报错的问题的原因是:例子中的线程连续调用了两个或者两个以上的同步方法,听起来很奇怪是吗?我来解释下。

例子中的removeThread线程会首先调用size方法获取大小,接着调用remove方法移除相应位置的元素,而printThread线程也是先调用size方法获取大小,接着调用get方法获取相应位置的元素。

假设vector大小是5,此时printThread线程执行到i=4的时候,进入for循环但是在执行输出之前,线程的CPU时间片到了,此时printThread则转入到就绪状态。

此时removeThread线程获得CPU的执行权,然后把vector中的5个元素都删除了,此时removeThread的CPU时间片到了。

而此时printThread再获取到CPU的执行权,此时执行输出中的get(4)方法就会出现越界的错误,因为此时vector中的元素已经被remove线程删除了。

synchronized关键字保证的是同一时间片只有一个线程进入该方法执行,但是无法保证多个线程之间的数据同步,也就是remove线程删除vector元素之后无法通知到print线程。

聪明的你应该已经理解这个场景了吧,所以,vector在多线程使用的时候也不是绝对安全的。

CopyOnWriteArrayList

这个就是为了解决多线程下的ArrayList而生的,位于java.util.cocurrent包下,就是为并发而设计的。

我们听名字其实也可以简单的读懂,就是写的时候会复制一份新的数据,而事实是每一次的数据改动都会伴随这一次数据的复制。

设计的重点其实就是读写分离,这个思想大家再熟悉不过了吧,读的时候不会加锁,而写的时候会复制一份新数据,然后加上锁之后进行修改。

老规矩,先看一段代码,我们通过debug的方式来学习下先。

  1. public static void main(String[] args) { 
  2.  
  3.         CopyOnWriteArrayList list = new CopyOnWriteArrayList(); 
  4.         list.add("test1"); 
  5.  
  6.         Thread addThread = new Thread(new Runnable() { 
  7.             @Override 
  8.             public void run() { 
  9.                 list.add("test4"); 
  10.                 try { 
  11.                     Thread.sleep(1000); 
  12.                 } catch (InterruptedException e) { 
  13.                     e.printStackTrace(); 
  14.                 } 
  15.             } 
  16.         }); 
  17.  
  18.         addThread.start(); 
  19.  
  20.     } 

来吧,我们一起debug看下过程,顺便看下源码:

加锁用的是ReentrantLock,使用完了要记得手动释放锁,继续:

add的过程也是比较简单的,先是加锁,加锁之后调用getArray,这个就是拿到现在的数组,然后取得数组的大小。

接着是将原数组复制到一个大小加一的一个更大的数组中,然后将要添加的元素复制到最后的位置,最后再调用SetArray进行赋值,完成替换。

我们可以通过地址很清晰的看到,新数组就是又重新开辟了一块内存空间,和原来数组是完全不一样的。

其实这也就意味着每次add增加元素都需要一次数组的复制。

对于get获取元素来说也没有太多需要注意的,这个里面没有什么额外的操作,没有什么复制新数组一类的操作,只是简单的从原数组取值即可。

这也就意味着在多线程运行的时候,线程读取到的数据可能不是最新的我们想要的数据,但是这种情况是需要我们考虑到的,必须在可以接受的情况下来使用。

remove和iterator

分析remove过程:

进去indexOf看:

这个其实也很好理解,就是循环遍历,然后通过equals判断,相同则返回定位到的位置。

当我们想要删除一个不存在的元素的时候,我们在这里会拿到false,因为底层定位不到会返回-1,我们进入remove方法看,这个是重点。

我们再重新看一下remove的源码。

刚刚的调试是没有走到这里面的,我们把目光聚集到这块代码。

snapshot是刚刚的镜像数据,这里考虑到了多线程的情况,即原有的数组可能已经被其它的线程修改了,snapshot已经过时的数据了,而这段处理的就是如果该数组被别的线程修改了的情况下,是如何处理的。

其实根本目的就是重新定位index的值,防止误删别的元素。

先是找到index和当前长度中的最小值,进行遍历,findIndex就是做这个的,在其中重新找相应的元素,找到就就直接跳出,重新判断。

如果没有找到元素下标,就进行下面的判断,index大于len的时候,代表元素被删除或者不存在了。

也不是很难理解,大家看一下这块就可以理解了。

看里面的iterator

这个迭代器和原来ArrayList中的迭代器区别点就是增加了一个快照机制,这个快照就是把遍历时的这个最新链表状态记录了下来。

此快照数组在迭代器的生存期内是不会更改的,因此也就不可能发生冲突,也就保证了迭代器不会抛出并发修改异常。

创建迭代器以后,迭代器不会反映列表的添加、移除和更改等修改的操作,但是也就同时带来了一个小小的问题,遍历拿到的数据可能不是最新的数据。

需要注意的一点,ArrayList在迭代器上进行元素的更改操作是不被允许的,比如remove、set和add操作,这些方法将抛出UnsupportedOperationException异常。

CopyOnWriteArrayList优缺点分析

优点

读操作性能高,无需要任何的同步措施,比较适合于读多写少的并发场景。

采用读写分离的思想,读的时候读取镜像的数据,写的时候复制一份新的数据进行修改操作,所以也就不会抛出并发修改异常了。

存储的数据有序,刚刚在看源码的时候你应该注意到了,它是先进行原数据的复制,然后再在最后位置上赋值这个要添加的数据。

缺点

内存占用问题,每次写操作都需要将原容器数据拷贝一份,数据量比较大的时候,对内存压力会比较多,也有可能引起频繁的GC。 

读取的时候无法保证实时性,这也是读写分离付出的代价,Vector可以保证读写的强一致性,但是缺点上面也已经说过了,不同的场景使用不同的容器。

哦对了,后续所有的文章都会更新到这里。 

https://github.com/DayuMM2021/Java

本文转载自微信公众号「Java贼船  」,可以通过以下二维码关注。转载本文请联系Java贼船公众号。

责任编辑:武晓燕 来源: Java贼船
相关推荐

2024-04-01 09:59:08

消息队列通信微服务

2024-05-16 11:13:16

Helm工具release

2024-07-29 12:21:12

2009-10-26 19:09:50

VB.NET转换形态

2009-11-09 12:55:43

WCF事务

2009-11-17 10:13:29

PHP正则表达式

2021-04-20 13:59:37

云计算

2023-12-27 08:15:47

Java虚拟线程

2024-01-16 07:46:14

FutureTask接口用法

2022-02-17 09:24:11

TypeScript编程语言javaScrip

2024-08-30 08:50:00

2009-11-03 17:15:07

VB.NET开发Exc

2019-08-29 20:10:03

U盘系统Windows 10

2020-06-30 10:45:28

Web开发工具

2013-06-28 14:30:26

棱镜计划棱镜棱镜监控项目

2021-12-17 07:47:37

IT风险框架

2009-11-05 16:04:19

Oracle用户表

2020-06-29 07:42:20

边缘计算云计算技术

2024-10-15 09:18:30

2024-07-05 09:31:37

点赞
收藏

51CTO技术栈公众号