前言

本文基于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回顾基础的更多相关文章

  1. 阅读源码,HashMap回顾

    目录 回顾 HashMap简介 类签名 常量 变量 构造方法 tableSizeFor方法 添加元素 putVal方法 获取元素 getNode方法 总结 本文一是总结前面两种集合,补充一些遗漏,再者 ...

  2. 阅读源码(III)

    往期系列: <由阅读源码想到> <由阅读源码想到 | 下篇> Medium上有一篇文章Why You Don't Deserve That Dream Developer Jo ...

  3. 阅读源码,从ArrayList开始

    前言 为啥要阅读源码?一句话,为了写出更好的程序. 一方面,只有了解了代码的执行过程,我们才能更好的使用别人提供的工具和框架,写出高效的程序.另一方面,一些经典的代码背后蕴藏的思想和技巧很值得学习,通 ...

  4. 阅读源码(IV)

    往期系列: <由阅读源码想到> <由阅读源码想到 | 下篇> <阅读源码(III)> Eric S.Raymond的写于2014年的<How to learn ...

  5. How Tomcat works — 一、怎样阅读源码

    在编程的道路上,通过阅读优秀的代码来提升自己是很好的办法.一直想阅读一些开源项目,可是没有合适的机会开始.最近做项目的时候用到了shiro,需要做集群的session共享,经过查找发现tomcat的s ...

  6. JDK1.8源码分析02之阅读源码顺序

    序言:阅读JDK源码应该从何开始,有计划,有步骤的深入学习呢? 下面就分享一篇比较好的学习源码顺序的文章,给了我们再阅读源码时,一个指导性的标志,而不会迷失方向. 很多java开发的小伙伴都会阅读jd ...

  7. Spring mybatis源码篇章-动态SQL基础语法以及原理

    通过阅读源码对实现机制进行了解有利于陶冶情操,承接前文Spring mybatis源码篇章-Mybatis的XML文件加载 前话 前文通过Spring中配置mapperLocations属性来进行对m ...

  8. 学会阅读源码后,我觉得自己better了

    我有一个大学同学,名叫石磊,我在之前的文章里提到过几次,我们俩合作过很多项目.只要有他在,我就特别放心,因为几乎所有难搞的问题,到他这,都能够巧妙地化解.他给我印象最深刻的一句话就是,"有啥 ...

  9. 【初学】Spring源码笔记之零:阅读源码

    笔记要求 了解Java语言 了解Spring Framework的基础 会使用Maven 关于本笔记 起因 本职数据分析,为公司内部人员开发数据处理系统,使用了Python/Django+Bootst ...

  10. 如何在 GitHub 上高效阅读源码?

    原文链接: 如何在 GitHub 上高效阅读源码? 之前听说过一个故事,一个领导为了提高团队战斗力,把团队成员集中起来,搞封闭开发,重点还是在没有网的条件下. 结果就是一个月过去了,产出基本为零. 我 ...

随机推荐

  1. Python练习题 013:求解a+aa+aaa……

    [Python练习题 013] 求s=a+aa+aaa+aaaa+aa...a的值,其中a是一个数字.例如2+22+222+2222+22222(此时共有5个数相加),几个数相加由键盘输入. 这题倒也 ...

  2. 手写“SpringBoot”近况:IoC模块已经完成

    jsoncat:https://github.com/Snailclimb/jsoncat (About 仿 Spring Boot 但不同于 Spring Boot 的一个轻量级的 HTTP 框架) ...

  3. CString类常用方法----Left(),Mid(),Right()

    参考:https://blog.csdn.net/Qingqinglanghua/article/details/4992624 CString Left( int nCount ) const;   ...

  4. 阿里云服务器安装mongodb并且启动

    // 1.下载 我是直接在local里面创一个mongodb文件夹进行下载和解压 curl -O https://fastdl.mongodb.org/linux/mongodb-linux-x86_ ...

  5. JDK 中的栈竟然是这样实现的?

    前面的文章<动图演示:手撸堆栈的两种实现方法!>我们用数组和链表来实现了自定义的栈结构,那在 JDK 中官方是如何实现栈的呢?接下来我们一起来看. 这正式开始之前,先给大家再解释一下「堆栈 ...

  6. vi/vim系统编辑命令使用技巧

    01前言 在Linux系统中会有很多的文件信息,这些文件的内容如果需要编辑,就必须借助vi或vim编辑命令. vi是Linux命令行界面下的重要文字编辑器.vim是vi命令的增强版. [语法格式] v ...

  7. RESP协议

    RESP 是 Redis 序列化协议的简写.它是⼀种直观的⽂本协议,优势在于实现异常简单,解析性能极好. Redis 协议将传输的结构数据分为 5 种最⼩单元类型,单元结束时统⼀加上回⻋换⾏符号\r\ ...

  8. jquery1.9+,jquery1.10+ 为什么不支持live方法了?

    live() 替换成 on() die()  替换成off() 根据jQuery的官方描述,live方法在1.7中已经不建议使用,在1.9中删除了这个方法.并建议在以后的代码中使用on方法来替代. o ...

  9. Docker结合.Net Core初步使用

    Docker是一项比较流行的容器化技术,可以让开发者将应用以及应用依赖的环境,依赖包一起打包到容器中,然后部署容器到生产环境就可以了,解决了应用程序部署到不同服务器环境带来的问题(很多开发人员都遇到过 ...

  10. 建议你吃透python这68个内置函数!

    内置函数就是Python给你提供的, 拿来直接用的函数,比如print,input等. 截止到python版本3.6.2 ,一共提供了68个内置函数,具体如下 abs() dict() help() ...