1.前言
上一篇文章我们看了List集合的数组实现JDK1.8的ArrayList 源码解析,走过路过不要错过,本篇文章我们将介绍 List 集合的链表实现 LinkedList。
2.整体架构
和 ArrayList 一样,LinkedList 集合也实现了Cloneable接口,可以支持克隆。
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
各接口,类的关系如下:
2.1 cloneable接口
我们可以看下LinkedList中如何重写clone方法的,具体代码如下,我们可以看到其只是浅拷贝,真正的数据并没有拷贝。
// 返回此其浅表副本(元素本身不会被克隆。)
public Object clone() {
LinkedList<E> clone = superClone();
clone.first = clone.last = null;
clone.size = 0;
clone.modCount = 0;
for (Node<E> x = first; x != null; x = x.next)
clone.add(x.item);
return clone;
}
口说无凭,我们来看下demo, 是不是浅拷贝。
public static void main(String[] args){
LinkedList list=new LinkedList();
list.add("学习");
list.add("Java");
list.add("的");
list.add("小姐姐");
System.out.println(list);
Object otherList=list.clone();
System.out.println(otherList);
}
我们从下面的图可以看出list的各元素地址为547,548,549,552,和otherlist是一样的,但是list的地址为541,otherlist的地址为554,说明他们地址不一样。
这也就验证了上面的说法,linkedlist的clone方法是浅拷贝,返回的只是浅表副本,真正的元素并没有拷贝。
2.2Deque接口
相对于 ArrayList 集合,LinkedList 集合多实现了一个 Deque(双向队列接口) 接口,其两端都可以进行增加和删除操作。
Deque是一个线性collection,支持在两端插入和移除元素。名称 deque 是“double ended queue(双端队列)”的缩写,通常读为“deck”。大多数 Deque 实现对于它们能够包含的元素数没有固定限制,但此接口既支持有容量限制的双端队列,也支持没有固定大小限制的双端队列。
此接口定义在双端队列两端访问元素的方法。提供插入、移除和检查元素的方法。每种方法都存在两种形式:一种形式在操作失败时抛出异常,另一种形式返回一个特殊值(null 或 false,具体取决于操作)。插入操作的后一种形式是专为使用有容量限制的 Deque 实现设计的;在大多数实现中,插入操作不能失败。
下表总结了上述 12 种方法:
第一个元素 (头部) | 最后一个元素 (尾部) | |||
抛出异常 | 特殊值 | 抛出异常 | 特殊值 | |
插入 | addFirst(e) | offerFirst(e) | addLast(e) | offerLast(e) |
删除 | removeFirst() | pollFirst() | removeLast() | pollLast() |
检查 | getFirst() | peekFirst() | getLast() | peekLast() |
如上翻译于jdk1.8的LinkedList注释,如下图:
3.字段属性
主要包括三个核心字段,分别是节点数量size,第一个节点的指针first,最后一个节点的指针last。但是其有个内部类Node,item字段存放具体的数据。
//节点数量
transient int size = 0;
//第一个节点的指针
transient Node<E> first;
//最后一个节点的指针
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 底层数据结构是一个双向链表,整体结构如下图所示:
上图代表了一个双向链表结构,链表中的每个节点都可以向前或者向后追溯,我们有几个概念如下:
• 链表每个节点我们叫做 Node,Node 有 prev 属性,代表前一个节点的位置,next 属性,代表后一个节点的位置;
• first 是双向链表的头节点,它的前一个节点是 null,所以其prev属性为null;
• last 是双向链表的尾节点,它的后一个节点是 null,所以其next属性为null;
• 当链表中没有数据时,first 和 last 是同一个节点,前后指向都是 null;
• 因为是个双向链表,只要机器内存足够强大,是没有大小限制的。
4.源码解析
4.1构造函数
LinkedList 有两个构造函数,一个是默认的空构造函数,另一个是将具体的集合初始化到LinkedList中。
注意:LinkedList 是没有初始化链表大小,其没有确定的大小,只有添加一个元素才会增加大小,是通过修改指针的地址来实现元素的增加。
4.1.1无参构造
public LinkedList() {
}
4.1.2有参构造
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
4.2 新增
4.2.1 从头部追加addFirst
public void addFirst(E e) {
linkFirst(e);
}
private void linkFirst(E e) {
//定义f节点,暂存头节点
final Node<E> f = first;
//创建新的节点元素,为目标值
final Node<E> newNode = new Node<>(null, e, f);
//头节点赋值为新的节点元素
first = newNode;
//如果f节点为null,说明原来为空链表,即尾指针也指向新的节点元素
if (f == null)
last = newNode;
//如果不是null,说明原来不为空链表,即f节点的头指针为新的
else
f.prev = newNode;
//调整大小和版本
size++;
modCount++;
}
4.2.2 从尾部追加addLast
public void addLast(E e) {
linkLast(e);
}
void linkLast(E e) {
//定义l节点,暂存尾节点
final Node<E> l = last;
//创建新的节点元素,为目标值
final Node<E> newNode = new Node<>(l, e, null);
//尾节点赋值为新的节点元素
last = newNode;
//如果l节点为null,说明原来为空链表,即头指针也指向新的节点元素
if (l == null)
first = newNode;
//如果不是null,说明原来不为空链表,即l节点的尾指针为新的
else
l.next = newNode;
//调整大小和版本
size++;
modCount++;
}
4.2.3 在指定位置添加add
public void add(int index, E element) {
//检查下标
checkPositionIndex(index);
//下标等于链表长度,即直接添加到尾部
if (index == size)
linkLast(element);
else
//否则添加到指定下标的前面
linkBefore(element, node(index));
}
void linkBefore(E e, Node<E> succ) {
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
//调整大小和版本
size++;
modCount++;
}
4.4 删除
节点删除的方式和追加类似,我们可以选择从头部删除,也可以选择从尾部删除,删除操作会把节点的值,前后指向节点都置为 null,帮助 GC 进行回收。
4.4.1从头部删除
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return 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;
}
4.4.2从尾部删除
public E removeLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return unlinkLast(l);
}
private E unlinkLast(Node<E> l) {
// assert l == last && l != null;
final E element = l.item;
final Node<E> prev = l.prev;
l.item = null;
l.prev = null; // help GC
last = prev;
if (prev == null)
first = null;
else
prev.next = null;
size--;
modCount++;
return element;
}
4.5查询
4.5.1根据下标查询
从源码中我们可以发现,LinkedList 并没有采用从头循环到尾的做法,而是采取了简单二分法,首先看看 index 是在链表的前半部分,还是后半部分。如果是前半部分,就从头开始寻找,如果是后半部分,就从尾开始寻找。通过这种方式,使循环的次数至少降低了一半,提高了查找的性能。
public E get(int index) {
//判断下标位置
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int 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;
}
}
4.5.2查询第一个元素
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
4.5.3查询最后一个元素
public E getLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return l.item;
}
4.6 迭代器
4.6.1 ListIterator
因为 LinkedList 要实现双向的迭代访问,所以我们使用 Iterator 接口肯定不行了,因为 Iterator 只支持从头到尾的访问。
Java 新增了一个迭代接口,叫做:ListIterator,如下图所示:
// 双向迭代器
private class ListItr implements ListIterator<E> {
//上一次执行 next() 或者 previos() 方法时的节点位置
private Node<E> lastReturned;
//下一个节点
private Node<E> next;
//下一个节点的位置
private int nextIndex;
//expectedModCount:期望版本号;modCount:目前最新版本号
private int expectedModCount = modCount;
}
我们先来看下从头到尾方向的迭代:
// 判断还有没有下一个元素
public boolean hasNext() {
// 下一个节点的索引小于链表的大小,就有
return nextIndex < size;
}
// 取下一个元素
public E next() {
//检查期望版本号有无发生变化
checkForComodification();
if (!hasNext())//再次检查
throw new NoSuchElementException();
// next 是当前节点,在上一次执行 next() 方法时被赋值的。
// 第一次执行时,是在初始化迭代器的时候,next 被赋值的
lastReturned = next;
// next 是下一个节点了,为下次迭代做准备
next = next.next;
nextIndex++;
return lastReturned.item;
}
4.6.2迭代器和for循环效率差异
LinkedList<Integer> linkedList = new LinkedList<>();
for(int i = 0 ; i < 100000; i++){
linkedList.add(i);
}
long beginTimeFor = System.currentTimeMillis();
for(int i = 0 ; i < 100000 ; i++){
System.out.print(linkedList.get(i));
}
long endTimeFor = System.currentTimeMillis();
System.out.println("使用普通for循环遍历100000个元素需要的时间:"+ (endTimeFor - beginTimeFor));
long beginTimeIte = System.currentTimeMillis();
Iterator<Integer> it = linkedList.listIterator();
while(it.hasNext()){
System.out.print(it.next()+" ");
}
long endTimeIte = System.currentTimeMillis();
System.out.println("使用迭代器遍历100000个元素需要的时间:"+ (endTimeIte - beginTimeIte));
我们可以看到10万的数据,遍历的时间已经相差很大了,如果数据量更大,时间可能相差更多的,那是为什么造成这种情况呢?
1.采用for循环走的是LinkedList的get方法,这实际是一个简单的二分查询,每查询一个元素,都要走二分查询,那随着查询的数据越来越多,很大的一部分时间都浪费在查询上面,每个都重复查询之前的步骤,所以需要的时间多。
2.采用迭代器,则不是重复查询,因为其记录了上次查询的位置,所以需要的时间少。
总结
本文基于java8从源码分析LinkedList时如何构建的,底层的数据结构包括哪些,同时对多个方法逐步分析,包括添加add,删除remove,查询get等操作,还分析了迭代器与传统for循环相比,有哪些优势,为什么会产生优势等,若有不对之处,请批评指正,望共同进步,谢谢!
拜拜咯。