LinkedList
一、LinkedList的层级关系
LinkedList是Collection集合大类里的一种,实现了Cloneable、Serializable接口,表明具有可克隆、可序列化与反序列化的特性。
二、从源码深入探索LinkedList
1、底层数据结构
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节点不光存储元素数据,也有上一个节点和下一个节点的引用。
- LinkedList有单独的两个节点,分别指向链表的头结点(first)和尾结点(last),无节点时指向空
2、静态内部类节点Node
Node节点为LinkedList的静态内部类,为什么是静态的呢,LinkedList里还有一些其他的内部类,如:迭代器ListItr就不是静态的,那么是根据什么来定义一个内部类是否为静态的呢?说到内部类,这其实也是Java的一个语法糖。
class OuterClass {
int a = 1;
static int b = 1;
class InnerClass {
InnerClass() {
a = 2;
System.out.println(a);
System.out.println(b);
}
}
static class StaticInnerClass {
StaticInnerClass(){
b = 2;
// System.out.println(a);
System.out.println(b);
}
}
}
//反编译后
class OuterClass$InnerClass {
// $FF: synthetic field
final OuterClass this$0;
OuterClass$InnerClass(OuterClass this$0) {
this.this$0 = this$0;
this$0.a = 2;
System.out.println(this$0.a);
}
}
class OuterClass$StaticInnerClass {
OuterClass$StaticInnerClass() {
OuterClass.b = 2;
System.out.println(OuterClass.b);
}
}
上面的例子中,在一个外部类里定义了一个静态内部类和一个非静态内部类。在编译之后,会生成三个class文件,其中两个内部类以独立的类文件形式存在,class名称为外部类名称&内部类名称。这时候不禁产生一个疑问,如果内部类以独立的形式存在,那内部类是如何能访问外部类的成员变量和方法呢,经反编译之后,发现非静态内部类里会有一个外部类的引用this&0,该引用在实例化内部类时被传入(所有构造方法都有这个引用),通过该引用来访问外部类的成员;而静态内部类却没有这个引用,因为类加载机制的原因,静态内部类里只能访问外部类的静态成员,即可以用外部类直接访问静态成员,而不需要外部类的实例。正是这个引用,如果外部类是个大对象,内部类是非静态的,及容易造成内存泄漏
class OuterClass {
private int[] data;
public OuterClass(int size) {
data = new int[size];
}
class InnerClass {
}
InnerClass getInnerClassObject() {
return new InnerClass();
}
}
public class MemoryLeakTest {
public static void main(String[] args) {
ArrayList list = new ArrayList<>();
int counter = 0;
while (true) {
list.add(new OuterClass(100000).getInnerClassObject());
System.out.println(counter++);
}
}
}
当使用new OuterClass(100000).getInnerClassObject()频繁地往集合中塞一个大对象的非静态内部类时,当执行完new OuterClass(100000)后,按理说OuterClass里的data数组占用的内存会被回收,但内部类还存在,它还含有外部类的引用,导致这部分本该被释放的内存没有被及时释放,最终会导致内存溢出。
再回到上面的问题,可以用什么作为标准来定义一个内部类是否为静态的呢?答案已经很明显了,非静态内部类里会含有外部类的引用,先不说构建非静态内部类会消耗额外的空间和时间,还容易造成内存泄漏,所以在创建内部类时,如果内部类需要访问外部类的成员(非静态),就将内部类设置为非静态的,否则就尽可能设置为静态的。
3、序列化与反序列化
size变量以及first、last节点都是用transient修饰,LinkedList没有使用默认的序列化方法,而是重写了writeObject和readObject方法,自定义了序列化和反序列化的逻辑:只序列化具体的元素,没有序列化size变量和整个Node节点;在反序列化时,需要计算size的值和重新连接链表。因为Node节点中不光有元素数据,还包含上一节点和下一节点的地址引用,如果将Node节点序列化,相当于将整条链表都序列化了,这是一件非常消耗时间和空间的事。没有序列化size变量,也是出于这一点考虑,这就相当于,知道了长宽高,没必要再序列化体积一样。(也有其他博主认为LinkedList需要这么做的原因是反序列化后,节点的前驱节点和后继节点失效了,因为内存地址发生了改变,这一点我不怎么认同,内存地址应该是不会变的,不然包含其他类对象的类进行序列化将会非常麻烦,比如B类对象是A类的一个成员变量)
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// Write out any hidden serialization magic
s.defaultWriteObject();
// Write out size
s.writeInt(size);
// Write out all elements in the proper order.
for (Node<E> x = first; x != null; x = x.next)
s.writeObject(x.item);
}
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// Read in any hidden serialization magic
s.defaultReadObject();
// Read in size
int size = s.readInt();
// Read in all elements in the proper order.
for (int i = 0; i < size; i++)
linkLast((E)s.readObject());
}
4、添加元素
List<String> list = new LinkedList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
list.add("e");
//执行后的结果为:[a, b, c, d, e]
看下底层如何实现add方法
public LinkedList() {
}
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
用无参构造方法初始化了一个LinkedList后,整个链表都是空的,头节点和尾节点也是指向空,使用add()方法添加元素,实际采用的是尾插法,在节点的末尾添加元素。下面用几张图来表示LinkedList在插入元素时,链表是怎么变化的
c、d、e三个节点以此类推,这里就不画图了。
linkLast方法的最后一行,有一个modCount++,看到这个,我们就明白了,LinkedList也是一个fail-fast机制的集合,具体可以看下我其他的文章:Java集合–foreach遍历时,不能对集合进行新增和删除(fail-fast机制)
LinkedList不光可以在链表末尾添加元素,也提供了在指定下标插入元素
//若原链表为[a, b, c, d, e]
list.add(1, "cc");
//执行后的结果为:[a, cc, b, c, d, e]
public void add(int index, E element) {
//检查下标是否合法:!(index >= 0 && index <= size)
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
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++;
}
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;
}
}
如果要插入的下标位置为链表的末尾(下标为size的位置),则还是利用尾插法插入元素。否则,就先找到原链表中该下标的节点,在该元素之前插入新节点,并调整对应节点的next和pre节点的指向。找下标节点的方法比较有意思,由于LinkedList底层是链表实现的,不像ArrayList是数组实现,不支持快速随机访问,要找某个元素,是需要遍历链表的,但是他不是直接从头节点根据next指向,一直顺序遍历到下标节点,而是先确定要插入的元素在链表的前半部分还是后半部分,如果在前半部分就从头节点开始遍历,如果是后半部分,就从尾节点开始倒序遍历。这个方法很重要,LinkedList根据下标进行操作的方法都会用到这个,比如像根据下标获取元素、根据下标修改元素、根据下标删除元素等等。
5、修改元素
//若原链表为[a, cc, b, c, d, e]
list.set(2, "bb");
//执行后的结果为:[a, cc, bb, c, d, e]
set方法的内部实现和在指定位置插入元素的实现类似,都是需要先找到下标节点,然后再进行操作
public E set(int index, E element) {
checkElementIndex(index);
Node<E> x = node(index);
E oldVal = x.item;
x.item = element;
return oldVal;
}
6、删除元素
LinkedList提供了三种删除元素的方法:根据下标删除;根据元素删除;直接删除(删除的是头节点)
//若原链表为:[a, cc, bb, c, d, e]
list.remove("d"); //执行后的结果为[a, cc, bb, c, e]
list.remove(1); //执行后的结果为[a, bb, c, e]
list.remove(); //执行后的结果为[bb, c, e]
源码就不贴了,实现都差不多,先找到该节点,然后改变节点指向,另外需要边遍历边做删除动作时,可以看下我这篇文章,虽然写的是ArrayList,但是对于LinkedList也同样适用:那些年,我们在Java ArrayList Remove方法遇到的坑
三、LinkedList的特点
在知道了LinkedList的底层实现后,便可以总结一下LinkedList的特点
元素是否允许为空 | 允许(且允许多个元素为空) |
元素是否允许重复 | 允许 |
是否有序 | 有序 |
是否线程安全 | 非线程安全 |
四、LinkedList与ArrayList
- 随机访问: ArrayList比LinkedList快得多。ArrayList底层由数组实现,支持快速随机访问,且访问的速度基本不受数据量的影响,时间复杂度是O(1);而LinkedList底层由链表实现,不支持快速随机访问,调用get(index)方法访问元素时,需要遍历链表。
- 顺序迭代遍历: 如果用两者最擅长的方式遍历相同数量的元素,即:ArrayList用for循环遍历,LinkedList用迭代器或者foreach遍历(LinkedList用for循环加get遍历,速率会非常慢),从程序上看,时间复杂度都是O(n),但是实际上ArrayList的速度是要比LinkedList快的,这就要说到CPU了, CPU会把连续的一段内存加载到缓存(缓存读取速度大于内存),在读取数据时,先从缓存中读,缓存中没有再去内存中读,而数组申请的是一段连续的内存,这样数组的全部或部分元素就容易被连续存储在CPU缓存里(一个缓存行里)。而链表的节点是分散在堆内的,遍历时容易跳出缓存,去内存中查找数据
- 插入、删除元素: ArrayList在插入、删除元素时,可以根据下标快速定位到插入、删除的位置,但是耗时体现在数组元素的拷贝移动和扩容;LinkedList在插入、删除元素时,虽然只需调整相应节点的指向,但是慢在遍历链表,寻找插入、删除元素的位置。那么,在插入、删除元素方面,ArrayList和LinkedList的速率比较到底如何呢,是不是真的像传闻中的那样,在插入和删除元素方面,LinkedList要更胜一筹呢?我在网上看了很多博主的测试实验,自己也简单进行了测试,得出以下结论:插入、删除的元素在头部或者集合的靠前部分,LinkedList的速度要比ArrayList快很多,因为ArrayList需要移动的数据有很多,相反LinkedList要遍历的数据很少;插入、删除的元素越靠近中间,随着ArrayList需要移动的数据越来越少,LinkedList需要遍历的数据越来越多(在中间位置,需要遍历的数据最多),ArrayList是要比LinkedList快的。在集合末尾进行插入,ArrayList也是比LinkedList快,虽然ArrayList不需要移动数据,LinkedList也不需要遍历数据,但是数组的空间是提前new好的,而链表插入需要新建节点,且要调节相关节点的指向。
- 修改元素: 由于ArrayList不需要移动数据,而LinkedList需要遍历数据,需要找到修改的元素所在的位置,所以二者在修改元素上的速率比较,是同随机访问的。
所以,如果不是在集合的头节点或者靠前位置插入、删除元素,我一般都是选择ArrayList的。以上如有什么地方说的不对,还望指出。
– jdk版本:1.8