Java集合--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

  1. 随机访问: ArrayList比LinkedList快得多。ArrayList底层由数组实现,支持快速随机访问,且访问的速度基本不受数据量的影响,时间复杂度是O(1);而LinkedList底层由链表实现,不支持快速随机访问,调用get(index)方法访问元素时,需要遍历链表。
  2. 顺序迭代遍历: 如果用两者最擅长的方式遍历相同数量的元素,即:ArrayList用for循环遍历,LinkedList用迭代器或者foreach遍历(LinkedList用for循环加get遍历,速率会非常慢),从程序上看,时间复杂度都是O(n),但是实际上ArrayList的速度是要比LinkedList快的,这就要说到CPU了, CPU会把连续的一段内存加载到缓存(缓存读取速度大于内存),在读取数据时,先从缓存中读,缓存中没有再去内存中读,而数组申请的是一段连续的内存,这样数组的全部或部分元素就容易被连续存储在CPU缓存里(一个缓存行里)。而链表的节点是分散在堆内的,遍历时容易跳出缓存,去内存中查找数据
  3. 插入、删除元素: ArrayList在插入、删除元素时,可以根据下标快速定位到插入、删除的位置,但是耗时体现在数组元素的拷贝移动和扩容;LinkedList在插入、删除元素时,虽然只需调整相应节点的指向,但是慢在遍历链表,寻找插入、删除元素的位置。那么,在插入、删除元素方面,ArrayList和LinkedList的速率比较到底如何呢,是不是真的像传闻中的那样,在插入和删除元素方面,LinkedList要更胜一筹呢?我在网上看了很多博主的测试实验,自己也简单进行了测试,得出以下结论:插入、删除的元素在头部或者集合的靠前部分,LinkedList的速度要比ArrayList快很多,因为ArrayList需要移动的数据有很多,相反LinkedList要遍历的数据很少;插入、删除的元素越靠近中间,随着ArrayList需要移动的数据越来越少,LinkedList需要遍历的数据越来越多(在中间位置,需要遍历的数据最多),ArrayList是要比LinkedList快的。在集合末尾进行插入,ArrayList也是比LinkedList快,虽然ArrayList不需要移动数据,LinkedList也不需要遍历数据,但是数组的空间是提前new好的,而链表插入需要新建节点,且要调节相关节点的指向。
  4. 修改元素: 由于ArrayList不需要移动数据,而LinkedList需要遍历数据,需要找到修改的元素所在的位置,所以二者在修改元素上的速率比较,是同随机访问的。

所以,如果不是在集合的头节点或者靠前位置插入、删除元素,我一般都是选择ArrayList的。以上如有什么地方说的不对,还望指出。

– jdk版本:1.8

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值