揭秘双向链表LinkedList源码
一、LinkedList链表的基本结构
链表,可以简单的理解为一个链子。链子的特点就是一环套一环。当我们需要某一环的时候,只要我们拥有链子的任意一环,都能够找到我们想要的那一环。LinkedList可以看成是一个双向的链表。我们知道ArrayList内部用的是数组来存储数据。而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;
}
}
可以看到,Node类是一个嵌套类(内部类知识参考)。它的构造函数接收了要被存储的数据"element"。并且Node构造函数还接收两个其它Node对象的引用。这是用来记录前一个Node节点对象和后一个Node节点对象的内存地址。
Node类,如图,Node类构造一览无余。图片来源:点击打开链接
一个简单的链表如图所示,颜色含义参考上一个图片。图片来源:点击打开链接
二、LinkedList链表的特点
还是使用我的终极口诀,“序重步+数据结构”。
序:有序。LinkedList是List接口的实现类,List接口的最大特点就是有角标,因此是有序的。
重复:元素可以重复。内部使用“Node对象”存储数据,不同的Node类对象可以封装相同的数据,因为这两个Node对象并不是同一个对象。另外,从Node类的构造函数中,并没有对存放的元素element进行“非空”的限制,因此LinkedList可以存放“null”,即LinkedList支持存放"null"值。
同步:不同步。LinkedList是java.util包下的类,是快速失败的,因此不同步。(快速失败可以参考上一篇ArrayList源码分析)
数据结构的影响:增删速度快,查询速度慢。其实增删之前还要定位到删除位置,比ArrayList也快不到哪里去。
三、源代码分析
size表示LinkedList对象中存放的实际元素的个数。
transient int size = 0;
first代表的是当前LinkedList的头节点,last代表的是末尾节点,这两个引用可以看成是指针。它们用于指向头和尾,可以看到它们并没有被初始化,例如 first = new Node<T>(); 其实它们只是一个引用而已。
transient Node<E> first;
transient Node<E> last;
空参构造函数,没啥好说的。能看到super()就行了。
public LinkedList() {
}
接收一个集合对象c的构造函数。把这个集合对象c中的所有元素存放到LinkedList中。到底是怎么存放的呢,原来调用了addAll(..)方法。
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}
下面是最精彩的一部分了。addAll(int size,Collection c)方法也不是很复杂。总共有三种情况。
第一种情况:链表是一个空链表,什么都没有。指向头和尾的first和last指针都是空。
第二种情况:链表中已经有值了。把这个Collection集合中的所有数据存放到链表的尾部。
第三种情况:在链表的中间插入Collection集合中的所有元素。
我们先来分析第一种情况。我们把Collection的中的每一个元素的值遍历出来,为每一个元素值使用一个Node类对象进行封装。为了方便描述,Node对象可以看成是一个节点。Collection集合被封装成了“节点们”。我们在封装这些元素成为节点的同时,记录下它们相邻的节点的地址。最后,再把我们的first和last指针指向第一个节点和最后一个节点。
第二种情况就是,向已经存在元素的链表末尾追加节点。这个时候就像第一种情况那样,把所有Collection集合中的数据封装成“节点们”,然后使用last指针指向最后一个添加的节点。有人会问,那么first指针指向的是谁呢?这个不用管,链表既然有了元素,那么它必然有一个头节点。在添加头节点的那个时候,我们就已经使用first指向它了,所以现在不用关心头部。
第三种情况就是在链表中的某个位置添加这个Collection集合的元素。这个就更简单了。首先把Collection集合的所有数据封装成Node类的对象。然后就像下面的例子那样:有“苹果、香蕉、梨子”,现在要在香蕉处插入三个橘子。插入后的效果就是“苹果、橘子1、橘子2、橘子3、香蕉、梨子”。每个节点都有指向前一个和后一个的指针(可以回顾一下节点的示意图)。插入完三个橘子后,只要把苹果的后一个指向橘子1,香蕉的前一个指向橘子3,就行了。因为在中间插入的原因,头部和尾部的指针在插入头部和尾部节点的那个时候,指针就已经指向了它们,现在不用我们关心。
分析LinkedList源码,只需抓住:头节点head、尾节点last、插入处的前驱节点pred、插入处的后继节点succ
好了,分析结束,看代码咯。三种情况的简单示意图,“·”代表即将要被插入的位置。
空
口口口·
口口·口口
public boolean addAll(int index, Collection<? extends E> c) {
//检查index是否在指定的范围 index范围是[0,size]
checkPositionIndex(index);
//将集合转为数组
Object[] a = c.toArray();
//即将要被插入到链表的元素个数,用 int numNew表示
int numNew = a.length;
//健壮性检查:你存放0个干哈啊?
if (numNew == 0)
return false;
/**
* 这里的两个引用就有点意思了。首先你需要知道这两个节点是临时节点。
* pred:即将要被插入的新节点的前一个节点,因此,插入完成后,pred的下一个节点就是 新节点
* succ: 插入位置的节点。比如“苹果,香蕉,梨子”,在香蕉处插入三个橘子
* 插入后的效果就是“苹果,橘子1,橘子2,橘子3,香蕉,梨子”,那么succ代表的就是“香蕉”
*/
//18年10月7日注:插入处的前驱节点pred、插入处的后继节点succ
Node<E> pred, succ;
/**
* 接下来任务就是确定这两个临时节点的值。
* 为什么要确定这个值呢?
* 因为你添加完新节点们以后,不是需要连接上以前的那个链表吗?
* 好比上面的例子,我插入了三个橘子以后,我还得把苹果连接到橘子1,香蕉连接到橘子3。这个例子里面的succ就是“香蕉”
* 因此这两个临时节点的指针我们得确定好了
*/
if (index == size) {
/**
* 情况一:空链表,index = size = 0
* 情况二:在非空链表的末尾添加元素,这是通过构造函数传进来的
* 两种情况的共同点就是插入位置的后一个节点是null,即succ = null
* 那么pred节点就是链表的最后一个节点了。
*/
succ = null;
pred = last;
} else {
//否则,succ是当前位置的节点,可以理解为“香蕉”
succ = node(index);
//它的前一个节点是pred,即将插入到这个pred节点的后面,succ节点的前面
pred = succ.prev;
}
//插入节点
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
//使用Node封装数据,新节点的数据的前一个节点是pred,封装的数据是e
Node<E> newNode = new Node<>(pred, e, null);
//前一个节点是空,证明原来的链表是 空链表
if (pred == null){
//头指针指向这个新创建的节点
first = newNode;
} else{
//pred节点的下一个节点是 新创建的这个节点
pred.next = newNode;
}
//比如“橘子1”插入完成后,"橘子2"要被插入到“橘子1”之后,那么“橘子1”就被视为了 pred节点
pred = newNode;
}
//插入完成以后,我们还需要考虑一下 succ节点 是否为空
if (succ == null) {
//如果新节点们的后面没有节点了,那么新节点们的最后一个节点被 视为链表的末尾
last = pred;
} else {
//否则,这就是在非空链表的中间插入的,只需要按照情况三那样。
//这个时候pred代表的是新插入的节点的最后一个节点,本例子中,新插入的数据的最后一个数据被看成pred,即“橘子3”。succ代表的是"香蕉"
pred.next = succ;
//“香蕉”的前一个节点也是这个最后一个节点
succ.prev = pred;
}
//实际个数增加numNew个
size += numNew;
//快速失败机制
modCount++;
return true;
}
四、添加和删除方法
添加到头部。
思路:
1.先获取到以前的头部节点,旧节点
2.封装我们的数据成为,新节点。
3.再将头部指针指向:新节点
4.健壮性判断旧头部节点是否为空,然后修改旧头部节点的前指针指向新节点
public void addFirst(E e) {
linkFirst(e);
}
/**
* Links e as first element.
*/
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++;
}
删除头部节点。与添加头部节点类似,考虑到删除旧的头部节点以后,这个新头部是不是空是关键。
思路:找到以前的头部节点。找到以前的头部节点的下一个节点。把链表头部指针first指向新头部。修改新头部的前一个节点引用为null。
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}
/**
* Unlinks non-null first node 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;
}
其余方法大同小异,无非就是修改各个节点之间前后的引用,有兴趣的话可以参考JDK源码和API文档。
五、要点
LinkedList用的是“对象”来存储数据,这个对象是它的内部类对象Node类对象。因此,LinkedList的实际大小限制是堆内存的大小。
LinkedList是一个双向链表,它的任意一个节点都能获取到前一个节点和后一个节点的数据。
LinkedList能够存放“null”值。
特点:有序,可重复,不同步,增删块,查询慢。