LinkedList 源码深度解析

LinkedList 源码深度解析

一、重新认识LinkedList

  1. 什么是LinkedList?
    LinkedList是基于双向链表实现的List类,与ArrayList基于数组的实现方式不同。链表数据结构的特点是每个元素分配的空间不必连续、插入和删除元素时速度非常快、但访问元素的速度较慢,适用于集合元素先入先出和先入后出的场景。

  2. 长啥样?
    LinkedList
    如图是一个4节点的双向链表结构的LinkedList,双向链表里的每个节点称为Node,Node在java里的实现如下:

    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;
        }
    }
    
  3. 双向链表有哪些基本概念?

    • 双向链表里,每个节点有三个字段,item为本节点的值,prev指向前一个节点,next指向后一个节点;
    • 双向链表的最前面的一个节点也称为头节点,它的prev指向null;
    • 双向链表的最最后的一个节点也称为尾节点,它的next指向null;
    • 双向链表的大小和机器的内存有关,理论上讲是没有限制的;
    • 当双向链表没有数据时,头节点和尾节点是同一个节点,且prev和next都指向null;

二、知其所以然 ---- 撸源码

1、新增

LinkedList的新增操作有两种常用方法:add(e)和offer(e),它们的源码分别是:

    public boolean offer(E e) {
        return add(e);
    }
    public boolean add(E e) {
        linkLast(e);
        return true;
    }

可以看到两个方法的底层实现完全一致,都是调用linkLast(e),不过这里有一个坑要注意一点,那就是add方法无论成功失败都会返回true,虽然Queue 接口注释上面写的建议 add 方法操作失败时抛出异常,但显然是不可能抛出的;

新增数据的过程本质上就是对双向链表追加节点的过程,由于是双向链表,我们可以选择追加到链表的头部或者尾部,在Java里,add方法默认是从尾部开始追加,addFirst方法从头部开始追加。

下面开始逐行分析源码:

   // 从尾部开始追加节点
    void linkLast(E e) {
        // 把尾节点数据暂存
        final Node<E> l = last;
        //新建新的节点,l 是前一个节点,e 是当前节点的值,后一个节点是 null
        final Node<E> newNode = new Node<>(l, e, null);
        //新建的节点放在尾部
        last = newNode;
        //如果链表为空,头部和尾部是同一个节点,都是新建的节点
        if (l == null)
            first = newNode;
        //否则把前尾节点的下一个节点,指向当前尾节点。
        else
            l.next = newNode;
        //大小和版本更改
        size++;
        modCount++;
    }

   // 从头部追加
    private void linkFirst(E e) {
        //头节点赋值给临时变量
        final Node<E> f = first;
        //新建节点,前一个节点指向null,e是新建节点的值,f 是新建节点的下一个节点
        final Node<E> newNode = new Node<>(null, e, f);
        //新建节点成为头节点
        first = newNode;
        //头节点为空,就是链表为空,头尾节点是一个节点。
        if (f == null)
            last = newNode;
        //上一个头节点的前一个节点就是当前节点
        else
            f.prev = newNode;
        size++;
        modCount++;
    }

新增数据小结:

通过分析源码,我们发现新增数据的过程本质上就是追加双向链表节点的过程,不管是追加头部还是尾部,代码逻辑上都是类似的,分为3步:

  • 展存和追加节点相关的那个节点;
  • 新增一个节点;
  • 判空并修改节点prev和next指向,完成操作。

2、删除

LinkList删除数据有两种常用方法remove()(同removeFirst(),removeLast()类似)和poll(e),它们的源码分别是:

// remove()   
public E remove() {
  return removeFirst();
}
public E removeFirst() {
  final Node<E> f = first;
  if (f == null)
    throw new NoSuchElementException();
  return unlinkFirst(f);
}

// poll(e)
public E poll() {
  final Node<E> f = first;
  return (f == null) ? null : unlinkFirst(f);
}

这里我们会发现当链表为时,remove会抛出异常,而poll会返回null,除此之外,底层实现逻辑是一致的;

扫描二维码关注公众号,回复: 9492879 查看本文章

对双向链表的删除操作,也分为头部删除和尾部删除两种,源码实现类似,我们以头部删除为例,逐行分析源码:

    //从头删除节点 
		//f 是链表头节点
    private E unlinkFirst(Node<E> f) {
        // 拿出头节点的值,作为方法的返回值
        final E element = f.item;
        // 拿出头节点的下一个节点
        final Node<E> next = f.next;
        //将节点的值,前后指向节点都指向null,目的是为了帮助 GC 回收头节点
        f.item = null;
        f.next = null;
        // 头节点的下一个节点成为头节点
        first = next;
        //如果 next 为空,表明链表为空
        if (next == null)
            last = null;
        //链表不为空,头节点的前一个节点指向 null
        else
            next.prev = null;
        //修改链表大小和版本
        size--;
        modCount++;
        return element;
    }

删除数据小结:

删除操作比新增操作还要简单,只是把前后节点的指向修改一下,然后把要删除的节点的值返回并把值和指向都置为空,经过查阅资料,知道了这样做的目的是帮助GC回收。

3、查询

LinkedList查询数据的两种常用方法是element()和peek(),首先我们看一下这两种方法的源码实现:

// element()
public E element() {
  return getFirst();
}
public E getFirst() {
  final Node<E> f = first;
  if (f == null)
    throw new NoSuchElementException();
  return f.item;
}

// peek()
public E peek() {
  final Node<E> f = first;
  return (f == null) ? null : f.item;
}

看到这里,我们发现这两种查询和删除类似,最底层逻辑一致,只是当链表为空时,element会抛出异常,peek会返回null;

下面逐行分析查询源码:

   // 根据链表的索引位置查询节点
    Node<E> node(int index) {
      	// >> 是右移运算符,作用相当于除以2
        // index 处于队列的前半部分,从头开始找
        if (index < (size >> 1)) {
            Node<E> x = first;
            // 直到 for 循环到 index 的前一个 node
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {// index 处于队列的后半部分,从尾开始找
            Node<E> x = last;
            // 直到 for 循环到 index 的后一个 node
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

查找数据小结:

这里有一个非常值得学习的地方,LinkedList查找的算法并没有采用从头到尾的做法,而是采用了简单二分法。首先根据index位于链表的哪一部分,然后决定是从头节点开始查找还是从尾节点开始查找,采用这种算法,可以使循环次数至少降低一半,提高性能。同时,考虑到由于链表只能从头节点或者尾节点开始遍历,因此这种二分法已经使最优解

4、迭代器

LinkedList底层实现是双向链表,因此也要实现双向的迭代访问,传统的Iterator接口肯定不行,因为Iterator只能支持从头到尾的访问。为了解决这个问题,Java新增了一个迭代接口----ListIterator,这个接口提供了向前和向后的迭代方法:

迭代顺序 方法
从头到尾 hasNext、next、nextIndex
从尾到头 hasPrevious、previous、previousIndex

因此,Linkedlist实现了ListIterato接口,部分源码如下:

// 双向迭代器
private class ListItr implements ListIterator<E> {
  private Node<E> lastReturned;//上一次 next 或者 previos 的节点
        private Node<E> next;//下一个节点
        //下一个节点的位置,从头迭代到尾,位置递增,从尾迭代到头,位置递减。
        private int nextIndex;
        //expectedModCount:期望版本号;modCount:目前最新版本号
        private int expectedModCount = modCount;

        ListItr(int index) {
            // assert isPositionIndex(index);
            next = (index == size) ? null : node(index);
            nextIndex = index;
        }
//剩下实现部分先省略
}

下面我们开始分析上面省略的实现部分,首先是从头到尾的迭代源码如下:

// 判断还有没有下一个元素
public boolean hasNext() {
  //下一个节点的索引小于链表的大小,就有下一个节点,返回true
  return nextIndex < size;
}

// 取下一个元素
public E next() {
  //检查期望版本号有无发生变化
  checkForComodification();
  if (!hasNext())//再次检查
    throw new NoSuchElementException();
  // next 是当前节点
  lastReturned = next;
  // next 是下一个节点了,为下次迭代做准备
  next = next.next;
  nextIndex++;
  return lastReturned.item;
}

可以发现从头到尾的迭代的方式就是获取当前节点的下一个节点,下面我们在逐行分析一下从尾到头的迭代:

 // 如果上次节点索引位置大于 0,就说明还有节点没有遍历完,返回true
public boolean hasPrevious() {
  return nextIndex > 0;
}
// 取前一个节点
public E previous() {
  checkForComodification();
  if (!hasPrevious())
    throw new NoSuchElementException();
  // next 为空场景:说明是第一次迭代,取尾节点(last)
  // next 不为空场景:说明已经发生过迭代了,直接取前一个节点即可(next.prev)
  lastReturned = next = (next == null) ? last : next.prev;
  // 索引位置变化
  nextIndex--;
  return lastReturned.item;
}

这里需要注意的就是需要对next是否为空进行判断。由于是从尾到头,而next是当前节点的下一个节点,如果next尾空,就说明是第一次迭代,当前的节点就是原链表的尾节点。

最后还需要分析一个迭代器的删除源码,我们在使用linkedList删除元素时,一般推荐通过迭代器删除:

public void remove() {
  checkForComodification();
  // 通过前面的迭代器向前或向后迭代的源码,我们可以知道lastReturned初始尾null,因此这里时为了防止开发者在没有执行next()或者previous的情况下调用删除操作,如果是这样的话,会抛出异常
  if (lastReturned == null)
    throw new IllegalStateException();
  Node<E> lastNext = lastReturned.next;
  //删除当前节点
  unlink(lastReturned);
  // 想要满足next == lastReturned,只有一种情况,就是目前是以从尾到头的方式进行迭代的,这时候由于lastReturned节点删除了,会被GC回收,就需要把next指向lastReturned.next位置,这样进行下一次previous时,才可以找到lastReturned.prev
  if (next == lastReturned)
    next = lastNext;
  else
    nextIndex--;
  lastReturned = null;
  expectedModCount++;
}

三、LinkedList总结

  • 底层是基于双向链表实现的,因此增加,删除效率较高,查询效率较慢;
  • 查询的源码采用二分查找,在只能从头或尾进行查询的情况下是最优解值,这在我们平时写代码遇到这种情况时可以学习;
  • 迭代器基于Java的 ListIterator来完成双向迭代;
  • 存储同样数据,占用内存要高于ArrayList,但理论上没有长度限制,而ArrayList的容量最多为Interger的最大值;
  • 删除数据时推荐使用迭代器删除,因为迭代器的remove加了很多前置限制,同时删除数据时会导致LinkedList的状态发生变化,它会更新cursor来同步这一变化;
  • 非线程安全。

阅读扩展

对ArrayList源码感兴趣的还可以查看ArrayList源码深度解析

发布了97 篇原创文章 · 获赞 162 · 访问量 22万+

猜你喜欢

转载自blog.csdn.net/qq_26803795/article/details/104581078