1)类分析
分析一个类的时候,我们首先要从它的继承关系入手。继承关系很大程度上反应了该类的功能。
首先看大家比较熟悉的List接口和Queue接口,这两个接口分别说明了LinkedList可以同时作为队列和列表来使用。
LinkedList实现了Deque(double ended queue,双端队列),Deque的父类就是Queue,实现该接口代表了LinkedList可以作为一个队列来使用。在文章末尾我会就该使用方法来做一个演示。
LinkedList同时也实现了List(列表),所以代表LinkedList也能像ArrayList一样,能过通过索引来实现增删改查,这也是我们后面进行源码分析的主要部分。
然后就是就是继承了AbstractSequentialList,这个类就是实现了列表的核心类。子类继承该类,只需要额外实现部分代码即可完成的创造一个能够访问某种列表(链表)的类。
Serializable接口,允许LinkedList被序列化和反序列化。
Cloneable接口,允许LinkedList被克隆(复制),是浅拷贝。
至此,我们已经大概看完LinkedList的继承关系了,大概总结一下,就是LinkedList的底层实现其实就是一个双端队列(双向链表),该类可以由Queue的多态形式作为一个队列使用,也可以以List的多态形式作为一个列表来使用。
2)List方法解析:
List方法的核心无非就是增删改查,我们就对这四个点一一分析。
该类内部有一个节点类,了解即可。
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.1 增(插入)
先看不指定插入位置的add(E e)方法,该方法将元素插入到列表的末尾。
/**
* 直接向链表末尾添加元素,尾添加
* @param e 需要添加的元素
* @return 是否添加成功
*/
public boolean add(E e) {
linkLast(e);
return true;
}
/**
* 向末尾添加元素的具体执行方法
* @param e 需要添加的元素
*/
void linkLast(E e) {
//将类中的成员变量last复制一份
final Node<E> l = last;
//通过构造方法创建了新的节点,该新节点的pre指向当前的尾节点,next指向null
final Node<E> newNode = new Node<>(l, e, null);
//然后让类的last节点尾新创建的节点
last = newNode;
//如果我们之前复制的尾节点是空的话,说明原来的列表是空的
//当前创建的节点是列表中的唯一一个节点,所以要令first也是这个新创建的节点
if (l == null)
first = newNode;
else //如果之前复制的尾节点不是空节点,那么代表列表中至少存在一个以上节点
//我们就直接让之前复制的这个节点的next指向我们新创建的节点
//这样就实现了之前复制的节点和我们新创建的节点的前后联系
//是的我们新创建的节点成功成为最新的尾节点
l.next = newNode;
//列表的长度+1
size++;
//列表的操作次数+1
modCount++;
}
这里的modCount需要注意,这里记录列表的操作次数,是为了防止在并发的时候出现的一些并发异常(比如一个遍历在删除某元素,另一个遍历在修改该元素)。这个modCount就起到了一个校验的作用。
之后再看根据索引插入的add(int index,E element)方法,一个个慢慢来分析:
/**
* 通过索引将元素插入指定的位置
* @param index 需要插入的位置(索引)
* @param element 需要插入的元素
*/
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
add方法先调用了checkPositionIndex(int index)方法来判断index是否合法:
private void checkPositionIndex(int index) {
if (!isPositionIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
如果index不合法,就抛出下标越界异常。在具体看一下isPositionIndex方法:
private boolean isPositionIndex(int index) {
return index >= 0 && index <= size;
}
这里有一个小细节,就是index必须是>=0,这个我们知道,因为索引是从0开始的,但是为什么index要<=size呢?回到add方法,add方法有个判断index == size的,也就是说,如果插入的位置跟列表的长度一致,就是默认插在列表的最后,所以这里是允许index<=size。
这里特意讲这个,是因为后续的修改和查询也有相应的判断代码,但是它们的判断代码可没有=index的处理哦,index只能<size。怕大家记混了,所以在这里就把这点说一下。
继续回来看我们的add方法,需要注意的是,调用linkBefore的时候,同时调用node(index)这个方法哦,该方法我们只分析一遍,但是在后面也都会出现的。
/**
* 通过索引将元素插入指定的位置
* @param index 需要插入的位置(索引)
* @param element 需要插入的元素
*/
public void add(int index, E element) {
//检查索引是否合法
checkPositionIndex(index);
if (index == size) //如果索引等于列表的长度,代表需要插入尾部
linkLast(element);
else //否则就插入指定的位置
//先通过node(index)拿到指定位置的节点
linkBefore(element, node(index));
}
/**
* 根据索引返回指定位置处的节点
* @param index 需要查找的节点的位置(索引)
* @return 指定位置的节点
*/
Node<E> node(int index) {
//这里size右移一位,相当于size/2,在计算机内部,左移右移是比 * / 要快的
//这段的意思就是如果index<size/2,那么就从头节点开始向后遍历,直到找到指定位置的节点
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {//如果index>=size/2,那么就从尾节点开始向前遍历,直到找到指定位置的节点
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
/**
* 通过拿到的指定位置的节点,创建新的节点插入到它之前
* @param e 需要插入的元素
* @param succ 拿到的指定位置的节点
*/
void linkBefore(E e, Node<E> succ) {
//复制一个我们拿到节点的上一个节点,也就是拿到节点的上一个的节点
final Node<E> pred = succ.prev;
//将拿到的节点作为新创建节点的next,将拿到节点指向上一个的节点pred作为新创建节点的pred节点
//举例如下: (1是succ是上一个节点,2是新创建的节点,3是succ)
// 在没有创建新节点的时候 1->3, 1<-3,
//创建了新节点2之后 1->3,1<-3,1<-2,2->3,也就是当前虽然1和2节点还是互相链接
//但是新创建的节点前后已经将他们关联起来了,也就是该节点已经满足插入的条件
//接下来只需要修改1和3节点的后一个节点和前一个节点即可
final Node<E> newNode = new Node<>(pred, e, succ);
//这里我们令拿到的节点的上一个节点是新创建的节点
succ.prev = newNode;
if (pred == null) //如果拿到的节点复制出来的上一个节点是空的话,代表我们拿到的节点有可能只有一个
//所以我们需要更新头节点
//因为我们拿到的节点succ,是要在我们创建的节点之后的,所以succ不能是头节点了
first = newNode;
else
//如果pred不为空,代表该列表中不是只有一个元素,所以我们直接令拿到的节点复制出来的上一个节点
//pred的下一个为我们新创建的节点,这样就实现了 1->2, 2->3,完成了插入操作(1是succ是上一个节点,2是新创建的节点,3是succ)
pred.next = newNode;
size++; //列表的长度增加
modCount++; //列表的操作次数增加
}
这样就实现了我们List中通过索引增加元素的add方法了
2.2 删(删除)
删除和增加一样,有无参的remove()方法和能够指定删除某个索引的remove(int index)方法,一样是一个个来分析。
首先看无参的remove()方法:
/**
* 删除并得到头节点的元素
* @return 删除的头节点的元素
*/
public E remove() {
return removeFirst();
}
/**
* 删除头节点的方法
* @return 删除的头节点的元素
*/
public E removeFirst() {
//先复制头节点
final Node<E> f = first;
//判断头节点是否为空,为空则代表当前队列都为空,直接抛出无元素异常
if (f == null)
throw new NoSuchElementException();
//如果头节点不为空,则调用具体的删除方法
return unlinkFirst(f);
}
/**
* 具体的删除头节点代码
* @param f 头节点
* @return 删除的头节点的元素
*/
private E unlinkFirst(Node<E> f) {
//先将头节点的元素复制出来,用作返回
final E element = f.item;
//然后复制头节点的下一个元素
final Node<E> next = f.next;
f.item = null;
f.next = null; // help GC
/**
* 这里很有意思,有一句 help GC
* GC( Garbage Collection) ,垃圾收集器
* 这个涉及到Java的垃圾回收机制,有兴趣的可以自行查找
* 将f节点的三个属性设置为空之后,就会让该节点被回收
*/
//令头节点为复制的下一个节点,也就是头节点向后移动了一个节点
first = next;
if (next == null) // 如果向后移动了一个节点,该节点为null了,代表列表中没有元素了
//所以就要头节点和尾节点都为空
last = null;
else
//如果向后移动一个节点不为空,那么代表该节点就成为了头节点,头节点是没有上一个节点的
//所以就设置该节点的上一个节点为空了
next.prev = null;
//然后列表的元素减少一个
size--;
//列表的操作次数+1
modCount++;
//返回我们删除的头节点的元素
return element;
}
再看一下根据索引删除的remove(int index)方法:
private void checkElementIndex(int index) {
if (!isElementIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
private boolean isElementIndex(int index) {
return index >= 0 && index < size;
}
/**
* 根据索引删除元素
* @param index 需要删除的节点的位置(索引)
* @return 删除的节点的元素
*/
public E remove(int index) {
//先检查索引是否合法
checkElementIndex(index);
//同样是根据node(index),先取出对应位置的节点给unlink()方法
return unlink(node(index));
}
/**
* 根据索引返回指定位置处的节点
* @param index 需要查找的节点的位置(索引)
* @return 指定位置的节点
*/
Node<E> node(int index) {
//这里size右移一位,相当于size/2,在计算机内部,左移右移是比 * / 要快的
//这段的意思就是如果index<size/2,那么就从头节点开始向后遍历,直到找到指定位置的节点
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {//如果index>=size/2,那么就从尾节点开始向前遍历,直到找到指定位置的节点
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
/**
* 根据节点删除某个节点的具体方法
* @param x 需要删除的节点
* @return 删除的节点的元素
*/
E unlink(Node<E> x) {
//复制该节点的元素出来,用作返回
final E element = x.item;
//复制该节点的下一个节点
final Node<E> next = x.next;
//复制该节点的上一个节点
final Node<E> prev = x.prev;
//判断上一个节点是否为空,如果为空,那么代表当前的节点是列表中的唯一一个节点
if (prev == null) {
//所以该节点就为头节点,尾节点的话下面的代码会处理
first = next;
} else { //如果上一个节点不是空的,那么可以直接进行替换操作
//直接令上一个节点 的 下一个节点为 我们拿到节点的下一个节点
//举例说明: 如 1->2 , 2->3 , (1是复制的上一个节点,2是我们拿到的节点,3是复制的下一个节点)
//也就是直接令 1->3 (直接让1的下一个节点是3),就直接把2这个节点无视了,跳过了它
prev.next = next;
x.prev = null; //help GC 这里同前面一样,为了帮助垃圾收集器回收该节点
}
//这里判断下一个节点是否为空,如果为空,那么代表当前节点是列表中的唯一一个节点
if (next == null) {
//就直接令尾节点 = 头节点(列表中只有一个节点的时候,头节点=尾节点)
last = prev;
} else { //下一个节点不是空的,那么也可以直接进行替换操作
//直接令下一个节点 的 上一个节点为 我们拿到节点的上一个节点
//举例说明: 如 1<-2 , 2<-3 (1是复制的上一个节点,2是我们拿到的节点,3是复制的下一个节点)
//也就是直接令 1<-3 (直接让3的上一个节点是1),就直接把2这个节点无视了,跳过了它
next.prev = prev;
x.next = null; //help GC
}
x.item = null; //help GC
//列表的长度-1
size--;
//列表的操作次数+1
modCount++;
//返回我们删除的节点的元素
return element;
}
这样就实现了我们List中通过索引删除元素的remove方法了
2.3 改(修改)
相较于增加和删除,修改和查询是最容易理解的操作了,因为节点本质上就是一个引用类型的对象,这个对象只要取出来,修改它的值即可。原先列表的结构并没有发生变化:
private void checkElementIndex(int index) {
if (!isElementIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
private boolean isElementIndex(int index) {
return index >= 0 && index < size;
}
/**
* 根据索引修改某处节点的值
* @param index 需要修改的节点的位置
* @param element 需要修改成的元素
* @return 修改之前的那个元素
*/
public E set(int index, E element) {
//和删除一样,同样的索引检查是否合法
checkElementIndex(index);
//然后通过node方法拿到需要修改元素的节点
Node<E> x = node(index);
//将该节点的元素记录下来,用作返回
E oldVal = x.item;
//直接修改该节点的元素即可
x.item = element;
//返回修改之前的元素
return oldVal;
}
/**
* 根据索引返回指定位置处的节点
* @param index 需要查找的节点的位置(索引)
* @return 指定位置的节点
*/
Node<E> node(int index) {
//这里size右移一位,相当于size/2,在计算机内部,左移右移是比 * / 要快的
//这段的意思就是如果index<size/2,那么就从头节点开始向后遍历,直到找到指定位置的节点
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {//如果index>=size/2,那么就从尾节点开始向前遍历,直到找到指定位置的节点
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
修改的代码是不是看起来蛮简单的呀,但是也只是看起来简单,但是效率可是没有arrayList快的哦。
2.4 查(查询)
也就是List中的get方法,拿到对应的节点,并返回该节点的元素,这样就实现了通过索引取出某个值。
private void checkElementIndex(int index) {
if (!isElementIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
private boolean isElementIndex(int index) {
return index >= 0 && index < size;
}
public E get(int index) {
//跟删除一样,检查索引是否合法
checkElementIndex(index);
//直接通过node(int index)方法拿到对应节点
//然后返回该节点的元素即可
return node(index).item;
}
/**
* 根据索引返回指定位置处的节点
* @param index 需要查找的节点的位置(索引)
* @return 指定位置的节点
*/
Node<E> node(int index) {
//这里size右移一位,相当于size/2,在计算机内部,左移右移是比 * / 要快的
//这段的意思就是如果index<size/2,那么就从头节点开始向后遍历,直到找到指定位置的节点
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {//如果index>=size/2,那么就从尾节点开始向前遍历,直到找到指定位置的节点
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
2.5 小结
其实LinkedList中的实现逻辑都很清晰的,只要看明白了它的内部类Node的创建,它实现的list的功能也就都明白了,核心就是能够根据所以查询的Node(int index)方法。
3) Queue方法解析
Queue就是一个队列,那么队列基本的方法无非就是入队和出队。
来分析一下Queue,我个人的常用方法,一个offer(e),将元素入队,返回值是一个boolean,代表是否入队成功,另一个是 poll(),让元素出队并且删除该元素。
再看一下Queue的子接口,Deque,我们上面说了,它是一个双端队列,也就是可以同时从头尾进行操作,那么换个角度想一下,双端队列,和列表,有什么差别呢?这个就留给大家思考。
3.1 练习
这里我挑两个队列(Queue)的方法出来,作为作业,看一下大家是否真的看明白了LinkedList的源码~
3.1.1 入队
public boolean offer(E e) {
return add(e);
}
public boolean add(E e) {
linkLast(e);
return true;
}
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++;
}
3.1.2 出队
public E poll() {
final Node<E> f = first;
return (f == null) ? null : unlinkFirst(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;
}
3.2 小结
如果3.1的练习的方法大家都能看懂,那么就证明确实理解了,如果还是迷迷糊糊的话,可以尝试自己点进LinkedList的源码中进行查看哦~
4) 总结
其实LinkedList的成员属性,就记录并且帮助完成了一部分的功能,该类的底层实现并不复杂,还十分有趣,希望能对大家有所帮助,如果有疑惑或者文章出错也可以评论留言~
最最最后的小作业,为什么我把final 修饰的临时存放的 l ( last ) ,或者 f (first) 叫做复制呢?