LinkedList
上一期文章中我解析了 ArrayList 的源码实现,那么作为与它具备同样存储功能的 LinkedList ,我们还是很有必要了解它的源码实现的。如果你对于 ArrayList 还不熟悉,那么你可以进入 不会吧,都2023年了,你还搞不懂ArrayList吗? 进行学习。
文中的源码基于JDK 11,如有不同请以你的版本为主
概述
ArrayList 底层实现是基于数组,那么 LinkedList 是基于什么实现的?没错,就是链表并且是双向链表,每个节点都指向着上一个节点和下一个节点,类似这样
既然 LinkedList 是基于链表,那么它也具有了链表的特点:查询慢,增加和删除快
类结构
类结构图
类结构解析
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
}
LinkedList 继承了 AbstractSequentialList,实现了 List 、Deque 、Cloneable 和 Serializable 接口
LinkedList实现了Deque接口,表明了LinkedList可以像队列一样装卸数据,主要集中在插入头部和插入尾部,通过poll和peek获取数据等等LinkedList实现了Cloneable接口并重写了clone方法,表明了LinkedList可以被克隆LinkedList实现了Serializable接口,表明了LinkedList支持序列化
类属性
// 元素的个数
transient int size = 0;
// 首节点
/**
* Pointer to first node.
*/
transient Node<E> first;
// 尾节点
/**
* Pointer to last node.
*/
transient Node<E> last;
LinkedList 使用 Node 类作为节点,看下 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;
}
}
构造函数
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
LinkedList 只有两个构造函数,无参的构造函数什么都不处理,有参的构造函数会将集合存到 LinkedList 中
插入数据
LinkedList 中提供了两种方式去插入数据
- 插入尾部
- 插入头部
- 按索引位置插入
插入尾部
我们平时通过 add 方法插入元素时,其实就是插入到链表的尾部。当然,你也可以通过 addLast 、offer 和 offerLast 方法(实现了 Deque 接口)插入尾部,他们的效果是一样的,只是 add 、 offer 方法返回值是 boolean
public boolean add(E e) {
linkLast(e);
// 返回true
return true;
}
public void addLast(E e) {
linkLast(e);
}
add 方法和 addLast 方法都是通过 linkLast 方法将数据插入到尾部,那么我们来看下 linkLast 的实现
void linkLast(E e) {
// 获取当前的尾节点
final Node<E> l = last;
// 构造一个新节点,将它的前节点置为首节点,后节点置为null
final Node<E> newNode = new Node<>(l, e, null);
// 将尾节点置为新节点
last = newNode;
// 假如上一次的尾节点为null,那么将新节点就是首节点,否则将上一次的尾节点的后节点置为将新节点
if (l == null)
first = newNode;
else
l.next = newNode;
// 元素个数+1
size++;
modCount++;
}
linkLast 源码其实很简单,主要是在判断尾节点是否为 null
linkLast尾节点为null时,那么新节点同时是首节点和尾结点linkLast尾节点不为null时,那么需要把尾节点的后节点置为新节点
插入头部
LinkedList 提供了 addFirst 、push 和 offerFirst 方法(实现了 Deque 接口)插入头部,offerFirst 和 push 都是通过 addFirst 插入,最终调用的 linkFirst 方法
public void addFirst(E e) {
linkFirst(e);
}
public boolean offerFirst(E e) {
addFirst(e);
return true;
}
// 最终实现
private void linkFirst(E e) {
// 获取当前的首节点
final Node<E> f = first;
// 构造一个新节点,将它的前节点置为null,后节点置为首节点
final Node<E> newNode = new Node<>(null, e, f);
// 将首节点置为新节点
first = newNode;
// 如果首节点为null时,将尾结点置为新节点,否则将首节点的前节点置为新节点
if (f == null)
last = newNode;
else
f.prev = newNode;
// 元素个数+1
size++;
modCount++;
}
linkFirst 源码其实很简单,主要是在判断首节点是否为 null
linkLast首节点为null时,那么新节点同时是首节点和尾结点linkLast首节点不为null时,那么需要把首节点的前节点置为新节点
按索引插入
LinkedList 和 ArrayList 一样提供了以索引插入的方式
public void add(int index, E element) {
// 检测索引是否数组越界
checkPositionIndex(index);
// index = size时,直接插入尾部
if (index == size)
linkLast(element);
// 否则调用linkBefore插入
else
linkBefore(element, node(index));
}
private void checkPositionIndex(int index) {
if (!isPositionIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
// 索引必须在[0,size]区间内
private boolean isPositionIndex(int index) {
return index >= 0 && index <= size;
}
// 根据索引获取节点
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;
}
}
- 首先会判断索引是否数组越界,索引必须在[0,size]区间内
- 根据索引判断是否是尾部插入,如果是尾部插入直接调用
linkLast插入,否则调用linkBefore插入 - 根据索引获取该位置上的节点,如果索引在链表前半段内那么从头部开始遍历,否则从尾部开始倒序遍历。这样做的好处是减少遍历的次数,减少遍历所需的时间
linkLast 上面已经分析了,那我们来看下 linkBefore 吧
// succ就是原索引位置的节点
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
// 获取原索引位置上的前节点
final Node<E> pred = succ.prev;
// 构造一个新节点,将他的前节点置为原索引位置上的前节点,后节点置为原索引位置的节点
final Node<E> newNode = new Node<>(pred, e, succ);
// 将原索引位置的节点的前节点置为新节点
succ.prev = newNode;
// 如果原索引位置上的前节点为null,那么需要将新节点置为首节点,否则将原索引位置上的前节点的后节点置为新节点
if (pred == null)
first = newNode;
else
pred.next = newNode;
// 元素个数+1
size++;
modCount++;
}
看完上面的描述是不是觉得很混乱?其实我个人也觉得没表述清楚,其实总结为一句话:非尾部插入时,该位置的前节点和该位置的后节点指向都需要改变
为了更清晰的了解过程,下面我就用图片演示下
查找数据
LinkedList 实现了 Deque 接口,所以 LinkedList 在查找数据上有 peek 和 poll 操作
我这里把查找数据归结为三类:
-
按索引查找
-
头节点、尾节点查找
-
peek 、 poll 查找
按索引查找
通过索引查找时,索引必须在区间[0,size)范围内,否则会抛出数组越界异常
public E get(int index) {
// 检查索引是否有效
checkElementIndex(index);
return node(index).item;
}
private void checkElementIndex(int index) {
if (!isElementIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
// 索引必须在区间[0,size)内
private boolean isElementIndex(int index) {
return index >= 0 && index < size;
}
// 根据索引获取节点
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;
}
}
通过索引查找还是很简单的逻辑,总结就是两点
- 首先会判断索引是否数组越界,索引必须在[0,size)区间内
- 根据索引获取该位置上的节点,如果索引在链表前半段内那么从头部开始遍历,否则从尾部开始倒序遍历,最终返回节点的值
头节点、尾节点查找
LinkedList 实现了 Deque 接口,重写了 getFirst 和 getLast 方法,我们可以通过 getFirst 获取 LinkedList 的首节点数据,通过 getLast 获取 LinkedList 的尾节点数据
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
public E getLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return l.item;
}
其实大家应该都能想到,既然 LinkedList 中定义了首节点和尾节点,那么肯定是通过首节点和尾节点获取首节点和尾节点数据的。如果首节点(尾节点)为 null 时,抛出 NoSuchElementException 异常。
peek 和 poll
peek 和 poll 是 Queue 接口中声明的方法,Deque 接口继承了 Queue,并且声明了自己的方法,主要包括 pollFirst 、pollLast 、peekFirst 和 peekLast 等。
先说下 peek 和 poll 的区别,peek 只获取数据,poll 不仅会获取数据并且还会将这个数据从 LinkedList 中移除,表现像下图所示
先来看下 peek 的几个方法
public E peek() {
final Node<E> f = first;
return (f == null) ? null : f.item;
}
public E peekFirst() {
final Node<E> f = first;
return (f == null) ? null : f.item;
}
public E peekLast() {
final Node<E> l = last;
return (l == null) ? null : l.item;
}
peek会返回首节点数据,如果首节点为null时返回nullpeekFirst会返回首节点数据,如果首节点为null时返回nullpeekLast会返回尾节点数据,如果尾节点为null时返回null
peek 与 getXXX 不同的时,如果节点为 null ,peek 会返回 null ,getXXX 会抛出 NoSuchElementException 异常
再来看下 poll 的几个方法
public E poll() {
final Node<E> f = first;
// 首节点为null时返回null,否则返回unlinkFirst
return (f == null) ? null : unlinkFirst(f);
}
public E pollFirst() {
final Node<E> f = first;
// 首节点为null时返回null,否则返回unlinkFirst
return (f == null) ? null : unlinkFirst(f);
}
public E pollLast() {
final Node<E> l = last;
return (l == null) ? null : unlinkLast(l);
}
poll 与 getXXX 不同的时,如果节点为 null ,poll 会返回 null ,getXXX 会抛出 NoSuchElementException 异常
poll 和 pollFirst 完全一致,都是获取并删除首节点,pollLast 是获取并删除尾节点
private E unlinkFirst(Node<E> f) {
// assert f == first && f != null;
// 获取首节点的值
final E element = f.item;
// 获取首节点的后节点
final Node<E> next = f.next;
// 将首节点的前后节点都置为null
f.item = null;
f.next = null; // help GC
// 移除了首节点,那么后节点就是首节点了
first = next;
// 首节点的后节点为null时,移除了首节点之后 尾节点就是null,否则将后节点的前节点置为null
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;
// 将尾节点的前后节点都置为null
l.item = null;
l.prev = null; // help GC
// 移除了尾节点,那么前节点就是尾节点了
last = prev;
// 尾节点的前节点为null时,移除了尾节点之后 首节点就是null,否则将前节点的后节点置为null
if (prev == null)
first = null;
else
prev.next = null;
size--;
modCount++;
return element;
}
unlinkFirst 和 unlinkLast 的源码并不难,中心还是在于前后节点的指向
删除数据
LinkedList 实现了 Deque 接口,所以 LinkedList 在删除元素上有 removeFirst 等操作,别忘了 poll 也是可以删除元素的
我这里把查找数据归结为三类:
- 删除首节点、尾节点
- 按索引删除
- 按对象删除
删除首节点、尾节点
先来看一下 removexxx方法
public E remove() {
return removeFirst();
}
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}
public E removeLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return unlinkLast(l);
}
unlinkFirst和unlinkLast 上面已经分析过了,如果忘记了请往前看(peek和poll)
可以看到 remove 方法默认删除的首节点,删除成功时返回该节点的值。删除首、尾节点时,如果该节点为 null 会抛出 NoSuchElementException 异常,这与我们的 poll 方法略有不同:poll 节点为 null 时返回 null。
按索引删除
删除成功时会返回该节点的值
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
// 根据索引获取节点
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;
}
}
- 索引必须在[0,size)区间内,否则会抛出数组越界异常
- 通过
node方法获取该位置的元素,然后调用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;
// 如果前节点为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;
}
// 置空,以便gc
x.item = null;
// 元素-1
size--;
modCount++;
return element;
}
unlink 总结为三句话:
- 如果删除的是首节点,那么需要将首节点置为该节点的后节点
- 如果删除的是中间节点,那么需要将该节点的前节点指向它的后节点,它的后节点也指向它的前节点
- 如果删除的是尾节点,那么需要将尾节点置为该节点的前节点
按对象删除
按对象操作包括了三个方法,remove(Object) 、removeFirstOccurrence 和 removeLastOccurrence,removeFirstOccurrence 其实也是通过 remove(Object) 删除,删除成功返回 true
public boolean removeFirstOccurrence(Object o) {
return remove(o);
}
先说下为什么会有 removeFirstOccurrence 和 removeLastOccurrence?
因为 LinkedList 不是 set ,它存的数据是可以重复,那么如果按对象来删除的话,就很有必要搞清楚是从哪个方向查找后删除
看下 remove(Object) 是怎么删除的
public boolean remove(Object o) {
// 移除的对象为null时
if (o == null) {
// 从头到尾遍历节点
for (Node<E> x = first; x != null; x = x.next) {
// 假如某个节点的值为null时,通过unlink移除
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
// 从头到尾遍历节点
for (Node<E> x = first; x != null; x = x.next) {
// 假如某个节点的值与移除的对象相等时,通过unlink移除
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
总得来说,remove(Object) 会从头到尾遍历节点,找到应该删除的对象(如果删除的对象不为 null时,通过 equals 判断对象相等,所有自定义类重写 equals 很有必要),最后通过 unlink 删除。
unlink 上面 按索引删除 的小节中已经讲过
- 如果删除的是首节点,那么需要将首节点置为该节点的后节点
- 如果删除的是中间节点,那么需要将该节点的前节点指向它的后节点,它的后节点也指向它的前节点
- 如果删除的是尾节点,那么需要将尾节点置为该节点的前节点
大家想下,既然 remove(Object) 是从头到尾遍历,那么 removeLastOccurrence 应该怎么遍历呢?没错,就是从尾到头遍历
public boolean removeLastOccurrence(Object o) {
// 移除的对象为null时
if (o == null) {
// 从尾到头遍历节点
for (Node<E> x = last; x != null; x = x.prev) {
// 假如某个节点的值为null时,通过unlink移除
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
// 从尾到头遍历节点
for (Node<E> x = last; x != null; x = x.prev) {
// 假如某个节点的值与移除的对象相等时,通过unlink移除
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
removeLastOccurrence 与 remove(Object) 、 removeFirstOccurrence 的整体流程都一致,这里就不再多说了。
更改数据
LinkedList 也提供了 set 方法给我们去修改数据
public E set(int index, E element) {
// 索引必须在[0,size)区间内
checkElementIndex(index);
// 通过node获取该节点
Node<E> x = node(index);
// 获取该节点的旧值
E oldVal = x.item;
// 设置该节点的新值
x.item = element;
return oldVal;
}
set 方法只是修改了索引位置上节点的值,并不需要创建节点再指向
其他方法
clear
清除 LinkedList 的所有节点
public void clear() {
// Clearing all of the links between nodes is "unnecessary", but:
// - helps a generational GC if the discarded nodes inhabit
// more than one generation
// - is sure to free memory even if there is a reachable Iterator
for (Node<E> x = first; x != null; ) {
Node<E> next = x.next;
x.item = null;
x.next = null;
x.prev = null;
x = next;
}
first = last = null;
size = 0;
modCount++;
}
遍历链表中的所有节点,将所有节点的值、前节点和后节点都置为 null (GC回收),最后将首尾节点都置为 null,将 size 置为 0
toArray
将 LinkedList 的数据转为类型数组
public Object[] toArray() {
Object[] result = new Object[size];
int i = 0;
// 从头到尾遍历链表,将每个节点的值都添加到数组中
for (Node<E> x = first; x != null; x = x.next)
result[i++] = x.item;
return result;
}
ArrayList 与 LinkedList的区别
- 存储的数据结构不同,
ArrayList基于 数组,LinkedList基于双向链表 - 随机查找数据效率不同,
ArrayList通过元素索引就能获取数据,而LinkedList需要移动指针遍历链表,ArrayList效率比LinkedList好 - 所需容量不同,
ArrayList默认数组大小是16,而LinkedList基于链表不需要初始化容量大小 ArrayList删除数据比LinkedList慢,因为ArrayList删除数据时需要将后面的元素往前移,而LinkedList只需要重新指向前后节点即可
总结
LinkedList基于双向链表LinkedList实现的Deque,所以具有队列相关的操作LinkedList并不是线程安全的,如果考虑多线程开发可以使用CopyOnWriteArrayList或者通过Collections.synchronizedList将LinkedList转换成线程安全的List
打完收工!如果觉得本篇文章对你有作用,请不要吝啬你的赞赞哦~