Java集合进阶之ArrayList与LinkedList
ArrayList类与LinkedList类概述
ArrayList和LinkedList是Java集合类中常用的List类,其中我们众所周知的区别有
- ArrayList底层是数组,支持随机访问;LinkedList底层是链表,不支持随机访问;
- LinkedList由于底层是链表,所以其remove操作会比ArrayList快很多;
- LinkedList常用于栈(Stack)与队列(Queue)
预备知识
我们需要先明确ArrayList与LinkedList的预备知识,比如其底层大概实现等
ArrayList的底层概述
首先我们进入ArrayList的底层,我们可以看到这个字段
transient Object[] elementData;
这个字段代表着我们所可以使用到的实际的内容,可以看到他就是一个数组
既然是一个数组对象,众所周知,数组对象可以使用length方法获取其长度值,但是我们可以看到ArrayList的源码中还有一个这个字段
private int size;
很多小伙伴们就会疑惑,在我们平时的理解中,好像更多使用的size,那么size与elementData的length有什么区别呢?这个地方我们稍后再说明,大家现在可以知道其底层是一个数组,然后有个size字段代表其大小即可;
LinkedList的底层概述
我们都知道,LinkedList底层是由链表实现的,我们在查阅源码的过程中,可以看到一个很有意思的内部类:
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;
}
}
它就是Java中LinkedList的链表中的节点类;
我们暂时知道这些基础知识,为后面的理解做好准备;
容器嘛,最为核心的属性便是增、删、改、查这四个属性了,而我们今天来深入底层源码,来学习二者的设计思想与比较二者的不同。
增
增大家很好理解,利用之前学习过的数据结构与算法的知识,大家都可以大概猜测到ArrayList与LinkedList的增的大概实现;
- ArrayList由于底层是数组,那么其便有个游标代表其现在元素在容器中的哪个位置了,需要插入的时候就游标向后移动;
- LinkedList由于底层是链表,那么我们直接把新元素接到容器尾部就好了;
现在我们来比较其新增的操作所耗费的时间:
//ArrayList
@Test
public void testAdd(){
ArrayList list = new ArrayList<Integer>();
Long before = System.currentTimeMillis();
for(int i=0;i<10000000;i++){
list.add(i);
}
Long after = System.currentTimeMillis();
System.out.println(after-before);
}
//LinkedList
@Test
public void Test(){
LinkedList list = new LinkedList<Integer>();
Long before = System.currentTimeMillis();
for(int i=0;i<10000000;i++){
list.add(i);
}
Long after = System.currentTimeMillis();
System.out.println(after-before);
}
//运行结果
//ArrayList
2493
//LinkedList
7179
我们可以看到,在10000000次的循环新增过程所耗费的时间,ArrayList比LinkedList要短很多,那么我们来看下底层是如何实现的,为什么会有如此大的性能差距;
ArrayList的增与扩容机制
关于在讲述到ArrayList的增的机制的时候,必定离不开其扩容机制,我们首先看一下当我们新建一个ArrayList的时候,构造函数做了哪些操作;
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
我们可以看到,其为elementData数组赋值了一个空数组,可以看到此时并没有对于size进行修改;
然后我们来观察其add()方法的源码
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
我们可以看到,在add的时候,先进行 ensureCapacityInternal 方法,这个方法从词义上理解,大概是一个调整容量的方法,我们继续往下看,可以看到将size+1传参入该方法中
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
其将之前的size+1作为 minCapacity 再次传参到 calculateCapacity 方法中,然后在执行 ensureExplicitCapacity 方法,我们依次来看下其源码:
private static final int DEFAULT_CAPACITY = 10;
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
我们可以看到,在进入该方法之后,我们先进行判断,对象数组是否为 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 常量,这个常量就是在空参构造函数运行的时候的所赋值的常量,也就是说,如果容器是空的,那么将 DEFAULT_CAPACITY 与 minCapacity 比较得出较大值,否则返回 minCapacity ,这一步主要是将其调整为所预设好的容器大小;
我们再来看 ensureExplicitCapacity 方法
protected transient int modCount = 0;
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
这个modCount相当于是指该容器结构被改变了多少次,不太重要这里不多赘述;主要是看下面的判断语句,我们可以看到,他把 minCapacity 与数组的长度进行比较,如果 minCapacity 大于数组的长度了,那么我们便需要进行 grow 方法扩容;
小伙伴们可能还是觉得很迷惑,扩容啥意思啥情况,我来简单跟大家阐述一下: ArrayList 实现的是一个叫做动态数组的结构,所以我们会每次设置一个固定大小的数组,size代表的是当前的容器用到哪一位了,如果size超过容器大小了,我们便进行 grow 方法扩容。
简而言之:
size=游标
elementData.length=容器大小
我们继续看grow函数
private void grow(int minCapacity) {
// overflow-conscious code
// 获取当前容器容量
int oldCapacity = elementData.length;
// 获取未来的容器容量,此处位运算可以看做旧容量*1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果扩容不够,直接把游标赋值给新容量
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果新容量超过预设最大数组容量的操作,忽略不管
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
ArrayList增加元素总结
由此我们便可以看到其新增一个元素时候的操作了,我们来简单概括一下:
- 先调整容器大小
- 如果游标比容器大小大,那么便进入扩容,否则进入步骤4
- 扩容以1.5倍进行扩容,扩容后用 Arrays.copyOf 方法复制原数组到新数组
- 将新元素赋值到size位,size++
- 返回成功
我们来分析一下整个过程的时间复杂度,可以看到单次增加新元素的复杂度是 o(1) ,直接接到数组尾部;但是如果要进行扩容的话,那么我们会有一个将原数组复制的过程,此过程明显为 o(n) ;但是根据极客时间的资料说明,因为大多数时候,我们都是进行增加新元素的 o(1) 操作,也就是说,我们可以把复制的 o(n) 操作抽象平摊一下,那么其实总体看来add还是 o(1) 的时间复杂度;
结论:add()的时间复杂度为o(1)
LinkedList的增机制
老规矩,我们先看空参构造函数
public LinkedList() {
}
我们发现它就是一个空方法,没有任何操作;
LinkedList插入尾部
我们来看其add方法的源码
public boolean add(E e) {
linkLast(e);
return true;
}
我们可以看到其内部是有一个linkLast方法,从单词意思可以看到其是在链表尾部连接元素;
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
其主要过程便是:
- 先获取之前的尾结点
- 根据元素创建新节点
- 将新节点赋值给尾结点
- 判断之前的尾结点是否为null
- 是的话将新节点赋值给头结点
- 不是的话进行旧尾结点与新尾结点的连接
- size长度加一
- modCount修改次数加一
这个插入尾部十分简单,一路看下来就可以理解;
但是我们都知道,既然是一个链表,而且LinkedList经常作为栈与队列来使用,那么不得不说一下LinkedList的头插
LinkedList插入头部
我们查看源码,可以看到一个关键方法:linkFirst
private void linkFirst(E e) {
final Node<E> f = first;
final Node<E> newNode = new Node<>(null, e, f);
first = newNode;
if (f == null)
last = newNode;
else
f.prev = newNode;
size++;
modCount++;
}
代码也是一样简单明了,我们来看一下过程
- 获取头结点
- 根据元素创建节点
- 将新元素赋值给头结点
- 如果头结点是空,进入步骤5;否则进入步骤6
- 将新节点赋值给末节点
- 将新节点作为头结点的上一个节点
- 大小size+1
- 修改次数modCount+1
也十分简单,就是普通的链表新增方法;
LinkedList的增总结
其实很简单,就是普通的头插法与尾插法,我们查阅源码之后,可以看到其实增的方法很多,但是都是基于linkFirst方法与linkLast方法。
删
ArrayList的元素删除原理
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
其思路很好理解:
- 先判断是否超出容器大小
- 获取要删除的元素
- 获取要移动的位数numMoved
- 如果numMoved超过0,那么便将错位复制到数组后面
- 元素丢弃尾元素,size自减
这个过程可以看做之前学习c语言的时候,如果要删除数组中间的一个元素,那么后面的元素全部要往前移动一位;而如果删除的是尾部,那么直接把游标往前挪一位即可;
LinkedList的元素删除原理
查阅源码,我们可以看到,虽然LinkedList类的删除元素方法很多,但是万变不离其宗,都是基于以下三个核心方法:
- unlink(删除中间元素)
- unlinkFirst(删除头部元素)
- unlinkLast(删除尾部元素)
我们一个一个来看其实现
unlink
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
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;
}
private E unlinkLast(Node<E> l) {
// assert l == last && l != null;
final E element = l.item;
final Node<E> prev = l.prev;
l.item = null;
l.prev = null; // help GC
last = prev;
if (prev == null)
first = null;
else
prev.next = null;
size--;
modCount++;
return element;
}
平平无奇的删除链表节点,这里就不多赘述了,要注意的就是他有个把对象设置为空,帮助垃圾回收的地方;
ArrayList与LinkedList的删除元素总结
我们可以看到,他们的删除都是使用的很朴素的方式;
由于ArrayList使用的动态数组,那么经常会出现一个数组迁移的过程,所以大部分时间里时间复杂度为o(n);
而由于LinkedList使用的是链表,其删除是删除单个节点,所以复杂度会一直都是o(1);
查
对于一个容器,最为重要的可能就是查操作了,我们来看一下查操作上的不同;
ArrayList的查找元素原理
emmmm,我仔细看了下源码,其实没什么好说的,简单来说就是
- get方法是使用随机访问,直接通过数组下标访问
- indexOf是遍历整个数组,返回元素的下标
- contains调用了indexOf方法来判断是否有该元素
LinkedList的查找元素原理
先看get方法
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
// assert isElementIndex(index);
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;
}
}
简单来说,便是他进行了一个小技巧处理,先判断我们需要的这个元素是位于序列的前半段还是后半段,然后来进行不同方向的查找;
其他的查找方法便是直接返回字段;
查找元素总结
其实查的话,没有什么优化,只要记住ArrayList使用随机访问即可;
改
这个查太过于简单了,直接略过,其实就是正常的数组元素修改与链表元素修改
总结
ArrayList与LinkedList是容器中较为简单的两个容器,其中比较常问的是ArrayList的扩容机制以及二者的区别等等,希望读者看完该博客之后能有个清晰的认识