Java List整理

474 阅读7分钟

概述

有序的集合
允许插入null
能够使用索引(元素在list的位置,类似于数组的下标)访问list中的元素
常见的实现:ArrayList, LinkedList

Arraylist

特性

1、使用数组存储元素
2、随机访问元素较快,时间复杂度O(1),插入移除元素较慢,时间复杂度O(n)
3、线程不安全,若需保证线程安全使用SynchronizedList和CopyOnWriteArrayList代替
4、继承于 AbstractList,实现了List, RandomAccess, Cloneable, java.io.Serializable

1、实现了RandomAccess 接口, RandomAccess 是一个标志接口,
表明实现这个这个接口的 List 集合是支持快速随机访问的。
在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。
2、实现了Cloneable接口,表示能被克隆
3、java.io.Serializable 接口,这意味着ArrayList支持序列化

结合源码

带着问题看源码:
问题1:添加、移除、查找基本功能的实现
问题2:何时、如何扩容
问题3:如何利用泛型
问题4:并发下可能出现的线程安全问题

问题1 基本功能的实现

上图展示了ArrayList的两个核心属性 
elementData数组:存储集合的元素, size: 集合元素的数量

上图展示了给定了初始化容量的ArrayList的构造函数的实现,
若初始化容量>0, elementData赋值为对应长度的Obejct数组,
若初始化容量为0, elementData赋值为一个空Object数组。

2图为新增的逻辑(先忽略ensureCapacityInternal方法),
前者为顺序新增元素,后者为指定位置插入元素.
顺序新增逻辑:将元素放入elementData数组的末尾,也就是size索引处,最后size+1
插入元素逻辑:将elementDatas中index以及后边元素整体右移一位,然后将元素放入index位置,最后size+1
所以,若为顺序插入,速度会很快,而若为指定索引插入,会伴随着数组元素的平移,相对会慢很多。

上图为获取指定索引位置的元素。

上两图分别为查询指定元素在集合中索引位置的实现和判断元素在集合中是否存在的实现。

上图为移除集合元素的实现,前者为移除指定索引位置的元素,后者为移除指定元素。

移除指定索引位置元素:判读数组删除元素后是否需要移动元素(若移除的为最后一位,则不需要移动),
若需要移动,index+1及其之后元素整体左移,索引为size处元素置为空,size-1
若不需要移动,代表移除位于size处的元素,size-1

移除指定元素:查找元素所在索引位置,然后根据索引移除元素。
需要注意的是每次只会移除匹配到的第一个元素。

问题2 何时如何扩容

扩容逻辑为之前忽略分析的ensureCapacityInternal方法,添加元素前会调用该方法 看下该方法的实现

首先根据calculateCapacity计算最小所需容量minCapacity,若新建容器对象时未初始化容量,返回10作为所需最小容量minCapacity
然后将minCapacity作为参数传入ensureExplicitCapacity,判断是否扩容,若minCapacity大于当前元素数组长度则需要扩容
最后若需要扩容,minCapacity作为参数传入grow方法,newCapacity = oldCapacity + (oldCapacity >> 1) 
即newCapacity = oldCapacity + (oldCapacity * 0.5),比较 newCapacity、minCapacity、MAX_ARRAY_SIZE得到
新容量的值,Arrays.copyOf(elementData, newCapacity) 完成扩容。

问题3 如何利用泛型

问题4 并发下的可能问题

添加元素越界IndexOutOfBoundsException

若当前elementData数组长度为2,size=1(即初始化了一个长度为2的ArrayList, 并且已经添加了一个元素),
此时存在多个线程同时对该ArrayList执行add,极端情况下则可能会出现该情形:
calculateCapacity方法判断是否需要扩容时,结果均为不需要扩容,而在elementData[size++]=e
对size赋值时,size的值会超出当前数组范围,产生越界异常IndexOutOfBoundsException
看如下示例:

并发修改异常ConcurrentModificationException

若某个线程正在修改一个ArrayList,同时另外存在一个线程则遍历该ArrayList,则会出现该异常,如下对上边的案例做了下修改,
减少了添加元素的数量,同时在后边打印该数组

LinkedList

特性

1、使用链表存储元素
2、新增插入快,随机查找慢
3、线程不安全,可以使用Collections.synchronizedList(new LinkedList(...));创建线程安全的LinkedList
4、继承自AbstractSequentialList,实现了List, Deque, Cloneable, java.io.Serializable接口

实现了List,使得具有List集合的特性,实现了Deque接口,使得LinkedList类也具有队列的特性
实现了Cloneable,表示能被克隆
实现了java.io.Serializable,表示支持序列化

结合源码

带着问题看源码:
问题1:添加、移除、查找基本功能的实现
问题2:并发下可能出现的线程安全问题

问题1 基本功能实现

上图为LinkedList的核心属性,size:元素数量, first:链表的头部, last:链表的尾部

上图为链表节点的构成属性, item:值,next:下一个节点,pre:上一个节点

上述图为add方法实现,即尾部添加元素

以上几图为指定位置插入元素的实现,首先检查index是否越界,(忽略index==size也就是末尾插入的情况)
然后调用node方法,根据插入的index,查找index的前一个节点
最后执行linkBefore插入元素

上图为查询逻辑,其中的node(int index)方法在add操作时已经涉及了

以上几图为移除元素的逻辑,依然是通过node方法找到当前index位置处的节点,然后修改前后节点的next和pre节点即可。

下边来看下实现队列的几个基础方法:

int size():获取队列长度;
boolean add(E)/boolean offer(E):添加元素到队尾;
E remove()/E poll()/peek()/element():获取队首元素并从队列中删除;
E element()/E peek():获取队首元素但并不从队列中删除。

队尾添加元素

remove移除并返回首位元素,若为空,抛出异常

poll:返回并移除首位元素,若为空,返回null

peek: 返回但不删除首位元素,若为空返回null

element: 返回但不删除首位元素,若为空抛出异常

问题2 并发下可能出现的线程安全问题

并发下新增元素,数据丢失

并发下进行add操作,极端情形下两个线程同时执行Node<E> newNode = new Node<>(l, e, null); 
各自生成一个新node,则必然会有一个节点被覆盖,即丢失数据
下边简单验证一下:
新建一千个线程,每个线程向LinkedList中新增一个元素
多次执行发现,控制台会打印999,不符合预期1000

并发下移除元素会出现各种未知情况

1、java.util.NoSuchElementException

还是先看demo代码:首先给LinkedList添加100个元素,然后新建99个线程,每个线程执行一次remove操作,
也就是每个线程删除一次first节点,预期结果为执行成功,最后size=1,剩余节点为100,实际执行多次后出现异常如下图

重新翻下源码,如下两图,第一个图说明了异常抛出原因,first节点为空,抛出NoSuchElementException异常
那为什么有100个节点,我们一共删除了99次,为什么中间为产生first节点为空的异常呢
看第二图,假设存在线程A执行到2位置,清除掉了f.next的值,而线程B此时执行到1位置拿到的f.next为null,
并把null3位置赋给了first,则次之后的线程所得到的first皆为null,从而抛出NoSuchElementException异常

2、空指针异常

demo代码逻辑,首先给LinkedList添加1000个元素,然后新建500个线程,每个线程执行一次remove(499)操作(增加node()方法的执行时间)
预期结果为执行成功,size为500,多次执行后出现空指针异常

同样的也是多线程情况下,链表结构被改乱掉的缘故