LinkedList全面解析
LinkedList
文中的源码基于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
时返回null
peekFirst
会返回首节点数据,如果首节点为null
时返回null
peekLast
会返回尾节点数据,如果尾节点为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
打完收工!如果觉得本篇文章对你有作用,请不要吝啬你的赞赞哦~
转载自:https://juejin.cn/post/7270421682141134867