前言

本文基于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. 2020 Java开发者数据分析:中国已成为 Java 第一大国

    最近知名开发工具供应商Jetbrains在Java 25周年之际,对开发群体做了一次有意思的数据分析. 全文内容可见:https://blog.jetbrains.com/idea/2020/09/a ...

  2. 18-SE-你说的都队

    文章目录 前言 建设银行app分析 招商银行app分析 中国银行app分析 工商银行app分析 总结 团队成员分工与评分 前言 18-SE-你说的都队所选项目题目为"村镇银行储蓄业务系统开发 ...

  3. 联赛模拟测试8 Dash Speed 线段树分治

    题目描述 分析 对于测试点\(1\).\(2\),直接搜索即可 对于测试点\(3 \sim 6\),树退化成一条链,我们可以将其看成序列上的染色问题,用线段树维护颜色相同的最长序列 对于测试点\(7\ ...

  4. 009 01 Android 零基础入门 01 Java基础语法 02 Java常量与变量 03 什么是变量?

    009 01 Android 零基础入门 01 Java基础语法 02 Java常量与变量 03 什么是变量? 什么是变量? 形象化的生活中的例子理解什么是变量以及变量的三个元素 我们可以根据客人要求 ...

  5. JVM系列【3】Class文件加载过程

    JVM系列笔记目录 虚拟机的基础概念 class文件结构 class文件加载过程 jvm内存模型 JVM常用指令 GC与调优 Class文件加载过程 JVM加载Class文件主要分3个过程:Loadi ...

  6. [HAOI 2017]八纵八横

    线段树分治+线形基. 线段树分治是个锤子?? 以时间轴构建线段树,把每个环以"对线段树产生影响的时间区间"的形式加入线段树即可. #include<bits/stdc++.h ...

  7. cocos creator屏幕适配的一些知识点

    一. cocos creator 提供的几种适配策略 EXACT_FIT: 整个应用程序在指定区域可见,无需尝试保留原始纵横比.可能会出现失真,应用程序会被拉伸或压缩.也就是说设计分辨率的长和宽不会等 ...

  8. Angular8 ie兼容性解决

    启用腻子脚本 polyfills.ts /* IE9, IE10 and IE11 requires all of the following polyfills. */ import 'core-j ...

  9. dockerfile镜像设置中文

    一.dockerfile镜像设置中文 centos镜像默认不支持中文,把下面的内容加到dockerfile即可 # 修改时区 RUN rm -rf /etc/localtime && ...

  10. docker容器命令2

    docker容器命令2 启动守护式容器 docker run -d 镜像名字 [root@tzh ~]# docker images REPOSITORY TAG IMAGE ID CREATED S ...