Java基础之刨根问底第8集——LinkedList

原文转自我自己的个人公众号:Java基础之刨根问底第8集——LinkedList(由于是拷贝过来的,如果排版有问题,请看公众号文章)

  • 本系列不适合初学者,读者应具备一定的Java基础。

  • 本系列依据Java11编写

 

内容简介:

  • 1.LinkedList的继承关系

  • 2.队列和双端队列

  • 3.LinkedList的内部实现机制

  • 4.核心方法实现解析

    • 4.1.List接口相关方法

    • 4.2.新增相关

    • 4.3.查询相关

    • 4.4.修改相关

    • 4.5.删除相关

  • 5.迭代器实现解析

    • 5.1.Iterator

    • 5.2.ListIterator

  • 6.对比ArrayList

  • 7.下集预告

1.LinkedList的继承关系

LinkedList的继承关系如下图所示:

对比在第7集ArrayList的继承关系来看,相比ArrayList,LinkedList没有实现RandomAccess接口,在继承AbstractList之前多继承了一层AbstractSequentialList,同时额外实现了Deque接口。在这里我就这3点做一个小小的解释,具体的解释会在后文中持续阐述:

  1. 没有实现RandomAccess接口:在第7集中我介绍过这个接口,该接口是标识其实现类支持随机访问,也就是说需要实现类提供根据下标访问元素的能力。LinkedList没有实现这个接口,说明它不具备这个能力。

  2. 多了继承了一层AbstractSequentialList:由于List接口定义了根据下标访问元素的方法,为了让LinkedList在不提供随机访问的同时兼容List接口,在AbstractSequentialList类中,将所有的随机访问方法的实现都改为了使用ListIterator从前向后迭代到对应下标然后返回,实际上就是一种通过顺序方式访问下标元素的方法。因此,LinkedList的所有跟下标相关的方法的性能都会随着顺序查询到该元素的迭代次数增多而变差。不过由于LinkedList的能力主要是双端链表,因此虽然继承自AbstractSequentialList,不过其中的方法都已经根据双端链表的特性重写了。这种继承也更多的是一种声明的体现。

public E get(int index) {
  try {
    return listIterator(index).next();
  } catch (NoSuchElementException exc) {
    throw new IndexOutOfBoundsException("Index: "+index);
  }
}
​
public E set(int index, E element) {
  try {
    ListIterator<E> e = listIterator(index);
    E oldVal = e.next();
    e.set(element);
    return oldVal;
  } catch (NoSuchElementException exc) {
    throw new IndexOutOfBoundsException("Index: "+index);
  }
}
​
public void add(int index, E element) {
  try {
    listIterator(index).add(element);
  } catch (NoSuchElementException exc) {
    throw new IndexOutOfBoundsException("Index: "+index);
  }
}
​
public E remove(int index) {
  try {
    ListIterator<E> e = listIterator(index);
    E outCast = e.next();
    e.remove();
    return outCast;
  } catch (NoSuchElementException exc) {
    throw new IndexOutOfBoundsException("Index: "+index);
  }
}

 3.实现Deque接口:Deque是一个双端队列,这是LinkedList能成为双向链表的基础。

 

2.队列和双端队列

Deque接口继承自Queue接口,前者是双端队列,而后者则是单向队列,下面我们来具体看一下。

首先介绍下Queue。

Queue的中文名称是:“队列”,队列是一种可以按照一个方向迭代元素的数据结构。在大多数情况下,队列迭代元素的方向是先进先出的,也就是FIFO(first-in-first-out),即:先进入队列的元素也会被先迭代到,这时可以类比成管道,水从一头进入,再从另一头流出,先流入的水会先流出。但是,队列并没有要求方向一定是FIFO的,因此也可以是后进先出的(LIFO),这时的队列就会表现出“栈”的特征。

Queue继承了Collection接口,Collection接口在前两集中都有介绍,主要提供了新增、删除和检查元素是否存在的方法,而Queue对add(e)的行为做了重新定义,同时扩展出了两组功能相同的方法,其中一组在操作失败的时候会抛出异常,而另一组则会返回false或者null,如下所示:

以插入方法为例,对比add(e)和offer(e)方法上的JavaDoc就会比较比较清晰了,如下所示:

可以看到,相比add方法,offer方法所抛出的异常中少了IllegalStateException这个异常,从方法注释中可以看到,当Queue的容量达到上限时,add方法会抛出IllegalStateException异常,而offer方法则返回false表示操作失败。

下面具体来看下Queue中的方法:

  • add(e):继承自Collection,但对行为进行了重定义。Collection中的该方法在集合不允许重复值且已经存在要插入的值时会抛出IllegalStateException,而在Queue中则是当容量到达上限时抛出IllegalStateException。

  • offer(e):和add(e)的能力相同,只是不会抛出IllegalStateException,而是在容量达到上限后返回false。

  • remove():返回队列中的第一个元素,返回后从队列中移除该元素,如果队列是空的,则抛出异常。

  • poll():和remove()的能力相同,只是队列为空时返回null。

  • element():返回队列中的第一个元素(不删除),如果队列是空的则抛出异常。

  • peek():和element()能力相同,只是队列为空时返回null。

下面介绍下Deque

Deque继承自Queue,与Queue的单向不同,Deque是双端队列,两端都可以插入和获取元素。

这里多说语句,Deque的发音是“deck”。

为了见名知义,Deque为两端提供了两组方法,尽管其中一组方法的能力等同与Queue,但依然使用了不同的命名,这些等效的方法如下:

从代码中看会更加直观,以LinkedList的实现方式为例:

public boolean add(E e) {
  linkLast(e);
  return true;
}
​
public void addLast(E e) {
  linkLast(e);
}

可以看到,在LinkedList的实现中,add(e)和addLast(e)都调用了同一个linkLast(e)方法。

与Queue中的方法类似,Deque也提供了一组抛异常和另一组返回特定值的方法,如下所示:

3.LinkedList的内部实现机制

与ArrayList基于内部数组的实现方式不同,LinkedList的实现方式如下图所示:

Node是LinkedList的内部类,有三个属性,其中item是当前存储的元素,prev是该元素前一个元素所在的Node,next是该元素后一个元素所在的Node,这种结构被称为“链表”,LinkedList也因此得名。集合中第一个元素的prev是null,相应的最后一个元素的next是null。在LinkedList中,first属性是集合头部的第一个元素,而last则是集合尾部的第一个元素。

与数组不同的是,数组元素所占用的内存空间是连续的,而链表则是离散的,因此LinkedList的元素遍历效率要远差于ArrayList

除了first和last外,LinkedList中还有一个size属性,用来记录当前集合的元素个数,如下所示:

transient int size = 0;
​
transient Node<E> first;
​
transient Node<E> last;

这三个属性都设置成了transient,这个原因在第7集中已经解释过了。

4.核心方法实现解析

4.1.List接口相关方法

与List接口相关的方法主要是涉及下标的方法,例如:add(int, E)、set(int, E)、remove(int)、get(int)等。虽然LinkedList继承的AbstractSequentialList都已经使用顺序的方式实现了,但LinkedList根据自身双端链表的实现机制对这些方法进行了重写,主要是使用node(int)方法来获取元素,下面是部分方法的实现代码:

public void add(int index, E element) {
  checkPositionIndex(index);
​
  if (index == size)
    linkLast(element);
  else
    linkBefore(element, node(index));
}
​
public E get(int index) {
  checkElementIndex(index);
  return node(index).item;
}

上面是add和get方法的实现,可以看到他们都使用了node(int)方法来获取到对应下标的元素,然后再进行相关处理,下面来看一下node(int)方法的实现:

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;
  }
}

这段代码比较简单,右移(>>)1位等同于除2,因此当传入的index位于集合的前半段时,从first开始向后遍历寻找,否则如果传入的index位于集合的后半段则从last开始向前遍历寻找。这样就比AbstractSequentialList中该方法用ListIterator永远从前到后遍历查找的效率要高一些了。具体CRUD的操作将在后续的几个小节中介绍。

4.2.新增相关

LinkedList内部使用两个私有方法处理元素的插入,分别是linkFirst(E)和linkLast(E),前者是向头部插入,后者是向尾部插入,我们以linkFirst为例:

private void linkFirst(E e) {
  final Node<E> f = first;
  final Node<E> newNode = new Node<>(null, e, f);  // 1
  first = newNode;  // 2
  if (f == null)
    last = newNode;
  else
    f.prev = newNode;  // 3
  size++;
  modCount++;
}

链表操作的实现代码是比较简单的,用图来说明这段代码会更加直观,如下图所示:

第一步:创建一个新的Node对象,prev赋值为null,item赋值为要插入的元素。

第二步:LinkedList的first属性设置为新的Node对象。

第三步:原来位于第一个位置的Node对象的prev赋值为新的对象。

这样操作后,结果如下:

这样新的元素就插入到集合中了。

4.3.查询相关

LinkedList的查询方法大致可以分为三类,a) 根据下标查询,如:get(int);b) 获取头部元素,如:getFirst、poll、peek等;c) 获取尾部元素,如:getLast、pollLast、peekLast等。后两者的处理很简单,因为LinkedList内部本来就有first和last属性,可以直接用来返回结果。第一类方法的查询方式已经在4.1中介绍过了,就是通过node(int)方法来遍历查询。

4.4.修改相关

修改也比较简单,以set(int, E)方法为例:

public E set(int index, E element) {
  checkElementIndex(index);
  Node<E> x = node(index);
  E oldVal = x.item;
  x.item = element;
  return oldVal;
}

先用node(int)获取到对应下标的Node对象,然后直接将其中的item属性赋值为新元素就行了。

4.5.删除相关

与新增正好相反,根据删除的方向,最终会调用两个内部私有方法,unlinkFirst(Node)、unlinkLast(Node)和unlink(Node),以unlink(Node)为例:

E unlink(Node<E> x) {
  final E element = x.item;
  final Node<E> next = x.next;
  final Node<E> prev = x.prev;
​
  if (prev == null) {
    first = next;
  } else {
    prev.next = next;
    x.prev = null;
  }
​
  if (next == null) {
    last = prev;
  } else {
    next.prev = prev;
    x.next = null;
  }
​
  x.item = null;
  size--;
  modCount++;
  return element;
}

原理很简单,就是先通过node(int)方法找到目标元素对应的Node对象,然后修改与该对象相邻的两个Node对象的prev和next属性,然后解除该对象自身的prev和next属性的引用就可以了。

5.迭代器实现解析

5.1.Iterator

LinkedList自身没有实现Collection接口中的iterator方法,是直接从AbstractSequentialList中继承的。在AbstractSequentialList中,iterator方法直接调用了listIterator方法并返回:

public Iterator<E> iterator() {
  return listIterator();
}

AbstractList对listIterator的实现是调用listIterator(int)方法,LinkedList实现了ListIterator(int)方法,这个在下一节再具体介绍。

虽然LinkedList没有实现iterator方法,但是却扩展出了一个descendingIterator方法,毕竟,LinkedList是双端链表嘛。iterator方法可以看作是从头部开始的升序迭代,而descendingIterator则是从尾部开始的降序迭代。

private class DescendingIterator implements Iterator<E> {
    private final ListItr itr = new ListItr(size());
    public boolean hasNext() {
        return itr.hasPrevious();
    }
    public E next() {
        return itr.previous();
    }
    public void remove() {
        itr.remove();
    }
}

降序迭代器的实现也是依赖ListIterator,不同点在于next和hasNext方法都是查询该元素所在Node的前一个Node。

5.2.ListIterator

接下来就再来看看ListIterator。

好吧,其实也没啥看的,就是prev和next的来回倒腾。。。

6.对比ArrayList

性能方面的对比已经在Java基础之刨根问底第8集——LinkedList中介绍过了。如果你的需求是List接口中的方法,那么在几乎所有场景下,都应该使用ArrayList,因为它可以随机访问、基于数组的连续存储空间、无需构建Node对象更省内存等等等等。

只有在需要使用队列或者栈的情况下,才应该考虑LinkedList
 

7.下集预告

List接口中的两个实现类ArrayList和LinkedList介绍完了,下集开始整Set!

 插播个小广告:

本人新书发布!《企业架构与绕不开的微服务》。

  • 在理论方面,介绍了企业架构标准、云原生思想和相关技术、微服务的前世今生,以及领域驱动设计等;

  • 在实践方面,介绍了用于拆分微服务的“五步法”、包含4个维度的“企业云原生成熟度模型”,以及衡量企业变革成果的“效果收益评估方法”等。

本书可以帮助企业明确痛点、制定原则、规划路径、建设能力和评估成效,最终实现微服务架构在企业中的持续运营和持续演化,从而应对日益增多的业务挑战。

 点击这里进入购买页面

 更多内容请关注我的个人公众号

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值