我相信学习过ArrayList的源码的小伙伴对于集合的概念应该不陌生吧。以前是正着来,这次我们反着来。先手写一一个我们理解的LinkedList,再回去观察源码和我们代码之间的区别。如果对于ArrayList集合比较陌生的,可以先去学习一下它的底层源码,我认为对你绝对有帮助
传递门
ArrayList的源码解析
文章目录
1、ArrayList引发的思考
优点:
- 查询比较块:直接采用数组下标的方式,时间复杂度为1
缺点:
- 增删慢:总体上来说,需要每次增删数据时,需要移动修改位置后面所有的数据
- 某种程度上浪费内存空间:数据足够大时每次的扩容容易出现浪费空间
为了弥补这个集合的缺点,出现了我们今天的主角LinkedList
2、手写LinkedList简易版(单链表)
链表分为:单链表、双链表、循环链表
LinkedList是双向链表,我们只需要对单链表进行修改就能够得到双链表,所以我们先来手写一个单链表结构(像极了大学课程里面的数据结构)
观察LinkedList类的类图
Serializable和Cloneable为常规的标记型接口,Deque为一个与队列相关的接口,所以就只剩下了AbstractSequentiaList这个类。我们将剩下的一条主线中的类统称为AbstractList(毕竟这些类都是抽象类,都是对上层的一个扩充),接口统称为List(实现了上层的接口,就是对上层接口的一个扩充)。
2.1、顶层的接口类
顶层的接口,一个声明大众方法的接口
package pers.mobian.test01;
public interface List01<E> {
//ArrayList和LinkedList都继承了这个接口,且实现方式上相同
//所以我们可以直接在抽象类中实现,增删改查再在具体的集合类中实现
int size();
boolean isEmpty();
boolean contains(E element);
//由于两种集合的数据结构与不同,所以实现的方式也就不同了
void add(E element);
void add(int index, E element);
E remove(int index);
E set(int index, E element);
E get(int index);
//获取对象的索引
int indexOf(E element);
//清空集合
void clear();
//打印集合
String toString();
}
2.2、抽象类
为了减少顶层接口方法的实现代码,使用一个抽象类来提前编写重复方法的方法体(ArrayList类也同样继承这个抽象类,两个集合就都不再需要重写这三个方法)
package pers.mobian.test01;
public abstract class AbstractList01<E> implements List01<E> {
//集合的长度
//由于LinkedList与ArrayList结构的不同,所以这里的size既是集合长度又是集合容量
protected int size;
//返回集合的长度
@Override
public int size() {
return size;
}
//判空方法
@Override
public boolean isEmpty() {
return size == 0;
}
//判断集合中是否包含指定的元素
@Override
public boolean contains(E element) {
//如果找到对象就返回具体数字,数字不等于-1就返回true,即查找成功
return indexOf(element) != -1;
}
}
2.3、LinkedList实现类
基本结构:
- 每一个节点包含两个值:一个是自身元素一个是下一个节点的地址值
- 链头节点也包含两个值:一个是链表的长度,一个是first节点(用于指向第一个节点的地址值)
- 最后一个节点只有一个值:由于是最后一个节点, 所以next为null
1、是链表结构,所以我们需要书写一个节点类,用来存放数据和下一个节点的地址值
//添加一个我们的链头的first节点(它代表集合0号位置的节点地址值)
private Node first;
private static class Node<E> {
//下一个节点的地址值
Node<E> next;
//本身节点的数据
E element;
//节点的全参构造
public Node(Node<E> next, E element) {
this.next = next;
this.element = element;
}
}
2、在业务代码部分,需要不止一次的根据对应index值获取相应节点的地址值,可以抽取一个公共方法
private Node<E> node(int index) {
//将头节点指向的地址值赋值给x
Node x = first;
//一直遍历到x我们指定的index处
for (int i = 0; i < index; i++) {
x = x.next;
}
//返回对应index的节点地址值
return x;
}
3、get() (增删改都需要借助这个方法,所以可以先写)
@Override
//重写接口中的get方法
public E get(int index) {
//1.对传入的index进行校验
checkElementIndex(index);
//2.返回对应的element数据
return node(index).element;
}
private void checkElementIndex(int index) {
//如果数据不符合规范,抛出异常
if (!isElementIndex(index)) {
//为了与真实的LinkedList抛出异常相匹配,此处采用这种异常
throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
}
}
private boolean isElementIndex(int index) {
//集合长度 = index最大下标 + 1
//index值应该为非负数,且要小于集合长度
return index >= 0 && index < size;
}
4、set() 传入指定的index值以及新的数据内容,并且返回被修改的数据
@Override
public E set(int index, E element) {
//1.对传入的index进行校验
checkElementIndex(index);
//2.获取指定index的对象,并通过对象获取到指定index的原数据内容
Node<E> node = node(index);
E oldElement = node.element;
//3.将传入的新element赋值到对应节点的内容
node.element = element;
//4.返回原来的值
return oldElement;
}
private void checkElementIndex(int index) {
//如果数据不符合规范,抛出异常
if (!isElementIndex(index)) {
//为了与真实的LinkedList抛出异常相匹配,此处采用这种异常
throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
}
}
private boolean isElementIndex(int index) {
//集合长度 = index最大下标 + 1
//index值应该为非负数,且要小于集合长度
return index >= 0 && index < size;
}
5、add(int index, E element) 添加的节点 (重点)
附有插入四种可能的图示
@Override
public void add(int index, E element) {
//1.对传入数据的校验(此处的校验不同于之前的get和set)
checkPositionIndex(index);
if (index == 0) {
//2.创建一个新的节点
//当链表的first = null时,添加的数据为头节点,所以next也为null
//如果链表的size != 0时,新添加的节点的next为最开始first指向的地址值
Node node = new Node(first, element);
//3.将新创建的链表地址值赋值给first,使得能够连接
first = node;
size++;
} else {
//2.拿到想要添加位置的前一个节点
Node<E> pre = node(index - 1);
//3.利用前一个节点拿到想要添加位置的下一个节点
Node<E> next = pre.next;
//4.创建一个新的节点,然后进行添加
pre.next = new Node<E>(next, element);
size++;
}
}
private void checkPositionIndex(int index) {
if (!isPositionIndex(index)) {
throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
}
}
//由于增加的节点,可以在第一个位置(first = null),也可以在最后一个位置(index = size)
//传入的index值,应该大于等于0,且小于等于size
private boolean isPositionIndex(int index) {
return index >= 0 && index <= size;
}
(1)当没有节点是插入
(2)有节点时插入在0位置
(3)有节点时,插入在节点中间
(4)有节点时插入在节点的末尾
无参的add方法
@Override
public void add(E element) {
//直接调用add的含有参数的方法
add(size, element);
}
6、编写remove方法,返回被删除的值
@Override
public E remove(int index) {
//1.将链头的first赋值给需要被删除的节点
Node<E> oldNode = first;
//2.检查传入的index是否符合要求
checkElementIndex(index);
if (index == 0) {
//3.如果是想删除0号位,那就将我们要被删除的节点的下一个节点指向链头
first = first.next;
} else {
//3.如果要删除的节点不是0号位,那就获取要被删除节点的上一个节点
Node<E> pre = node(index - 1);
//4.重新赋值我们的要被删除的节点
oldNode = pre.next;
//5.将要被删除的节点的next指向上一个节点的next
pre.next = oldNode.next;
}
//6.集合大小-1,并返回被删除的节点
size--;
return oldNode.element;
}
private void checkElementIndex(int index) {
//如果数据不符合规范,抛出异常
if (!isElementIndex(index)) {
throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
}
}
private boolean isElementIndex(int index) {
//index值应该为非负数,且要小于集合长度
return index >= 0 && index < size;
}
7、根据element返回对应的index值
@Override
public int indexOf(E element) {
//1.拿到0号位置的节点
Node x = first;
//2.将index置于0号位置
int index = 0;
//为了防止空指针异常,可以添加一个判断
if (element == null) {
//3.遍历整个链表,拿到对应数据的index值,并返回
for (Node i = x; i != null; i = i.next) {
if (element == i.element) {
return index;
}
index++;
}
} else {
//3.遍历整个链表,拿到对应数据的index值,并返回
for (Node i = x; i != null; i = i.next) {
if (element.equals(i.element)) {
return index;
}
index++;
}
}
//如果没有找到,那就返回-1
return -1;
}
8、清空集合数据
@Override
public void clear() {
//将集合的长度变为0,链头的first指向null,等待垃圾回收
size = 0;
first = null;
}
9、编写toString方法
@Override
public String toString() {
//1.如果集合长度为0,直接返回
if (size == 0) {
return "[]";
}
//2.为了避免空间的浪费,采用StringBuilder拼接集合数据
StringBuilder sb = new StringBuilder().append("[");
//3.获取位置为0的集合元素
Node<E> x = first;
//4.遍历整个链表,完成拼接
for (int i = 0; i < size; i++) {
//根据地址获取对应的元素
E element = x.element;
//追加元素
sb.append(element);
//如果下一个元素为null,就追加 ] 然后退出
if (x.next == null) {
return sb.append("]").toString();
}
//如果不是null,就追加", "
if (x.next != null) {
sb.append(", ");
}
x = x.next;
}
//5.返回拼接好的字符串
return sb.toString();
}
到这里,我们已经基本上完成了单项链表基本功能的创建,和大学的数据结构很像,然后就是将我们的单向链表改写成双向链表!!!
3、将单链表改写成双向链表
3.1、概述
双向链表的结构
变化:
- 每一个节点含有三个成员变量:element、下一个节点next、上一个节点pre
- 0号位置的节点pre为null,链表末尾节点的next为null
- 链头新增一个last节点,用于存放链表末尾节点的地址值
- 每一个节点的pre指向前一个节点的地址值
对于传统的单链表形式,访问数据只能从前到后,如果数据在最后一个位置,查找是十分消耗性能的。如果使用了双向链表,在查询之前,先去判断查询的位置靠近头还是靠近尾,然后再进行相关操作,能够减少系统的开销,继而提高查询效率。但是节点新增了pre成员变量,会占用更多的系统资源。所以具体使用哪一种链表,应该根据应用场景决定。
3.2、修改方法的实现类
1、修改每次新建的节点信息
private Node first;
//新增的指向尾部的节点
private Node last;
private static class Node<E> {
//新增的指向前一个节点的成员变量
Node<E> pre;
Node<E> next;
E element;
public Node(Node<E> pre, E element, Node<E> next) {
this.next = next;
this.element = element;
this.pre = pre;
}
}
2、get方法
由于我们的get方法传入的参数是对应的index,所以不需要改变
3、set方法
由于set方法只是找到对应的index,然后在修改element,所以也不需要改变
4、add方法 (重点)
涉及到节点信息的添加,由于节点变化了,所以方法也需要进行相应的修改
附有插入四种可能的图示
@Override
public void add(int index, E element) {
//1.判断index是否符合规定
checkPositionIndex(index);
//2.由于去维护last变量的方法,所以这里进行了一个分类
if (index == size) {
//3.处理添加到末尾或者是没有元素的(链表长度为0或者是添加到链表的末尾)
linkedLast(element);
} else {
//3.普通的添加
linkedBefore(element, node(index));
}
size++;
}
private void linkedLast(E element) {
//1.拿到对应的last节点
Node l = last;
//2.构建node 完成他的指向关系,将原来的last节点中的next修改为新构建的node
Node<E> newNode = new Node<>(l, element, null);
//3.将链表头部的last进行修改
last = newNode;
//判断之前的集合size是否为null
//为null则说明size == 0
//不为null则说明 size != 0
if (l == null) {
//将新的节点指向链头中的first
first = newNode;
} else {
//将新的节点追加在之前节点的后面
l.next = newNode;
}
}
private void linkedBefore(E element, Node lastNode) {
//获取想要添加位置的元素,即成功添加后的后一个位置的节点
//获取成功添加后元素的前一个节点
//1.获取想要添加的index的对象的前节点
Node<E> preNode = lastNode.pre;
//2.创建一个新的节点(pre为前节点,element为数据元素,next为添加数据之后的下一个节点)
Node<E> newNode = new Node<>(preNode, element, lastNode);
//3.将新节点与它的下一个节点的pre进行连接
lastNode.pre = newNode;
//4.判断前节点是否为null,以此来决定新的节点是赋值给first还是上一个节点的next
if (preNode == null) {
first = newNode;
} else {
preNode.next = newNode;
}
}
//数据校验
private void checkPositionIndex(int index) {
if (!isPositionIndex(index)) {
throw new IndexOutOfBoundsException(": Index: " + index + ", Size: " + size);
}
}
private boolean isPositionIndex(int index) {
return index >= 0 && index <= size;
}
无参的add方法不变
@Override
public void add(E element) {
add(size, element);
}
(1)没有节点时添加节点
(2)往节点中间添加节点
(3)节点末尾添加节点
(4)有节点时,在0位置添加节点
5、remove方法
@Override
public E remove(int index) {
checkElementIndex(index);
//1.获得要删除的元素node
Node<E> node = node(index);
//2.获得node节点的前一个节点
Node<E> preNode = node.pre;
//3.获得node节点的后一个节点
Node<E> nextNode = node.next;
if (preNode == null) {
//删除的位置是第一个
first = nextNode;
//如果集合中只有一个元素,防止出现空指针异常
if (nextNode == null && preNode == null) {
first = null;
last = null;
} else {
nextNode.pre = null;
}
} else {
//4.需要改变前一个节点的next
preNode.next = nextNode;
}
if (nextNode == null) {
//删除的位置是最后一个
last = preNode;
//可以不用处理。如果集合中只有一个元素,防止出现空指针异常
if (nextNode == null && preNode == null) {
first = null;
last = null;
} else {
preNode.next = null;
}
} else {
//5.需要改变后一个节点的pre
nextNode.pre = preNode;
}
//6.返回被删除的节点
return node.element;
}
6、clear方法
@Override
public void clear() {
//由于多出了一个last节点,所以也需要赋值为null
size = 0;
first = null;
last = null;
}
7、toString方法
由于该方法为一个一个遍历数据,不涉及pre所以不需要修改
4、对比源码
这里我就不去对照了,不过我相信你能够完成到这一步,一定阅读源码也没有太大的问题。
源码中还涉及了其他的变量,但是核心的功能,我们手写的双向链表已经能够实现了。
5、并发修改异常
在使用迭代器遍历数据的时候,不能通过链表对数据进行修改。
在迭代器内部,有这样一句expectedModCount = modCount代码,如果链表的修改次数 != 期望的修改次数,就会产生并发修改异常。如果想要在使用迭代器的时候还能对数据进行增删操作,那么就需要使用迭代器对应的增删方法
private class ListItr extends Itr implements ListIterator<E> {
ListItr(int index) {
cursor = index;
}
......
public void set(E e) {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
AbstractList.this.set(lastRet, e);
//如果期望的修改次数和链表的长度不匹配,就会抛出ConcurrentModificationException()
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
public void add(E e) {
checkForComodification();
try {
int i = cursor;
AbstractList.this.add(i, e);
lastRet = -1;
cursor = i + 1;
//如果期望的修改次数和链表的长度不匹配,就会抛出ConcurrentModificationException()
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
}
6、LikedList如何保证多线程的安全
1、使用Collections.synchronizedCollection()锁住集合
package pers.mobian.test01;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
public class LinkedListDemo2 {
public static void main(String[] args) {
LinkedList<String> linkedList = new LinkedList<>();
//使用Collections.synchronizedCollection()用来锁住linkedlist集合
Collection strings = Collections.synchronizedCollection(linkedList);
//开启一个线程
new Thread(new Runnable() {
@Override
public void run() {
strings.add("test");
System.out.println(linkedList);
}
}).start();
}
}
2、使用ConcurrentLinkedQueue类创建一个线程安全的集合
package pers.mobian.test01;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
public class LinkedListDemo2 {
public static void main(String[] args) {
//使用ConcurrentLinkedQueue()创建一个集合
ConcurrentLinkedQueue concurrentLinkedQueue = new ConcurrentLinkedQueue();
new Thread(new Runnable() {
@Override
public void run() {
//使用创建的集合添加数据,保证线程安全
concurrentLinkedQueue.add("ss");
System.out.println(concurrentLinkedQueue);
}
}).start();
}
}
学习ArrayList的时候,我是从上到下,一步一步的分析源码。而学习LinkedList的时候,我们是从下往上的学习,个人觉得收获颇丰!!!明白了程序的执行逻辑,手写一下整个集合,对于集合一定有更加深刻的认识,希望你也一样!!!个人觉得,一定要理解增删改查的编写方式,这应该是比较基础的数据结构吧。