LinkedList 源码深度解析
1、什么是LinkedList?
LinkedList同ArrayList一样都是List接口的实现类,它与ArrayList底层基于动态数组实现不同,它的底层实现为双向链表。链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑是通过链表中的指针链接次序实现的。链表由一系列结点组成,结点可以在运行时动态生成。每个节点包括两部分:一个是存储元素的数据域,另一个是存储下一个节点地址的指针域。双链表是链表的一种,由结点组成,每个结点中都有两个指针,分别是直接后继和直接前驱。
2、重新认识LinkedList
2.1、什么是LinkedList?
LinkedList是基于双向链表实现的List类,与ArrayList基于数组的实现方式不同。链表数据结构的特点是每个元素分配的空间不必连续、插入和删除元素时速度非常快、但访问元素的速度较慢,适用于集合元素先入先出和先入后出的场景。
2.2、长啥样?
如图是一个4节点的双向链表结构的LinkedList,双向链表里的每个节点称为Node,Node在java里的实现如下:
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.3、双向链表有哪些基本概念?
- 双向链表里,每个节点有三个字段,item为本节点的值,prev指向前一个节点,next指向后一个节点;
- 双向链表的最前面的一个节点也称为头节点,它的prev指向null;
- 双向链表的最最后的一个节点也称为尾节点,它的next指向null;
- 双向链表的大小和机器的内存有关,理论上讲是没有限制的;
- 当双向链表没有数据时,头节点和尾节点是同一个节点,且prev和next都指向null;
3、知其所以然 ---- 撸源码
3.1、新增
LinkedList的新增操作有两种常用方法:add(e)和offer(e),它们的源码分别是:
public boolean offer(E e) {
return add(e);
}
public boolean add(E e) {
linkLast(e);
return true;
}
可以看到两个方法的底层实现完全一致,都是调用linkLast(e),不过这里有一个坑要注意一点,那就是add方法无论成功失败都会返回true,虽然Queue 接口注释上面写的建议 add 方法操作失败时抛出异常,但显然是不可能抛出的;
新增数据的过程本质上就是对双向链表追加节点的过程,由于是双向链表,我们可以选择追加到链表的头部或者尾部,在Java里,add方法默认是从尾部开始追加,addFirst方法从头部开始追加。
下面开始逐行分析源码:
// 从尾部开始追加节点
void linkLast(E e) {
// 把尾节点数据暂存
final Node<E> l = last;
//新建新的节点,l 是前一个节点,e 是当前节点的值,后一个节点是 null
final Node<E> newNode = new Node<>(l, e, null);
//新建的节点放在尾部
last = newNode;
//如果链表为空,头部和尾部是同一个节点,都是新建的节点
if (l == null)
first = newNode;
//否则把前尾节点的下一个节点,指向当前尾节点。
else
l.next = newNode;
//大小和版本更改
size++;
modCount++;
}
// 从头部追加
private void linkFirst(E e) {
//头节点赋值给临时变量
final Node<E> f = first;
//新建节点,前一个节点指向null,e是新建节点的值,f 是新建节点的下一个节点
final Node<E> newNode = new Node<>(null, e, f);
//新建节点成为头节点
first = newNode;
//头节点为空,就是链表为空,头尾节点是一个节点。
if (f == null)
last = newNode;
//上一个头节点的前一个节点就是当前节点
else
f.prev = newNode;
size++;
modCount++;
}
新增数据小结:
通过分析源码,我们发现新增数据的过程本质上就是追加双向链表节点的过程,不管是追加头部还是尾部,代码逻辑上都是类似的,分为3步:
- 暂存和追加节点相关的那个节点;
- 新增一个节点;
- 判空并修改节点prev和next指向,完成操作。
3.2、删除
LinkList删除数据有两种常用方法remove()(同removeFirst(),removeLast()类似)和poll(),它们的源码分别是:
// remove()
public E remove() {
return removeFirst();
}
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}
// poll(e)
public E poll() {
final Node<E> f = first;
return (f == null) ? null : unlinkFirst(f);
}
这里我们会发现当链表为空时,remove会抛出异常,而poll会返回null,除此之外,底层实现逻辑是一致的;
对双向链表的删除操作,也分为头部删除和尾部删除两种,源码实现类似,我们以头部删除为例,逐行分析源码:
//从头删除节点
//f 是链表头节点
private E unlinkFirst(Node<E> f) {
// 拿出头节点的值,作为方法的返回值
final E element = f.item;
// 拿出头节点的下一个节点
final Node<E> next = f.next;
//将节点的值,前后指向节点都指向null,目的是为了帮助 GC 回收头节点
f.item = null;
f.next = null;
// 头节点的下一个节点成为头节点
first = next;
//如果 next 为空,表明链表为空
if (next == null)
last = null;
//链表不为空,头节点的前一个节点指向 null
else
next.prev = null;
//修改链表大小和版本
size--;
modCount++;
return element;
}
删除数据小结:
删除操作比新增操作还要简单,只是把前后节点的指向修改一下,然后把要删除的节点的值返回并把值和指向都置为空,经过查阅资料,知道了这样做的目的是帮助GC回收。
3.3、查询
LinkedList查询数据的两种常用方法是element()和peek(),首先我们看一下这两种方法的源码实现:
// element()
public E element() {
return getFirst();
}
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
// peek()
public E peek() {
final Node<E> f = first;
return (f == null) ? null : f.item;
}
看到这里,我们发现这两种查询和删除类似,最底层逻辑一致,只是当链表为空时,element会抛出异常,peek会返回null;
// 根据链表的索引位置查询节点
Node<E> node(int index) {
// >> 是右移运算符,作用相当于除以2
// index 处于队列的前半部分,从头开始找
if (index < (size >> 1)) {
Node<E> x = first;
// 直到 for 循环到 index 的前一个 node
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {// index 处于队列的后半部分,从尾开始找
Node<E> x = last;
// 直到 for 循环到 index 的后一个 node
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
查找数据小结:
这里有一个非常值得学习的地方,LinkedList查找的算法并没有采用从头到尾的做法,而是采用了简单二分法。首先根据index位于链表的哪一部分,然后决定是从头节点开始查找还是从尾节点开始查找,采用这种算法,可以使循环次数至少降低一半,提高性能。同时,考虑到由于链表只能从头节点或者尾节点开始遍历,因此这种二分法已经是最优解!
4、迭代器
LinkedList底层实现是双向链表,因此也要实现双向的迭代访问,传统的Iterator接口肯定不行,因为Iterator只能支持从头到尾的访问。为了解决这个问题,Java新增了一个迭代接口----ListIterator,这个接口提供了向前和向后的迭代方法:
迭代顺序 | 方法 |
---|---|
从头到尾 | hasNext、next、nextIndex |
从尾到头 | hasPrevious、previous、previousIndex |
因此,Linkedlist实现了ListIterato接口,部分源码如下:
// 双向迭代器
private class ListItr implements ListIterator<E> {
private Node<E> lastReturned;//上一次 next 或者 previos 的节点
private Node<E> next;//下一个节点
//下一个节点的位置,从头迭代到尾,位置递增,从尾迭代到头,位置递减。
private int nextIndex;
//expectedModCount:期望版本号;modCount:目前最新版本号
private int expectedModCount = modCount;
ListItr(int index) {
// assert isPositionIndex(index);
next = (index == size) ? null : node(index);
nextIndex = index;
}
//剩下实现部分先省略
}
下面我们开始分析上面省略的实现部分,首先是从头到尾的迭代源码如下:
// 判断还有没有下一个元素
public boolean hasNext() {
//下一个节点的索引小于链表的大小,就有下一个节点,返回true
return nextIndex < size;
}
// 取下一个元素
public E next() {
//检查期望版本号有无发生变化
checkForComodification();
if (!hasNext())//再次检查
throw new NoSuchElementException();
// next 是当前节点
lastReturned = next;
// next 是下一个节点了,为下次迭代做准备
next = next.next;
nextIndex++;
return lastReturned.item;
}
可以发现从头到尾的迭代的方式就是获取当前节点的下一个节点,下面我们在逐行分析一下从尾到头的迭代:
// 如果上次节点索引位置大于 0,就说明还有节点没有遍历完,返回true
public boolean hasPrevious() {
return nextIndex > 0;
}
// 取前一个节点
public E previous() {
checkForComodification();
if (!hasPrevious())
throw new NoSuchElementException();
// next 为空场景:说明是第一次迭代,取尾节点(last)
// next 不为空场景:说明已经发生过迭代了,直接取前一个节点即可(next.prev)
lastReturned = next = (next == null) ? last : next.prev;
// 索引位置变化
nextIndex--;
return lastReturned.item;
}
这里需要注意的就是需要对next是否为空进行判断。由于是从尾到头,而next是当前节点的下一个节点,如果next尾空,就说明是第一次迭代,当前的节点就是原链表的尾节点。
最后还需要分析一个迭代器的删除源码,我们在使用linkedList删除元素时,一般推荐通过迭代器删除:
public void remove() {
checkForComodification();
// 通过前面的迭代器向前或向后迭代的源码,我们可以知道lastReturned初始为null,因此这里时为了防止开发者在没有执行next()或者previous的情况下调用删除操作,如果是这样的话,会抛出异常
if (lastReturned == null)
throw new IllegalStateException();
Node<E> lastNext = lastReturned.next;
//删除当前节点
unlink(lastReturned);
// 想要满足next == lastReturned,只有一种情况,就是目前是以从尾到头的方式进行迭代的,这时候由于lastReturned节点删除了,会被GC回收,就需要把next指向lastReturned.next位置,这样进行下一次previous时,才可以找到lastReturned.prev
if (next == lastReturned)
next = lastNext;
else
nextIndex--;
lastReturned = null;
expectedModCount++;
}
5、inkedList总结
- 底层是基于双向链表实现的,因此增加,删除效率较高,查询效率较慢;
- 查询的源码采用二分查找,在只能从头或尾进行查询的情况下是最优解值,这在我们平时写代码遇到这种情况时可以学习;
- 迭代器基于Java的 ListIterator来完成双向迭代;
- 存储同样数据,占用内存要高于ArrayList,但理论上没有长度限制,而ArrayList的容量最多为Interger的最大值;
- 删除数据时推荐使用迭代器删除,因为迭代器的remove加了很多前置限制,同时删除数据时会导致LinkedList的状态发生变化,它会更新cursor来同步这一变化;
- 非线程安全。