一、博客背景
list接口下有两个主要常用地的实现子类ArrayList和LinkedList,今天我们就一起来查看学习下,使用的jdk版本为1.7.0_04
二、ArrayList
在查看源码之前我们思考一下下面的问题,然后带着问题去学习
-
ArrayList底层实现原理
-
ArrayList的默认容量大小?
-
ArrayList的插入或删除一定慢吗?
-
ArrayList底层就是数组,访问速度本身就比较快,为什么还要实现RandomAccess接口?
-
ArrayList增加数据时,为何不会数组越界?
-
ArrayList是如何扩容的?
我们先看下类声明:public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable
ArrayList实现了RandmoAccess接口,即提供了随机访问功能。RandomAccess是java中用来被List实现,为List提供快速访问功能的。在ArrayList中,我们可以通过元素的序号快速获取元素对象,这就是快速随机访问;实现了Cloneable接口,能被克隆;实现了Serializable接口,因此它支持序列化,能够通过序列化传输。
1.构造函数
private transient Object[] elementData;
private int size;
//自己指定新建的ArrayList的长度
public ArrayList(int initialCapacity) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
this.elementData = new Object[initialCapacity];
}
//空参的构造函数
public ArrayList() {
this(10);
}
//使用其他集合来新建ArrayList
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
size = elementData.length;
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
}
通过上面的构造函数我们就可以得到
Q1:ArrayList底层实现原理
Q2:ArrayList的默认容量大小?
两个问题的答案了
ArrayList是一个Object类型数组,相当于动态数组。与Java中的数组相比,它的容量能动态增长。默认容量为10
需要注意的java8对于ArrayList的空参构造函数做了改变
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
可以看到创建ArrayList对象时,仅仅给成员变量elementData赋值了一个{},记住这里:java8的构造器并没有对成员变量elementData进行开辟默认长度是10的数组
A)Java7->8的变化(总结)
JDK7 | JDK8 | |
---|---|---|
构造器 | 实例化即开辟数组长度为10的内存空间 | 实例化时不开辟内存空间 |
add()方法 | 直接添加 | 第一次调用add方法时,开辟数组长度为10的内存空间 |
2.add和remove方法
A)add(E e)方法
在数组尾部添加一个元素
//在数组尾部添加一个元素
public boolean add(E e) {
//判断数组容量大小,容量不够扩容
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}
//判断数组容量大小,容量不够扩容
private void ensureCapacityInternal(int minCapacity) {
// 增加修改次数
modCount++;
// 若此时minCapacity(ArrayList中要存储的元素个数) > elementData原始的容量,则要按照minCapacity进行扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
//扩容
private void grow(int minCapacity) {
// overflow-conscious code
//获取数组原先容量大小
int oldCapacity = elementData.length;
// 计算新的容量
// 若原数组长度为偶数,那么新数组长度就恰好是原数组长度的1.5倍
// 若原数组长度为奇数,那么新数组长度就恰好是原数组长度的1.5倍 - 1
int newCapacity = oldCapacity + (oldCapacity >> 1);
//若扩容后的数组容量小于插入新数据后的数组容量大小,则数组容量大小直接使用插入后数据容量大小
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//如果扩容后数组容量大小大于最大值的全局变量(为int类型最大值-8),则将新数组长度更改为最大正数:Integer.MAX_VALUE
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
//按照新的容量newCapacity创建一个新数组,然后再将原数组中的内容copy到新数组中
elementData = Arrays.copyOf(elementData, newCapacity);
}
B) add(int index, E element)
在数组elementData指定位置index处添加元素
//在指定位置添加元素
public void add(int index, E element) {
// 判断下标index的合法性
rangeCheckForAdd(index);
// 数组容量判断,容量不够扩容
ensureCapacityInternal(size + 1); // Increments modCount!!
// 数组拷贝,将index到末尾的元素拷贝到index + 1到末尾的位置,将index的位置留出来
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
// 判断下标index的合法性
private void rangeCheckForAdd(int index) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
C)remove(int index)
根据index下标删除元素
public E remove(int index) {
// 下标合法性检验
rangeCheck(index);
// 修改次数加1
modCount++;
// 获取旧的元素值
E oldValue = elementData(index);
// 计算需要移动的元素个数
int numMoved = size - index - 1;
// 将元素向前移动
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 将最后的元素值设置为null
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
private void rangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
D)remove(Object o)
删除某个元素
public boolean remove(Object o) {
// 若删除的元素为null
if (o == null) {
for (int index = 0; index < size; index++)
// 若数组元素为null,则调用fastRemove方法快速删除
if (elementData[index] == null) {
fastRemove(index);
return true;
}
}
// 若删除的元素不为null
else {
for (int index = 0; index < size; index++)
// 找到要删除的元素,调用fastRemove方法快速删除
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
private void fastRemove(int index) {
// 修改次数加1
modCount++;
// 计算需要移动的元素数目
int numMoved = size - index - 1;
// 将index之后的元素向前移动一位
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 将数组最后一位置为null
elementData[--size] = null; // clear to let GC do its work
}
ArrayList删除元素时,是分为元素为null和不为null两种方式来判断的,这也说明ArrayList允许添加null元素;同时,如果这个元素在ArrayList中存在多个,则只会删除最先出现的那个。
看了上面的增加和删除的方法我们就可以得到Q3,Q5,Q6的答案了
Q3:ArrayList的插入或删除一定慢吗?
插入删除的快慢取取决于插入或删除的元素距离有多远和list的容量大小,如果不是最后一个元素,则在插入或者删除时,需要移动该位置往后的元素,
在插入时且在数组的末端,如果底层数组的容量已经小于当前list容量,则根据ArrayList的扩容机制需要增大1.5倍的容量,并初始化一个新的数组,将原有的数据复制到新的数组中去,比较耗费资源,如果不是末端,还需要移动该位置之后的元素。
Q5:ArrayList增加数据时,为何不会数组越界
当给ArrayList增加一个对象时,首先会检查该ArrayList是否有足够的容量来存储这个新对象,如果没有足够的容量时,会建一个新的更长的数组,是旧数组容量的1.5倍,旧的数组会使用Arrays.copyOf方法被复制到新的数组中去。现有的数组引用指向新的数组。
Q6:ArrayList是如何扩容的
扩容机制为判断原有数组容量大小是否能够存入新的元素,若无法存入,则进行扩容,一次扩容1.5倍
Q4:ArrayList底层就是数组,访问速度本身就比较快,为什么还要实现RandomAccess接口
RandomAccess是一个标记接口 (Marker interface), 被用于List接口的实现类, 表明这个实现类支持快速随机访问功能(如ArrayList). 当程序在遍历这中List的实现类时, 可以根据这个标识来选择更高效的遍历方式
具体详情请查看这篇博客:https://blog.csdn.net/weixin_39148512/article/details/79234817
三、fail-fast机制
在ArrayList,LinkedList,HashMap等等的内部实现增,删,改中我们总能看到modCount的身影,modCount字面意思就是修改次数,但为什么要记录modCount的修改次数呢?
这个字段的用途,在ArrayList的父类AbstractList源码中有注释,说的很清楚:
该字段表示list结构上被修改的次数。结构上的修改指的是那些改变了list的长度大小或者使得遍历过程中产生不正确的结果的其它方式。
该字段被Iterator以及ListIterator的实现类所使用,如果该值被意外更改,Iterator或者ListIterator 将抛出ConcurrentModificationException异常,
这是jdk在面对迭代遍历的时候为了避免不确定性而采取的快速失败原则。
子类对此字段的使用是可选的,如果子类希望支持快速失败,只需要覆盖该字段相关的所有方法即可。单线程调用不能添加删除terator正在遍历的对象,
否则将可能抛出ConcurrentModificationException异常,如果子类不希望支持快速失败,该字段可以直接忽略。
现在可以明白modCount是用来实现fail-fast机制的,fail-fast机制是Java集合中的一种错误机制,当多个线程对同一个集合的内容进行操作时,就会发生fail-fast事件,它是一种错误检测机制,只能被用来检测错误,因为JDK并不一定保证fail-fast机制一定会发生。fail-fast机制会尽最大努力来抛出ConcurrentModificationException异常。
迭代器在调用next()、remove()等方法时都要调用checkForComodification()方法:
int expectedModCount = modCount;
//很多方法都会调用这个方法,检查modCount是否改变,如果在操作当前方法的时候,modcount改变,则抛出异常
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
该方法主要是检测modCount是否等于expectedModCount,若不等于,则抛出ConcurrentModificationException异常。
在创建迭代器时,会将modCount的值赋给expectedModCount,所以在迭代期间,expectedModCount不会改变,在ArrayList中,无论add、remove还是clear方法,只要改变了ArrayList的元素个数,都会导致modCount改变,从而可能导致fail-fast产生。
四、LinkedList
在查看源码之前我们同样思考一下下面的问题,然后带着问题去学习
- LinkedListt的实现原理
- 为什么说相比于ArrayList来说linkedlist插入,删除快,而查找慢
- ArrayList和LinkedList有什么区别
Linkedlist基于链表的动态数组(双向链表): 可以被当作堆栈(后进先出)、队列(先进先出)或双端队列进行操作。
1.node类
与ArrayLuist的数组类型为Object不同,LinkedList类中的元素节点都是Node类型的:
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
2.数据结构示意图
node结构示意
如上图,LinkedList是由很多个这样的节点构成
- prev存储的是上一个节点的引用
- item存储的是具体内容
- next存储的是下一个节点的引用
linkeedlist结构示意图
3.构造方法
// 双向队列元素个数
transient int size = 0;
// 双向队列首节点
transient Node<E> first;
// 双向队列尾节点
transient Node<E> last;
// 默认构造方法
public LinkedList() { }
// 根据其他集合创建LinkedList
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}
public boolean addAll(int index, Collection<? extends E> c) {
//检查插入的节点位置是否合理
checkPositionIndex(index);
//将其他集合转为数组
Object[] a = c.toArray();
int numNew = a.length;
//如果新插入的集合大小为0,直接返回false
if (numNew == 0)
return false;
//定义前驱节点,后继节点
Node<E> pred, succ;
//判断要插入的节点位置是不是和链表的长度相等,相等则代表插入到链表末尾
if (index == size) {
//将后继节点置为null
succ = null;
//将末尾节点赋值给前驱节点
pred = last;
} else {
//不是在链表的末尾处
//将未插入前位于当前位置的节点设置为当前节点的下一个节点
succ = node(index);
//将后继节点的前一个节点设置为前驱节点
pred = succ.prev;
}
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
//遍历集合中的每一个元素,新建node实例
Node<E> newNode = new Node<>(pred, e, null);
//如果前驱节点为空,说明是新建节点是头结点
if (pred == null)
first = newNode;
else
//将新建的实例节点设置为前驱节点的下一个节点
pred.next = newNode;
pred = newNode;
}
if (succ == null) {
//能走到这里,说明是在末尾链表末尾插入的数据,这时赋值新的尾节点
last = pred;
} else {
//是在列表中部插入数据
//更新插入节点的下一个几点为后继节点
pred.next = succ;
//更新后继节点的前置节点为当前插入节点
succ.prev = pred;
}
size += numNew;
modCount++;
return true;
}
看完上面的构造函数,我们可以得到Q1的答案,
Q1:LinkedListt的实现原理
LinkedList的底层是通过链表来实现的
4.add和remove方法
A)add(E e)
添加指定元素,调用此方法是将元素添加到链表的末尾
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
// 获取尾节点
final Node<E> l = last;
// 创建新节点,新节点的前驱节点是l,后继节点是null
final Node<E> newNode = new Node<>(l, e, null);
// 更新尾节点
last = newNode;
// 若原始链表为空,则初始化首节点
if (l == null)
first = newNode;
else
l.next = newNode;
// 更新元素个数
size++;
modCount++;
}
B) add(int index, E element)
将元素添加到指定位置
public void add(int index, E element) {
//检查插入位置是否合理
checkPositionIndex(index);
if (index == size)
//如果插入位置等于链表的大小,则在末尾插入数据
linkLast(element);
else
//否则在指定之前插入数据
linkBefore(element, node(index));
}
Node<E> node(int index) {
// assert isElementIndex(index);
// 若index小于size >> 1,通过从首节点向后遍历来寻找节点
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
}
// 否则,通过从尾节点向前遍历来寻找节点
else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
void linkBefore(E e, Node<E> succ) {
//获取原来被插入位置节点的前一个节点
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
//将新插入节点设置为原先插入位置节点的上一个节点
succ.prev = newNode;
if (pred == null)
//如果前一个节点为空则说明链表为空,将新插入节点赋值为头节点
first = newNode;
else
//将新插入节点赋值为被插入节点位置的前一个节点的下一个节点
pred.next = newNode;
size++;
modCount++;
}
C)E remove()
移除头部元素
public E remove() {
return removeFirst();
}
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}
private E unlinkFirst(Node<E> f) {
// assert f == first && f != null;
//获取头部元素的数据
final E element = f.item;
//获取头部元素的下一个节点
final Node<E> next = f.next;
//将头部元素的数据置空
f.item = null;
//将头部元素指向下一个节点的指针置空
f.next = null; // help GC
//将原先头部元素的下一个节点赋值给头部元素
first = next;
if (next == null)
//如果下一个节点为空,说明链表已经空了
last = null;
else
//将原先头部节点的下一个节点的上一个节点置空
next.prev = null;
size--;
modCount++;
return element;
}
D)E remove(int index)
删除指定位置的节点
public E remove(int index) {
// 检查index下标的合法性
checkElementIndex(index);
// 获取元素值,并通过unlink方法删除节点
return unlink(node(index));
}
F)boolean remove(Object o)
删除指定元素
public boolean remove(Object o) {
// 要删除的元素为null
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
}
// 要删除的元素不为null
else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
E unlink(Node<E> x) {
// 获取要删除的元素值
final E element = x.item;
// 获取元素后继节点
final Node<E> next = x.next;
// 获取元素前驱节点
final Node<E> prev = x.prev;
// 若前驱节点为null,说明当前删除的节点为首节点,则更新首节点
if (prev == null) {
first = next;
}
// 否则,更新前驱节点的后继节点
else {
//将前驱节点的下一个节点指向当前删除元素的后继节点
prev.next = next;
//将当前节点的前驱节点置空
x.prev = null;
}
// 若后继节点为null,说明当前删除的节点为尾节点,则更新尾节点
if (next == null) {
last = prev;
}
// 否则,更新后继节点的前驱节点
else {
//将前驱节点设置为后继节点的前驱节点
next.prev = prev;
//将当前删除节点的后继节点置空
x.next = null;
}
// 将当前节点的数据置空
x.item = null;
// 减少元素个数值
size--;
modCount++;
return element;
}
LinkedList删除元素时,是分为元素为null和不为null两种方式来判断的,这也说明LinkedList允许添加null元素;同时,如果这个元素在LinkedList中存在多个,则只会删除最先出现的那个。
G)E get(int index)
获取指定下表元素
public E get(int index) {
// 检查index下标的合法性
checkElementIndex(index);
// 获取元素值
return node(index).item;
}
通过上面的代码学习我们可以得到Q2的答案
Q2:为什么说相比于ArrayList来说linkedlist插入,删除快,而查找慢
相比于插入快原因是linkedlist通过add(int index, E element)向LinkedList插入元素时。先是在双向链表中找到要插入节点的位置index;找到之后,再插入一个新节点,而双向链表查找index位置的节点时,有一个加速动作:若index < 双向链表长度的1/2,则从前向后查找; 否则,从后向前查找。
而ArrayList中的add(int index, E element)
方法,是通过调用系统的数组复制方法(System.arraycopy(elementData, index, elementData, index + 1, size - index))来实现了元素的移动。所以,插入的位置越靠前,需要移动的元素就会越多
删除也是同快也是同样的原因
而查找慢的原因则是;对于ArrayList,无论什么位置,都是直接通过索引定位到元素,时间复杂度O(1),而对于LinkedList查找,其核心方法就是上面所说的node()方法,所以头尾查找速度极快,越往中间靠拢效率越低
当然也不是绝对的说ArrAyList的删除和插入就比LinkedList的慢,这一切都要看集合的大小和元素所处位置来具体分析。
五、ArrayList和LinkedList有什么区别
1.ArrayList是基于动态数组实现的数据结构,LinkedList是基于双向链表实现的数据结构
2.对于随机访问操作get和set,ArrayList要优于LinkedList,因为LinkedList要移动指针找index对应的元素
3.对于添加和删除操作add和remove,LinedList比较占优势,因为除了要找index对应的元素,ArrayList还要移动数据
通常来说ArrayList更适合用于读操作多的场景,linkedList更适合用于添加或删除数据频繁的场景