阅读源码,通过LinkedList回顾基础
前言
本文基于jdk1.8
书接上回,在简单介绍ArrayList的时候,提到了ArrayList实现了RandomAccess接口,拥有随机访问的能力,当时说到了这个接口配合LinkedList理解更容易。今天就来还愿了,开始阅读LinkedList。
LinkedList也算我们比较常用的几个集合之一了,对普通程序员来说,
List list1 = new ArrayList()
List list2 = new LinkedList(),
该怎么选择?
其实二者最大的区别在于实现方式的不同,只看名称我们也能知道, ArrayList基于数组,而LinkedList基于链表,所以关键在于数组和链表有啥区别。
说到这,是不是又说明了一个很重要的道理,基础,基础,基础。如果想成为一个真正的程序员,不管你是科班还是半路出家,都要下功夫夯实基础。
说回正题,ArrayList基于数组,查找快(按索引查找),增删慢;LinkedList基于链表,增删快,查找慢。但这只是相对的,仅仅知道这两点,远远不够,所以,继续往下看。
类签名
public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable

鉴于前一篇有很多遗漏,这里多说明一下:
泛型
集合类在1.5开始的版本中都采用了泛型,也就是,最主要的作用就是编译期检查,避免添加进不合理的类型。简单说:
不用泛型,List list1 = new LinkedList();此时默认类型是Object,也就是可以添加任意类型元素,在取出元素时,就需要强制类型转换,增加了出错几率。
使用泛型,List list2 = new LinkedList();其中,等号右边String可以省略。此时,编译时会执行检察,添加非String类型元素直接编译不通过,而获取时也不需要强制类型转换。当然,这里涉及到了不同时期的类型擦除,不是本文重点,后期有需要的话再专门写一下。
因为我们使用集合绝大部分情况都是希望存储同一种类型,所以使用泛型(提前声明类型)很重要。这里也体现了一种思想:错误越早暴露越好。
Serializable和Cloneable
实现Cloneable, Serializable接口,具有克隆和序列化的能力。
Deque
实现了Deque接口, 而Deque接口又继承了Queue接口,这也意味着LinkedList可以当队列使用,实现“先进先出”。
List和AbstractList
在上一篇文章中,有一个细节没有说到,可能很多人也有疑问, 为啥抽象类AbstractList已经实现了List接口,ArrayList在继承AbstractList的同时还要再次实现List接口?换到今天的主角,LinkedList继承了AbstractSequentialList,而AbstractSequentialList继承了AbstractList,为啥LinkedList还要单独实现List接口?

在Stack Overflow上看到两个回答:
一位网友说问过了设计这个类的作者本人,作者本人表示这是当时设计时的一个缺陷,就一直遗留下来了。(当然,我个人觉得这个说法有待考证)。
第二位网友举例表明了不直接再次实现List接口,使用代理时可能会出现意想不到的结果。(从实际的角度看有道理,但是细想之下集合类在jdk1.2已经出现,代理类出现在 1.3,逻辑上有点疑问。)
我个人的理解:
大神在设计集合类的时候充分考虑到了将来优化时的情况。
具体来讲,这里主要在于如何理解接口和抽象类的区别,尤其是在java8之前。接口是一种规范,方便规划体系,抽象类已经有部分实现,更多的是帮助我们减少重复代码,换言之, 这里的抽象类就相当于一个工具类,只不过恰好实现了List接口,而且鉴于java单继承,抽象类有被替换的可能。
在面向接口编程的过程中,List list= new LinkedList();如果将来LinkedList有了更好的实现,不再继承AbstractSequentialList抽象类,由于本身已经直接实现了List接口,只要内部实现符合逻辑,上述旧代码不会有问题。相反,如果不实现List,去除继承AbstractSequentialList抽象类,上述旧代码就无法编译,也就无法“向下兼容”。
RandomAccess接口(没实现)
LinkedList并没有实现RandomAccess接口,实现这个接口的是ArrayList,之所以放在这里是为了对比。
注意,这里说的随机访问的能力指的是根据索引访问,也就是list接口定义的E get(int index)方法,同时意味着ArrayList和LinkedList都必须实现这个方法。
回到问题的本质,为啥基于数组的ArrayList能随机访问,而基于链表的LinkedList不具备随机访问的能力呢?
还是最基础的知识:数组是一块连续的内存,每个元素分配固定大小,很容易定位到指定索引。而链表每个节点除了数据还有指向下一个节点的指针,内存分配不一定连续,要想知道某一个索引上的值,只能从头开始(或者从尾)一个一个遍历。
RandomAccess接口是一个标记接口,没有任何方法。唯一的作用就是使用instanceof判断一个实现集合是否具有随机访问的能力。
List list1 = new LinkedList();
if (list1 instanceof RandomAccess) {
    //...
}
可能看到这里还是不大明白,不要紧,这个问题的关键其实就是ArrayList和LinkedList对List接口中get方法实现的区别,后文会专门讲到。
变量
//实际存储元素数量
transient int size = 0;
/**
 * 指向头节点,头结点不存在指向上一节点的引用
 *
 * Invariant: (first == null && last == null) ||
 *            (first.prev == null && first.item != null)
 */
transient Node<E> first;
/**
 * 指向尾节点,尾节点不存在指向下一节点的引用
 * Invariant: (first == null && last == null) ||
 *            (last.next == null && last.item != null)
 */
transient Node<E> last;
//节点类型,包含存储的元素和分别指向下一个和上一个节点的指针
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;
    }
}
注意这里的节点类型,可以看出,LinkedList实现基于双向链表。为啥用不直接用单向链表一链到底呢?最主要的原因是为了查找效率,前面说到链表的查找效率比较低,如果是单向链表,按索引查找时,不管索引处于哪个位置,只能从头开始,平均需要N次;若是双向链表,先判断索引处于前半部分还是后半部分,再决定从头开始还是从尾开始,平均需要N/2次。当然,双向链表的缺点就是需要的存储空间变大,这从另一个方面体现了空间换时间的思想。
上述两个变量,first和last,其本质是指向对象的引用,和Student s=new Student()中的s没有区别,只不过first一定指向链表头节点,last一定指向链表尾节点,起到标记作用,让我们能够随时从头或者从尾开始遍历。
构造函数
//空参构造
public LinkedList() {
}
//通过指定集合构造LinkedList, 调用addAll方法
public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}
常用方法
常用方法比较多(多到一张图截不下),这里主要分两类,一类是List体系,一类是Deque体系.
List体系下的方法:

这里主要看两个,add,get
add(E e)
添加元素到链表末尾,成功则返回true
//添加元素到链表末尾,成功则返回true
public boolean add(E e) {
    linkLast(e);
    return true;
}
void linkLast(E e) {
    //1.复制一个指向尾节点的引用l
    final Node<E> l = last;
    //2.将待添加元素构造为一个节点,prev指向尾节点
    final Node<E> newNode = new Node<>(l, e, null);
    //3.last指向新构造的节点
    last = newNode
    //4.如果最初链表为空,则将first指向新节点
    if (l == null)
        first = newNode;
    //5.最初链表不为空,则将添加前最后元素的next指向新节点
    else
        l.next = newNode;
    //存储的元素数量+1
    size++;
    //修改次数+1
    modCount++;
}
关键在于linkLast(E e)方法,分两种情况,最初是空链表添加元素和最初为非空链表添加。
这里涉及到的知识很基础,还是链表的基本操作,但是单纯用语言很难描述清楚,所以画个简图表示一下(第一次画图,没法尽善尽美,将就一下吧)
linkLast(E e)方法
双向链表的基本形式

对于空链表的添加
对应linkLast(E e)方法注释1、2、3、4
空链表,没有节点,意味着first和last都指向null

1.复制一个指向尾节点的引用l(蓝色部分)

此时复制的引用l本质也指向了null
2.将待添加元素构造为一个节点newNode,prev指向,也就是null

3.last指向新构造的节点(红色部分)

4.最初链表为空,将first指向新节点

此时,first和last均指向唯一非空节点,当然引用newNode依然存在,但是已经没有意义。
对于非空链表的添加
对应linkLast(E e)方法注释1、2、3、5
1.复制一个指向尾节点的引用l(蓝色部分)

2.将待添加元素构造为一个节点newNode,prev指向尾节点(蓝色部分)

3.last指向新构造的节点(红色部分)

5.将添加前最后元素的next指向新节点(绿色部分)

此时,引用newNode和l引用依然存在,但是已经没有意义。
add(int index, E element)
将元素添加到指定位置
public void add(int index, E element) {
    checkPositionIndex(index);
    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}
可以看出,该方法首先检查指定索引是否符合规则,也就是在index >= 0 且 index <= size;
如果index==size,则相当于直接在链表尾部插入,直接调用linkLast方法;
以上不满足,调用linkBefore方法,而linkBefore中调用了node(index)。
node(index)
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;
    }
}
linkBefore
回过头来看linkBefore,参数分别为待插入的元素,以及指定位置的节点
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    //1.复制指向目标位置的上一个节点引用
    final Node<E> pred = succ.prev;
    //2.构造新节点,prev指向目标位置的上一个节点,next指向原来目标位置节点
    final Node<E> newNode = new Node<>(pred, e, succ);
    //3.原来节点prev指向新节点
    succ.prev = newNode;
    //4.如果插在头结点位置,则first指向新节点
    if (pred == null)
        first = newNode;
    //5.非头节点,目标位置上一节点的next指向新节点
    else
        pred.next = newNode;
    size++;
    modCount++;
}
上述过程可以看出,关键过程在于linkBefore方法,我们同样画图表示:
头结点处添加:
1.复制指向目标位置的上一个节点引用
Node<E> pred = succ.prev;

本质是指向null
2.构造新节点,prev指向目标位置的上一个节点,next指向原来目标位置节点
Node<E> newNode = new Node<>(pred, e, succ);

3.目标节点的prev指向待添加新节点
succ.prev = newNode;

4.first指向新节点
first = newNode;

中间位置添加
如图,假设指定添加到第三个节点,即index=2,则第二个和第三个节点之间必须有断开的过程。

1.复制指向目标位置的上一个节点引用,也就是第二个节点
Node<E> pred = succ.prev;

2.构造新节点,prev指向复制的上一个节点,next指向原来目标位置上的节点
Node<E> newNode = new Node<>(pred, e, succ);

3.目标节点的prev指向待添加新节点
succ.prev = newNode;

5.目标位置上一节点的next指向新节点
pred.next = newNode;

get(int index)
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}
get方法是按索引获取元素,本质调用node(index),上一部分已经提到了这个方法,虽然双向链表在一定程度上提高了效率,由N减少到N/2,但本质上时间复杂度还是N的常数倍,所以轻易不要用这个方法,在需要随机访问的时候应当使用ArrayList,在需要遍历访问以及增删多查找少的时候考虑LinkedList。之所以要有这个方法是由List接口指定,这个方法也是LinkedList没有实现RandomAccess接口的原因之一。
Deque体系下的方法:
当我们把LinkedList当队列和栈使用时,主要用到的就是Deque体系下的方法。

如果稍微细看一下,会发现上述很多方法基本是重复的,比如push(E e)其实就是调用了addFirst(e),
addFirst(e)也是直接调用了linkFirst(e);pop()就是直接调用了removeFirst();
为啥搞这么麻烦,一个方法起这么多名称?
其实是因为从不同角度来看LinkedList时,它具有不同的角色。可以说它哪里都能添加,哪里都能删除。
具体使用时建议仔细看下对应注释。
作为队列
队列的基本特点是“先进先出”,相当于链表尾添加元素,链表头删除元素。
对应的方法是offer(E e),peek(),poll()
public boolean offer(E e) {
    return add(e);
}
public boolean add(E e) {
    linkLast(e);
    return true;
}
可以看出offer方法的本质还是在链表末尾添加元素,linkLast(e)方法前面已经讲到。
/**
* Retrieves, but does not remove, the head (first element) of this list.
*
* @return the head of this list, or {@code null} if this list is empty
* @since 1.5
*/
public E peek() {
    final Node<E> f = first;
    return (f == null) ? null : f.item;
 }
peek()方法返回队列第一个元素,但是不删除元素。也就是说多次peek得到同一个元素。
 /**
 * Retrieves and removes the head (first element) of this list.
 *
 * @return the head of this list, or {@code null} if this list is empty
 * @since 1.5
 */
public E poll() {
    final Node<E> f = first;
    return (f == null) ? null : unlinkFirst(f);
}
poll() 方法返回队列第一个元素的同时并将其从队列中删除。也就是多次poll得到不同元素。
显然poll方法更符合队列的概念。
这里没有详细解说删除相关的方法,是因为如果前面的添加方法细看了,删除方法也很简单,无非是越过被删除的元素连接指针,这里没必要浪费篇幅。不妨自己画一下,有助于理解。
作为栈
栈的基本特点是“先进后出”,相当于链表头部添加元素,头部删除元素。
对应的方法是push(E e)和pop()。
public void push(E e) {
    addFirst(e);
}
public void addFirst(E e) {
    linkFirst(e);
 }
可以看出,push是在调用addFirst,进而调用linkFirst(e),而在头部添加元素,add(int index, E element)方法处已经讲到了,大体只是方法名不一样而已。
public E pop() {
    return removeFirst();
}
pop()方法返回并删除第一个元素。
总结
这篇文章主要讲了LinkedList相关的最基本的内容,更多的是回顾一些基础知识,既有java相关的,也有最基础数据结构的知识,比如链表相关的操作。第一次画图来说明问题,有时候真的是一图胜千言。写到这里最大的感受是基础很重要,它决定了你能走多远。
希望我的文章能给你带来一丝帮助!
阅读源码,通过LinkedList回顾基础的更多相关文章
- 阅读源码,HashMap回顾
		目录 回顾 HashMap简介 类签名 常量 变量 构造方法 tableSizeFor方法 添加元素 putVal方法 获取元素 getNode方法 总结 本文一是总结前面两种集合,补充一些遗漏,再者 ... 
- 阅读源码(III)
		往期系列: <由阅读源码想到> <由阅读源码想到 | 下篇> Medium上有一篇文章Why You Don't Deserve That Dream Developer Jo ... 
- 阅读源码,从ArrayList开始
		前言 为啥要阅读源码?一句话,为了写出更好的程序. 一方面,只有了解了代码的执行过程,我们才能更好的使用别人提供的工具和框架,写出高效的程序.另一方面,一些经典的代码背后蕴藏的思想和技巧很值得学习,通 ... 
- 阅读源码(IV)
		往期系列: <由阅读源码想到> <由阅读源码想到 | 下篇> <阅读源码(III)> Eric S.Raymond的写于2014年的<How to learn ... 
- How Tomcat works — 一、怎样阅读源码
		在编程的道路上,通过阅读优秀的代码来提升自己是很好的办法.一直想阅读一些开源项目,可是没有合适的机会开始.最近做项目的时候用到了shiro,需要做集群的session共享,经过查找发现tomcat的s ... 
- JDK1.8源码分析02之阅读源码顺序
		序言:阅读JDK源码应该从何开始,有计划,有步骤的深入学习呢? 下面就分享一篇比较好的学习源码顺序的文章,给了我们再阅读源码时,一个指导性的标志,而不会迷失方向. 很多java开发的小伙伴都会阅读jd ... 
- Spring mybatis源码篇章-动态SQL基础语法以及原理
		通过阅读源码对实现机制进行了解有利于陶冶情操,承接前文Spring mybatis源码篇章-Mybatis的XML文件加载 前话 前文通过Spring中配置mapperLocations属性来进行对m ... 
- 学会阅读源码后,我觉得自己better了
		我有一个大学同学,名叫石磊,我在之前的文章里提到过几次,我们俩合作过很多项目.只要有他在,我就特别放心,因为几乎所有难搞的问题,到他这,都能够巧妙地化解.他给我印象最深刻的一句话就是,"有啥 ... 
- 【初学】Spring源码笔记之零:阅读源码
		笔记要求 了解Java语言 了解Spring Framework的基础 会使用Maven 关于本笔记 起因 本职数据分析,为公司内部人员开发数据处理系统,使用了Python/Django+Bootst ... 
- 如何在 GitHub 上高效阅读源码?
		原文链接: 如何在 GitHub 上高效阅读源码? 之前听说过一个故事,一个领导为了提高团队战斗力,把团队成员集中起来,搞封闭开发,重点还是在没有网的条件下. 结果就是一个月过去了,产出基本为零. 我 ... 
随机推荐
- Actor:人生如戏全靠演技--“三维度”逻辑编程语言的设计(3)
			在上一篇介绍了逻辑编程的作用,介绍了逻辑编程中的一些概念,包括逻辑程序的结构:事实.规则和问题:知识的表达方式:谓词演算.产生式规则,以及这些概念与三维度(角色+场景+时间)理论的契合关系,正式提出了 ... 
- 【CF1425A】 Arena of Greed题解
			原题链接 简要翻译: Mr.Chanek与另一个人玩一个取硬币游戏,他先手.玩家在自己的回合内可以取走硬币堆中的一个.如果硬币堆里有偶数个硬币,玩家也可以选择取走硬币总数的一半.两名玩家都是绝对聪明的 ... 
- Centos-重定向方式打包、备份、还原、恢复工具-cpio
			cpio 通过重定向方式将文件进行打包.备份.还原.恢复工具,扩展名为 .cpio 相关选项 -o 将文件复制.打包成文件或将将文件输出到标准输出 -i 将打包文件或者将设备上的备份还原到系统中 - ... 
- Mysql安装(解压版)
			文章首推 刷网课请点击这里 刷二级请点击这里 论文查重请点击这里 WIFI破解详细教程 今日主题:Mysql安装(解压版) 环境 系统:windows10 版本:mysql5.7.29 安装过程 1. ... 
- 手把手撸套框架-Victory框架1.0 详解
			目录 其实Victory框架1.0 在8月份就完成了,整个9月份都没有更新博客,主要还是因为松懈了. 所以,趁着国庆节的放假的时间把博客给更新一下,1.0总的来说算不得一个成熟的产品,但是拿来开发我们 ... 
- 06 C语言变量
			C语言变量 变量的本质 变量的本质其实是程序可操作的存储区的名称. C 中每个变量都有特定的类型,类型决定了变量存储的大小的范围,在范围内的值都可以存储在内存中,运算符可应用于变量上. 变量的名称可以 ... 
- E: 无法获得锁 /var/lib/dpkg/lock - open (11: 资源暂时不可用)
			Linux下sudo apt-get install 安装时报错: 解决办法 1. 终端输入 ps -aux ,列出进程.找到含有apt'-get或者wget的进程, 直接sudo k ... 
- OneWire总线的Arduino库函数
			OneWire总线基本点 One-wire总线是DALLAS公司研制开发的一种协议,采用单根信号线,既传输时钟,又传输数据而且数据传输是双向的.它具有节省I/O 口线资源.结构简单.成本低廉.便于总线 ... 
- 【题解】NOIP2018 填数游戏
			题目戳我 \(\text{Solution:}\) 题目标签是\(dp,\)但是纯暴力打表找规律可以有\(65\)分. 首先是对于\(O(2^{nm}*nm)\)的暴力搜索,显然都会. 考虑几条性质: ... 
- Mybatis老手复习文档
			Mybatis学习笔记 再次学习Mybatis,日后,有时间会把这个文档更新,改的越来越好,然后,改成新手老手通用的文档 1.我的认识 Mybatis 是一个持久层框架,(之前 我虽然学了这个myba ... 
