来,进来的小伙伴们,我们认识一下。
我是俗世游子,在外流浪多年的Java程序猿
这两天看到太多小伙伴在秀公司“10.24程序员节”的福利,我承认我酸了o(╥﹏╥)o
上一节我们聊过了ArrayList,对其底层结构和源码实现进行了了解,那这节我们来聊一聊关于它的“兄弟集合”:LinkedList
集合之LinkedList
特性
同样属于List的子类,那么也就同样拥有了其特点:
- 有序
- 不重复
可以看到,LinkedList除了实现List接口外,还实现了Queue接口,在Java中,该接口定义的是队列的,向我们之后要聊到的
等等的都是属于该类的实现
基于这种方式,那么我们的LinkedList也适合做队列的处理场景,比如:
- FIFO
- 堆,栈等
链表的介绍
LinkedList底层是基于双向链表的方式来存储的,那肯定有人在想,什么是链表呢?我们这就来聊一聊
什么是链表
链表是一种在逻辑上连续,但是物理存储上非连续的存储结构,其保证逻辑连续是通过指针指向来确定顺序的。
上面看到的是单向链表,可以看到:
- 在链表中,每个节点称为
Node
,其中包含两个部分- data:具体存储数据
- next:是指针的指向,指向下一个
Node
还有一种双向链表的形式
看上图:
- 节点中,额外多了个一个prev的指向,双向链表的节点是两两互相指向
LinkedList就是采用的双向链表的形式,下面我们来看具体的代码
链表的操作
背景:这里已双向链表为例
对链表操作,实际上就是修改指针的指向,比如
- 插入元素
头尾的插入非常简单,直接指向 next 和 prev 就可以了,这里我们看插入到中间
- 移除元素
移除元素和插入元素很类似,无非就是将指定元素删除掉,然后将指针指向下一个节点
前面的链表介绍都是为之后做铺垫,我们继续来看今天的主角:LinkedList
LinkedList详细介绍
LinkedList底层是采用双向链表的结构来进行数据存储的,可以说,LinkedList所有的操作都是针对引用指向来进行操作的。下面来看具体的方法
我们是这么操作LinkedList
LinkedList<String> linkedList = new LinkedList<>();
// Arrays.asList("item1", "item2"):为了方便演示
LinkedList<String> linkedList2 = new LinkedList<>(Arrays.asList("item1", "item2"));
同样,我们还是通过构造方法来看:
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
和ArrayList不同,这里只有两个构造方法。
-
在LinkedList中,初始长度是不需要设置的,而且也不需要扩容操作。
-
理论情况下,只要内存足够大,那么LinkedList就可以一直存储下去
不需要设置初始长度和底层存储结构有关,如果想不明白可以先去上一节看一看数组的介绍
不过也有说,LinkedList是存在最大容量的:不能超过Integer.MAX_VALUE
大家可以查找下资料,好好验证下该说法(本人没有在源码中发现对应的验证)
同时,我们来看一看Node
的属性值:
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;
}
}
双向链表节点:在代码中对应的具体实现。
这里我需要让大家考虑一个问题:
ArrayList和LinkedList同样存储了100W的数据,哪种集合占用的空间更大?
具体的操作方法
插入元素的方法
前面也说到了,LinkedList除了实现List接口外,还实现了Queue接口,自然也重写其对应的方法,下面我们一一来看看:
linkedList.add("item2");
linkedList.add(1, "item3");
linkedList.addFirst("item0");
linkedList.addLast("item9");
我们对比add(e)
和addLast(e)
的源代码
public boolean add(E e) {
linkLast(e);
return true;
}
public void addLast(E e) {
linkLast(e);
}
void linkLast(E e) {
// 得到临时变量尾结点
final Node<E> l = last;
// 新节点的上一个节点是之前的尾结点,下一个节点是null
final Node<E> newNode = new Node<>(l, e, null);
// 新的节点成为尾结点,
last = newNode;
// 如果尾结点是null,这个链表是空的,那么头结点也是新增的节点
if (l == null)
first = newNode;
else
// 之前尾结点的next是新节点
l.next = newNode;
size++;
modCount++;
}
-
两者实现方式都是
linkLast(e)
方法,从字面和具体的实现方法上来看,默认的add(e)
方法是将元素添加到了链表的尾部,这里专业名词叫:尾插法 -
addLast(e)
就更不用说了,直接将元素添加到尾部 -
关于
linkLast(e)
方法注释都已经有了,其实就是在调整指针的指向,整体描述如下图:
addFirst(e)
public void addFirst(E e) {
linkFirst(e);
}
private void linkFirst(E e) {
// 得到临时变量头结点
final Node<E> f = first;
// 新节点 插入到头部,所以新节点的next指向f
final Node<E> newNode = new Node<>(null, e, f);
// 同样,新节点称为新的头
first = newNode;
// 如果头结点是null,这个链表是空的,那么尾结点也是新增的节点
if (f == null)
last = newNode;
else
// 否则的话,新节点的prev指向新节点
f.prev = newNode;
size++;
modCount++;
}
- 其实,
addFirst(e)
和addLast(e)
方法正好相反
这里就不给图了,就是上面插入尾部改成插入头部
add(index, e)
这里我们要看一下指定位置的插入:
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, 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()
方法就不贴出来了,也就是改变prev和next的指向,下面我们看一下node(index)
方法
- 我们可以看到,指定位置插入节点,需要先遍历将当前索引上的节点找到,然后在改变指向
- 同样,这里在遍历的时候采用简单二分法,判断当前索引是在链表的前半段还是在后半段,减少循环遍历的次数,提高了性能
这里我们举个例子
比如:LinkedList中存储了500条,我们需要往250个位置上添加,那么node(index)
对应的遍历就是在后半段:
Node<E> x = last;
for (int i = 500 - 1; i > 250; i--)
x = x.prev;
return x;
// 比如这里是 400
Node<E> x = last;
for (int i = 500 - 1; i > 400; i--)
x = x.prev;
return x;
- 这样的情况下,需要循环最少(499-250)次才能找到对应的节点,而如果索引越大,那么循环次数就越小:
- 基于这种方式我们得出结论:
虽然遍历采用简单二分法提升了整体遍历的性能,但是如果遍历的节点越靠近中间位置,检索的效率也就越低
这里给大家留一个试验:ArrayList的插入和LinkedList的插入,性能相比如何?需要考虑一下几个方面:
- ArrayList的扩容问题
- 插入到头部,中间,尾部的性能
获取元素
直接get(index)的方式
linkedList.get(0);
// 在LinkedList中已经记录了头节点和尾结点,这里就是得到当前的数据就行了
linkedList.getFirst();
linkedList.getLast();
- 其实这里的
get(index)
我们上面已经介绍到了,就是通过node(index)
来得到指定索引的数据的
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Iterator的方式
Iterator<String> iterator = linkedList.iterator();
这种模式我们就不介绍了,iterator()
实现其实是采用这种方式来做的:
public ListIterator<E> listIterator() {
return listIterator(0);
}
public ListIterator<E> listIterator(final int index) {
rangeCheckForAdd(index);
return new ListItr(index);
}
private class ListItr implements ListIterator<E> {
private Node<E> lastReturned;
private Node<E> next;
private int nextIndex;
private int expectedModCount = modCount;
ListItr(int index) {
// assert isPositionIndex(index);
next = (index == size) ? null : node(index);
nextIndex = index;
}
}
这里默认传入的参数是:0,LinkedList为我们开放了该方法:
ListIterator<String> listIterator = linkedList.listIterator(0); // == linkedList.iterator();
ListIterator和Iterator两者的对比我们上节也介绍过了
那么,问题来了:在迭代LinkedList的时候我们该采用那种方式?
我们来做个实验进行验证:1W的数据,我们来进行验证
int len = 1_0000;
LinkedList<String> linkedList = new LinkedList<String>() {{
for (int i = 0; i < len; i++) {
add("item" + i);
}
}};
long start = System.currentTimeMillis();
for (int i = 0; i < len; i++) {
linkedList.get(i);
}
System.out.println("for i 耗时:" + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
Iterator<String> iterator = linkedList.iterator();
while (iterator.hasNext()) {
iterator.next();
}
System.out.println("iterator 耗时:" + (System.currentTimeMillis() - start));
猜一猜最终的结果如何?
我们来想一想为什么:
- 上面介绍过,
get(index)
底层实现是:node(index)
,最外层每循环一次,node(index)
每次都会进行一次二分查找,然后循环迭代索引取出对应的值 - 如果采用
iterator()
的话,只会在构造方法中进行一次node(0)
的迭代取出第一个节点,因为双向链表的形式,所以通过item.next
来就可以取出对应的元素
public E next() {
checkForComodification();
if (!hasNext())
throw new NoSuchElementException();
lastReturned = next;
next = next.next;
nextIndex++;
return lastReturned.item;
}
所以我们在LinkedList的迭代的时候,最好采用iterator()
的方式
移除元素
我们都是通过remove()
来移除元素,那么对应其中的实现:
E unlink(Node<E> x) {
// assert x != null;
// 得到当前元素,当前next和prev的指向对象
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
// 如果prev是null,说明是第一个节点
if (prev == null) {
first = next;
} else {
// 否则就将当前节点的prev的下一个节点指向当前节点的next
prev.next = next;
x.prev = null;
}
// 如果next是null,说明是最后一个节点
if (next == null) {
last = prev;
} else {
// 否则就将当前节点的prev的下一个节点指向当前节点的prev
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
这里其实就是修改引用的过程,这里就不展示图了,大家感兴趣的话我在后面给出一个站点,大家可以在那个站点上查看对应具体的过程
这里我们就过了,下面基于LinkedList简单聊一点队列的东西
队列
队列也是我们常用的一种数据结构:而且只允许在一端进行插入操作,另一端进行删除操作。
这不就是排排站,先插入进来的先被处理掉。
这种结构可以称为先进先出的方式,也就是我们所说的:FIFO,具体如下:
那么,LinkedList又是怎么做的呢?
简单来两个方法来实现一下:
- push(e)
将元素推送到由此列表表示的堆栈上
底层实现也非常简单,就是调用之前的addFirst(e)
来操作的,都是上面介绍过的
public void push(E e) {
addFirst(e);
}
- pollLast(e)
检索并删除此列表的最后一个元素
public E pollLast() {
final Node<E> l = last;
return (l == null) ? null : 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;
}
根据LinkedList提供的方法就可以有很多中实现方式,只要满足先进先出的方式
线程安全性
和ArrayList是一样的,如果只是当做局部变量来使用的话,是不存在线程问题的;但是如果当做共享资源来使用,那么必然是线程不安全的,针对解决方式:
- 自己加锁
- 使用Collections#synchronizedList方法的返回值来进行数据操作
文档
更多关于LinkedList使用方法推荐查看其文档: