LinkedList 的作者:我写了 LinkedList,但我自己都不用

news2024/11/19 11:18:16

Joshua Bloch:我写了 LinkedList,但我自己都不用!

对,Joshua Bloch 就是 LinkedList 的作者!

如果你真信了作者的话,那就真的大错特错了,LinkedList 虽然用的没有 ArrayList 多,但使用它的地方可不少。

我在技术派这个开源项目的引用的第三方依赖中,随便搜了一下引用,好多好多地方在用呢,比如说大名鼎鼎的 Jackson。

所以,遇到一个问题,比如说:ArrayList 和 LinkedList 之间应该怎么选择?你应该先去思考,两者到底有什么区别,搞清楚事情的本质,再去做出选择。

下面我们就通过对话+源码的形式彻底来搞清楚两者之间的区别!


“终于,哥,我们要聊 LinkedList 和 ArrayList 之间的差别了,我期待了很久。”三妹嘟囔着说。

“其实经过前面两节的分析,差别已经很清晰了。”我喃喃道。

“哥,你再说点吧,深挖一下,OK?”

“好吧,那就让我们出发吧!”

PS:为了和前面两节的源码做适当的区分,这里采用的是 Java 11 的源码,请务必注意。但整体上差别很小。

01、ArrayList 是如何实现的?

ArrayList 实现了 List 接口,继承了 AbstractList 抽象类。

底层是基于数组实现的,并且实现了动态扩容(当需要添加新元素时,如果 elementData 数组已满,则会自动扩容,新的容量将是原来的 1.5 倍),来看一下 ArrayList 的部分源码。

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    private static final int DEFAULT_CAPACITY = 10; // 默认容量为 10
    transient Object[] elementData; // 存储元素的数组,数组类型为 Object
    private int size; // 列表的大小,即列表中元素的个数
}

ArrayList 还实现了 RandomAccess 接口,这是一个标记接口:

public interface RandomAccess {
}

内部是空的,标记“实现了这个接口的类支持快速(通常是固定时间)随机访问”。快速随机访问是什么意思呢?就是说不需要遍历,就可以通过下标(索引)直接访问到内存地址。而 LinkedList 没有实现该接口,表示它不支持高效的随机访问,需要通过遍历来访问元素。

/**
 * 返回列表中指定位置的元素。
 *
 * @param index 要返回的元素的索引
 * @return 列表中指定位置的元素
 * @throws IndexOutOfBoundsException 如果索引越界(index < 0 || index >= size())
 */
public E get(int index) {
    Objects.checkIndex(index, size); // 检查索引是否越界
    return elementData(index); // 调用 elementData 方法获取元素
}

/**
 * 返回列表中指定位置的元素。
 * 注意:该方法并没有检查索引是否越界,调用该方法前需要先检查索引是否越界。
 *
 * @param index 要返回的元素的索引
 * @return 列表中指定位置的元素
 */
E elementData(int index) {
    return (E) elementData[index]; // 强制类型转换,将 Object 类型转换为 E 类型
}

ArrayList 还实现了 Cloneable 接口,这表明 ArrayList 是支持拷贝的。ArrayList 内部的确也重写了 Object 类的 clone() 方法。

/**
 * 返回该列表的浅表副本。
 * (元素本身不会被复制。)
 *
 * @return 该列表的副本
 */
public Object clone() {
    try {
        ArrayList<?> v = (ArrayList<?>) super.clone(); // 调用 Object 类的 clone 方法,得到一个浅表副本
        v.elementData = Arrays.copyOf(elementData, size); // 复制 elementData 数组,创建一个新数组作为副本
        v.modCount = 0; // 将 modCount 置为 0
        return v; // 返回副本
    } catch (CloneNotSupportedException e) {
        // this shouldn't happen, since we are Cloneable
        throw new InternalError(e);
    }
}

ArrayList 还实现了 Serializable 接口,同样是一个标记接口:

public interface Serializable {
}

内部也是空的,标记“实现了这个接口的类支持序列化”。序列化是什么意思呢?Java 的序列化是指,将对象转换成以字节序列的形式来表示,这些字节序中包含了对象的字段和方法。序列化后的对象可以被写到数据库、写到文件,也可用于网络传输。

眼睛雪亮的小伙伴可能会注意到,ArrayList 中的关键字段 elementData 使用了 transient 关键字修饰,这个关键字的作用是,让它修饰的字段不被序列化。

这不前后矛盾吗?一个类既然实现了 Serilizable 接口,肯定是想要被序列化的,对吧?那为什么保存关键数据的 elementData 又不想被序列化呢?

这还得从 “ArrayList 是基于数组实现的”开始说起。大家都知道,数组是定长的,就是说,数组一旦声明了,长度(容量)就是固定的,不能像某些东西一样伸缩自如。这就很麻烦,数组一旦装满了,就不能添加新的元素进来了。

ArrayList 不想像数组这样活着,它想能屈能伸,所以它实现了动态扩容。一旦在添加元素的时候,发现容量用满了 s == elementData.length,就按照原来数组的 1.5 倍(oldCapacity >> 1)进行扩容。扩容之后,再将原有的数组复制到新分配的内存地址上 Arrays.copyOf(elementData, newCapacity)。

这部分源码我们在之前讲 ArrayList 的时候就已经讲的很清楚了,这里就一笔带过。

动态扩容意味着什么?

意味着数组的实际大小可能永远无法被填满的,总有多余出来空置的内存空间。

比如说,默认的数组大小是 10,当添加第 11 个元素的时候,数组的长度扩容了 1.5 倍,也就是 15,意味着还有 4 个内存空间是闲置的,对吧?

序列化的时候,如果把整个数组都序列化的话,是不是就多序列化了 4 个内存空间。当存储的元素数量非常非常多的时候,闲置的空间就非常非常大,序列化耗费的时间就会非常非常多。

于是,ArrayList 做了一个愉快而又聪明的决定,内部提供了两个私有方法 writeObject 和 readObject 来完成序列化和反序列化。

/**
 * 将此列表实例的状态序列写入指定的 ObjectOutputStream。
 * (即,保存这个列表实例到一个流中。)
 *
 * @param s 要写入的流
 * @throws java.io.IOException 如果写入流时发生异常
 */
private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
    s.defaultWriteObject(); // 写出对象的默认字段

    // Write out size as capacity for behavioral compatibility with clone()
    s.writeInt(size); // 写出 size

    // Write out all elements in the proper order.
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]); // 依次写出 elementData 数组中的元素
    }
}

从 writeObject 方法的源码中可以看得出,它使用了 ArrayList 的实际大小 size 而不是数组的长度(elementData.length)来作为元素的上限进行序列化。

此处应该有掌声啊!不是为我,为 Java 源码的作者们,他们真的是太厉害了,可以用两个词来形容他们——殚精竭虑、精益求精。

这是readObject方法的源码:

/**
 * 从指定的 ObjectInputStream 中读取此列表实例的状态序列。
 * (即,从流中恢复这个列表实例。)
 *
 * @param s 从中读取此列表实例的状态序列的流
 * @throws java.io.IOException 如果读取流时发生异常
 * @throws ClassNotFoundException 如果在读取流时找不到类
 */
private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
    elementData = EMPTY_ELEMENTDATA; // 初始化 elementData 数组为空数组

    // 读取默认字段
    s.defaultReadObject();

    // 读取容量,这个值被忽略,因为在 ArrayList 中,容量和长度是两个不同的概念
    s.readInt();

    if (size > 0) {
        // 分配一个新的 elementData 数组,大小为 size
        ensureCapacityInternal(size);

        Object[] a = elementData;
        // 依次从输入流中读取元素,并将其存储在数组中
        for (int i=0; i<size; i++) {
            a[i] = s.readObject(); // 读取对象并存储在 elementData 数组中
        }
    }
}

02、LinkedList 是如何实现的?

LinkedList 是一个继承自 AbstractSequentialList 的双向链表,因此它也可以被当作堆栈、队列或双端队列进行操作。

来看一下部分源码:

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
    transient int size = 0; // 非序列化字段,表示链表中的节点个数
    transient Node<E> first; // 非序列化字段,指向链表中的第一个节点
    transient Node<E> last; // 非序列化字段,指向链表中的最后一个节点

    // ...
}

LinkedList 内部定义了一个 Node 节点,它包含 3 个部分:元素内容 item,前引用 prev 和后引用 next。这个在讲 LinkedList 的时候也讲过了,这里略过。

LinkedList 还实现了 Cloneable 接口,这表明 LinkedList 是支持拷贝的。

LinkedList 还实现了 Serializable 接口,这表明 LinkedList 是支持序列化的。眼睛雪亮的小伙伴可能又注意到了,LinkedList 中的关键字段 size、first、last 都使用了 transient 关键字修饰,这不又矛盾了吗?到底是想序列化还是不想序列化?

答案是 LinkedList 想按照自己的方式序列化,来看它自己实现的 writeObject() 方法:

private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
    // 写入默认的序列化标记
    s.defaultWriteObject();

    // 写入链表的节点个数
    s.writeInt(size);

    // 按正确的顺序写入所有元素
    for (LinkedList.Node<E> x = first; x != null; x = x.next)
        s.writeObject(x.item);
}

发现没?LinkedList 在序列化的时候只保留了元素的内容 item,并没有保留元素的前后引用。这样就节省了不少内存空间,对吧?

那有些小伙伴可能就疑惑了,只保留元素内容,不保留前后引用,那反序列化的时候怎么办?

private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
    // 读取默认的序列化标记
    s.defaultReadObject();

    // 读取链表的节点个数
    int size = s.readInt();

    // 按正确的顺序读取所有元素
    for (int i = 0; i < size; i++)
        linkLast((E)s.readObject()); // 读取元素并将其添加到链表末尾
}

void linkLast(E e) {
    final LinkedList.Node<E> l = last;
    final LinkedList.Node<E> newNode = new LinkedList.Node<>(l, e, null);
    last = newNode; // 将新节点作为链表尾节点
    if (l == null)
        first = newNode; // 如果链表为空,将新节点作为链表头节点
    else
        l.next = newNode; // 否则将新节点链接到链表尾部
    size++; // 增加节点个数
}

注意 for 循环中的 linkLast() 方法,它可以把链表重新链接起来,这样就恢复了链表序列化之前的顺序。很妙,对吧?

和 ArrayList 相比,LinkedList 没有实现 RandomAccess 接口,这是因为 LinkedList 存储数据的内存地址是不连续的,所以不支持随机访问。

03、新增元素时究竟谁快?

前面我们已经从多个维度了解了 ArrayList 和 LinkedList 的实现原理和各自的特点。那接下来,我们就来聊聊 ArrayList 和 LinkedList 在新增元素时究竟谁快?

1)ArrayList

ArrayList 新增元素有两种情况,一种是直接将元素添加到数组末尾,一种是将元素插入到指定位置。

添加到数组末尾的源码(这部分前面讲 ArrayList 的时候讲过了,这里再温故一下):

public boolean add(E e) {
    add(e, elementData, size);
    return true;
}

private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length)
        elementData = grow(); // 扩容数组
    elementData[s] = e; // 将元素添加到数组末尾
    size = s + 1; // 增加元素个数
}

很简单,先判断是否需要扩容,然后直接通过索引将元素添加到末尾。

插入到指定位置的源码:

public void add(int index, E element) {
    rangeCheckForAdd(index); // 检查插入位置是否越界
    final int s; // 当前元素个数
    Object[] elementData; // 元素数组
    if ((s = size) == (elementData = this.elementData).length) // 如果数组已满,则扩容
        elementData = grow();
    System.arraycopy(elementData, index,
            elementData, index + 1,
            s - index); // 将插入位置后的元素向右移动一位
    elementData[index] = element; // 将新元素插入到指定位置
    size = s + 1; // 增加元素个数
}

先检查插入的位置是否在合理的范围之内,然后判断是否需要扩容,再把该位置以后的元素复制到新添加元素的位置之后,最后通过索引将元素添加到指定的位置。

2)LinkedList

LinkedList 新增元素也有两种情况,一种是直接将元素添加到队尾,一种是将元素插入到指定位置。

添加到队尾的源码:

public boolean add(E e) {
    linkLast(e); // 将元素添加到链表末尾
    return true;
}

void linkLast(E e) {
    final LinkedList.Node<E> l = last; // 获取链表的尾节点
    final LinkedList.Node<E> newNode = new LinkedList.Node<>(l, e, null); // 创建新节点
    last = newNode; // 将新节点作为链表的尾节点
    if (l == null)
        first = newNode; // 如果链表为空,则将新节点作为链表的头节点
    else
        l.next = newNode; // 否则将新节点链接到链表的尾部
    size++; // 增加节点个数
}

先将队尾的节点 last 存放到临时变量 l 中,然后生成新的 Node 节点,并赋给 last,如果 l 为 null,说明是第一次添加,所以 first 为新的节点;否则将新的节点赋给之前 last 的 next。

插入到指定位置的源码:

public void add(int index, E element) {
    checkPositionIndex(index); // 检查插入位置是否越界

    if (index == size)
        linkLast(element); // 如果插入位置为链表末尾,则将元素添加到链表末尾
    else
        linkBefore(element, node(index)); // 否则将元素插入到指定位置的前面的节点后面
}

LinkedList.Node<E> node(int index) {
    if (index < (size >> 1)) { // 如果插入位置在链表前半部分,则从头节点开始查找
        LinkedList.Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else { // 否则从尾节点开始查找
        LinkedList.Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

void linkBefore(E e, LinkedList.Node<E> succ) {
    final LinkedList.Node<E> pred = succ.prev; // 获取插入位置的前驱节点
    final LinkedList.Node<E> newNode = new LinkedList.Node<>(pred, e, succ); // 创建新节点
    succ.prev = newNode; // 将新节点链接到后继节点
    if (pred == null)
        first = newNode; // 如果前驱节点为空,则将新节点作为头节点
    else
        pred.next = newNode; // 否则将新节点链接到前驱节点
    size++; // 增加节点个数
}

先检查插入的位置是否在合理的范围之内,然后判断插入的位置是否是队尾,如果是,添加到队尾;否则执行 linkBefore() 方法。

在执行 linkBefore() 方法之前,会调用 node() 方法查找指定位置上的元素,这一步是需要遍历 LinkedList 的。如果插入的位置靠前前半段,就从队头开始往后找;否则从队尾往前找。也就是说,如果插入的位置越靠近 LinkedList 的中间位置,遍历所花费的时间就越多。

找到指定位置上的元素(参数succ)之后,就开始执行 linkBefore() 方法,先将 succ 的前一个节点(prev)存放到临时变量 pred 中,然后生成新的 Node 节点(newNode),并将 succ 的前一个节点变更为 newNode,如果 pred 为 null,说明插入的是队头,所以 first 为新节点;否则将 pred 的后一个节点变更为 newNode。

经过源码分析以后,你是不是在想:“好像 ArrayList 在新增元素的时候效率并不一定比 LinkedList 低啊!”

当两者的起始长度是一样的情况下:

  • 如果是从集合的头部新增元素,ArrayList 花费的时间应该比 LinkedList 多,因为需要对头部以后的元素进行复制。

我们来测试一下:

public class ArrayListTest {
    public static void addFromHeaderTest(int num) {
        ArrayList<String> list = new ArrayList<String>(num);
        int i = 0;

        long timeStart = System.currentTimeMillis();

        while (i < num) {
            list.add(0, i + "666");
            i++;
        }
        long timeEnd = System.currentTimeMillis();

        System.out.println("ArrayList从集合头部位置新增元素花费的时间" + (timeEnd - timeStart));
    }
}

/**
 * @author 
 */
public class LinkedListTest {
    public static void addFromHeaderTest(int num) {
        LinkedList<String> list = new LinkedList<String>();
        int i = 0;
        long timeStart = System.currentTimeMillis();
        while (i < num) {
            list.addFirst(i + "666");
            i++;
        }
        long timeEnd = System.currentTimeMillis();

        System.out.println("LinkedList从集合头部位置新增元素花费的时间" + (timeEnd - timeStart));
    }
}

num 为 10000,代码实测后的时间如下所示:

ArrayList从集合头部位置新增元素花费的时间595
LinkedList从集合头部位置新增元素花费的时间15

此时,ArrayList 花费的时间比 LinkedList 要多很多。

  • 如果是从集合的中间位置新增元素,ArrayList 花费的时间搞不好要比 LinkedList 少,因为 LinkedList 需要遍历。

来看测试代码。

public class ArrayListTest {
    public static void addFromMidTest(int num) {
        ArrayList<String> list = new ArrayList<String>(num);
        int i = 0;

        long timeStart = System.currentTimeMillis();
        while (i < num) {
            int temp = list.size();
            list.add(temp / 2, i + "666");
            i++;
        }
        long timeEnd = System.currentTimeMillis();

        System.out.println("ArrayList从集合中间位置新增元素花费的时间" + (timeEnd - timeStart));
    }
}

public class LinkedListTest {
    public static void addFromMidTest(int num) {
        LinkedList<String> list = new LinkedList<String>();
        int i = 0;
        long timeStart = System.currentTimeMillis();
        while (i < num) {
            int temp = list.size();
            list.add(temp / 2, i + "666");
            i++;
        }
        long timeEnd = System.currentTimeMillis();

        System.out.println("LinkedList从集合中间位置新增元素花费的时间" + (timeEnd - timeStart));
    }
}

num 为 10000,代码实测后的时间如下所示:

ArrayList从集合中间位置新增元素花费的时间16
LinkedList从集合中间位置新增元素花费的时间114

ArrayList 花费的时间比 LinkedList 要少很多很多。

  • 如果是从集合的尾部新增元素,ArrayList 花费的时间应该比 LinkedList 少,因为数组是一段连续的内存空间,也不需要复制数组;而链表需要创建新的对象,前后引用也要重新排列。

来看测试代码:

public class ArrayListTest {
    public static void addFromTailTest(int num) {
        ArrayList<String> list = new ArrayList<String>(num);
        int i = 0;

        long timeStart = System.currentTimeMillis();

        while (i < num) {
            list.add(i + "666");
            i++;
        }

        long timeEnd = System.currentTimeMillis();

        System.out.println("ArrayList从集合尾部位置新增元素花费的时间" + (timeEnd - timeStart));
    }
}

public class LinkedListTest {
    public static void addFromTailTest(int num) {
        LinkedList<String> list = new LinkedList<String>();
        int i = 0;
        long timeStart = System.currentTimeMillis();
        while (i < num) {
            list.add(i + "666");
            i++;
        }
        long timeEnd = System.currentTimeMillis();

        System.out.println("LinkedList从集合尾部位置新增元素花费的时间" + (timeEnd - timeStart));
    }
}

num 为 10000,代码实测后的时间如下所示:

ArrayList从集合尾部位置新增元素花费的时间69
LinkedList从集合尾部位置新增元素花费的时间193

ArrayList 花费的时间比 LinkedList 要少一些。

这样的结论和预期的是不是不太相符?ArrayList 在添加元素的时候如果不涉及到扩容,性能在两种情况下(中间位置新增元素、尾部新增元素)比 LinkedList 好很多,只有头部新增元素的时候比 LinkedList 差,因为数组复制的原因。

当然了,如果涉及到数组扩容的话,ArrayList 的性能就没那么可观了,因为扩容的时候也要复制数组。

04、删除元素时究竟谁快?

1)ArrayList

ArrayList 删除元素的时候,有两种方式,一种是直接删除元素(remove(Object)),需要直先遍历数组,找到元素对应的索引;一种是按照索引删除元素(remove(int))。

来看一下源码(其实前面也讲过了,这里温习一下):

public boolean remove(Object o) {
    final Object[] es = elementData; // 获取数组元素
    final int size = this.size; // 获取数组大小
    int i = 0;
    found: {
        if (o == null) {
            for (; i < size; i++)
                if (es[i] == null)
                    break found;
        } else {
            for (; i < size; i++)
                if (o.equals(es[i]))
                    break found;
        }
        return false;
    }
    fastRemove(es, i); // 调用 fastRemove 方法快速移除元素
    return true;
}

public E remove(int index) {
    Objects.checkIndex(index, size); // 检查索引是否越界
    final Object[] es = elementData; // 获取数组元素

    oldValue = (E) es[index]; // 获取要删除的元素
    fastRemove(es, index); // 调用 fastRemove 方法快速移除元素

    return oldValue; // 返回被删除的元素
}

本质上讲,两个方法是一样的,它们最后调用的都是 fastRemove(Object, int) 方法。

private void fastRemove(Object[] es, int i) {
    final int newSize;
    if ((newSize = size - 1) > i) // 如果要删除的不是最后一个元素
        System.arraycopy(es, i + 1, es, i, newSize - i); // 将要删除元素后面的元素向前移动一位
    es[size = newSize] = null; // 将最后一个元素置为 null,帮助垃圾回收
}

从源码可以看得出,只要删除的不是最后一个元素,都需要重新移动数组。删除的元素位置越靠前,代价就越大。

2)LinkedList

LinkedList 删除元素的时候,有四种常用的方式:

  • remove(int),删除指定位置上的元素
public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));
}

先检查索引,再调用 node(int) 方法( 前后半段遍历,和新增元素操作一样)找到节点 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; // 将要删除的节点的前驱节点置为 null,帮助垃圾回收
    }

    if (next == null) { // 如果要删除的节点是最后一个节点
        last = prev; // 将尾节点更新为要删除的节点的前驱节点
    } else {
        next.prev = prev; // 将要删除的节点的后继节点的前驱节点指向要删除的节点的前驱节点
        x.next = null; // 将要删除的节点的后继节点置为 null,帮助垃圾回收
    }

    x.item = null; // 将要删除的节点的元素置为 null,帮助垃圾回收
    size--; // 将链表的长度减一
    return element; // 返回被删除的元素
}
  • remove(Object),直接删除元素
public boolean remove(Object o) {
    if (o == null) { // 如果要删除的元素为 null
        for (LinkedList.Node<E> x = first; x != null; x = x.next) {
            if (x.item == null) { // 如果找到了要删除的节点
                unlink(x); // 调用 unlink 方法删除指定节点
                return true; // 返回删除成功
            }
        }
    } else {
        for (LinkedList.Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item)) { // 如果找到了要删除的节点
                unlink(x); // 调用 unlink 方法删除指定节点
                return true; // 返回删除成功
            }
        }
    }
    return false; // 没有找到要删除的节点,返回删除失败
}

也是先前后半段遍历,找到要删除的元素后调用 unlink(Node)。

  • removeFirst(),删除第一个节点
public E removeFirst() {
    final LinkedList.Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return unlinkFirst(f);
}

private E unlinkFirst(LinkedList.Node<E> f) {
    final E element = f.item; // 获取要删除的节点的元素
    final LinkedList.Node<E> next = f.next; // 获取要删除的节点的后继节点
    f.item = null; // 将要删除的节点的元素置为 null,帮助垃圾回收
    f.next = null; // 将要删除的节点的后继节点置为 null,帮助垃圾回收
    first = next; // 将头节点更新为要删除的节点的后继节点
    if (next == null) // 如果链表已经为空
        last = null; // 将尾节点置为 null
    else
        next.prev = null; // 将要删除的节点的后继节点的前驱节点置为 null,帮助垃圾回收
    size--; // 将链表的长度减一
    return element; // 返回被删除的元素
}

删除第一个节点就不需要遍历了,只需要把第二个节点更新为第一个节点即可。

  • removeLast(),删除最后一个节点

删除最后一个节点和删除第一个节点类似,只需要把倒数第二个节点更新为最后一个节点即可。

可以看得出,LinkedList 在删除比较靠前和比较靠后的元素时,非常高效,但如果删除的是中间位置的元素,效率就比较低了。

这里就不再做代码测试了,感兴趣的话可以自己试试,结果和新增元素保持一致:

  • 从集合头部删除元素时,ArrayList 花费的时间比 LinkedList 多很多;
  • 从集合中间位置删除元素时,ArrayList 花费的时间比 LinkedList 少很多;
  • 从集合尾部删除元素时,ArrayList 花费的时间比 LinkedList 少一点。

我本地的统计结果如下所示,可以作为参考:

ArrayList从集合头部位置删除元素花费的时间380
LinkedList从集合头部位置删除元素花费的时间4
ArrayList从集合中间位置删除元素花费的时间381
LinkedList从集合中间位置删除元素花费的时间5922
ArrayList从集合尾部位置删除元素花费的时间8
LinkedList从集合尾部位置删除元素花费的时间12

05、遍历元素时究竟谁快?

1)ArrayList

遍历 ArrayList 找到某个元素的话,通常有两种形式:

  • get(int),根据索引找元素
public E get(int index) {
    Objects.checkIndex(index, size);
    return elementData(index);
}

由于 ArrayList 是由数组实现的,所以根据索引找元素非常的快,一步到位。

  • indexOf(Object),根据元素找索引
public int indexOf(Object o) {
    return indexOfRange(o, 0, size);
}

int indexOfRange(Object o, int start, int end) {
    Object[] es = elementData; // 获取 ArrayList 中的元素数组
    if (o == null) { // 如果要查找的元素为 null
        for (int i = start; i < end; i++) {
            if (es[i] == null) { // 如果找到了要查找的元素
                return i; // 返回元素在 ArrayList 中的索引
            }
        }
    } else {
        for (int i = start; i < end; i++) {
            if (o.equals(es[i])) { // 如果找到了要查找的元素
                return i; // 返回元素在 ArrayList 中的索引
            }
        }
    }
    return -1; // 没有找到要查找的元素,返回 -1
}

根据元素找索引的话,就需要遍历整个数组了,从头到尾依次找。

2)LinkedList

遍历 LinkedList 找到某个元素的话,通常也有两种形式:

  • get(int),找指定位置上的元素
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

既然需要调用 node(int) 方法,就意味着需要前后半段遍历了。

  • indexOf(Object),找元素所在的位置
public int indexOf(Object o) {
    int index = 0; // 初始化索引为 0
    if (o == null) { // 如果要查找的元素为 null
        for (LinkedList.Node<E> x = first; x != null; x = x.next) { // 从头节点开始遍历链表
            if (x.item == null) // 如果找到了要查找的元素
                return index; // 返回元素在 LinkedList 中的索引
            index++; // 索引加一
        }
    } else {
        for (LinkedList.Node<E> x = first; x != null; x = x.next) { // 从头节点开始遍历链表
            if (o.equals(x.item)) // 如果找到了要查找的元素
                return index; // 返回元素在 LinkedList 中的索引
            index++; // 索引加一
        }
    }
    return -1; // 没有找到要查找的元素,返回 -1
}

需要遍历整个链表,和 ArrayList 的 indexOf() 类似。

那在我们对集合遍历的时候,通常有两种做法,一种是使用 for 循环,一种是使用迭代器(Iterator)。

如果使用的是 for 循环,可想而知 LinkedList 在 get 的时候性能会非常差,因为每一次外层的 for 循环,都要执行一次 node(int) 方法进行前后半段的遍历。

LinkedList.Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) { // 如果要查找的元素在链表的前半部分
        LinkedList.Node<E> x = first; // 从头节点开始遍历链表
        for (int i = 0; i < index; i++) // 循环查找元素
            x = x.next;
        return x; // 返回要查找的元素节点
    } else { // 如果要查找的元素在链表的后半部分
        LinkedList.Node<E> x = last; // 从尾节点开始遍历链表
        for (int i = size - 1; i > index; i--) // 循环查找元素
            x = x.prev;
        return x; // 返回要查找的元素节点
    }
}

那如果使用的是迭代器呢?

LinkedList<String> list = new LinkedList<String>();
for (Iterator<String> it = list.iterator(); it.hasNext();) {
    it.next();
}

迭代器只会调用一次 node(int) 方法,在执行 list.iterator() 的时候:先调用 AbstractSequentialList 类的 iterator() 方法,再调用 AbstractList 类的 listIterator() 方法,再调用 LinkedList 类的 listIterator(int) 方法,如下图所示。

最后返回的是 LinkedList 类的内部私有类 ListItr 对象:

public ListIterator<E> listIterator(int index) {
    checkPositionIndex(index); // 检查索引是否在有效范围内
    return new LinkedList.ListItr(index); // 创建一个新的 ListItr 对象并返回
}

private class ListItr implements ListIterator<E> {
    private LinkedList.Node<E> lastReturned; // 上一个已返回的节点
    private LinkedList.Node<E> next; // 下一个节点
    private int nextIndex; // 下一个节点的索引
    private int expectedModCount = modCount; // 链表被修改的次数

    ListItr(int index) {
        // assert isPositionIndex(index);
        next = (index == size) ? null : node(index); // 如果 index 等于 size,next 为 null,否则返回 node(index)
        nextIndex = index; // 设置 nextIndex 为 index
    }

    public boolean hasNext() {
        return nextIndex < size; // 如果下一个节点的索引小于链表的长度,返回 true,否则返回 false
    }

    public E next() {
        checkForComodification(); // 检查链表是否已经被修改
        if (!hasNext()) // 如果没有下一个节点,抛出 NoSuchElementException 异常
            throw new NoSuchElementException();

        lastReturned = next; // 将下一个节点设置为上一个已返回节点
        next = next.next; // 将下一个节点设置为当前节点的下一个节点
        nextIndex++; // 将下一个节点的索引增加 1
        return lastReturned.item; // 返回上一个已返回节点的元素
    }
}

执行 ListItr 的构造方法时调用了一次 node(int) 方法,返回第一个节点。在此之后,迭代器就执行 hasNext() 判断有没有下一个,执行 next() 方法下一个节点。

由此,可以得出这样的结论:遍历 LinkedList 的时候,千万不要使用 for 循环,要使用迭代器。

也就是说,for 循环遍历的时候,ArrayList 花费的时间远小于 LinkedList;迭代器遍历的时候,两者性能差不多。

06、两者的使用场景

当需要频繁随机访问元素的时候,例如读取大量数据并进行处理或者需要对数据进行排序或查找的场景,可以使用 ArrayList。例如一个学生管理系统,需要对学生列表进行排序或查找操作,可以使用 ArrayList 存储学生信息,以便快速访问和处理。

当需要频繁插入和删除元素的时候,例如实现队列或栈,或者需要在中间插入或删除元素的场景,可以使用 LinkedList。例如一个实时聊天系统,需要实现一个消息队列,可以使用 LinkedList 存储消息,以便快速插入和删除消息。

在一些特殊场景下,可能需要同时支持随机访问和插入/删除操作。例如一个在线游戏系统,需要实现一个玩家列表,需要支持快速查找和遍历玩家,同时也需要支持玩家的加入和离开。在这种情况下,可以使用 LinkedList 和 ArrayList 的组合,例如使用 LinkedList 存储玩家,以便快速插入和删除玩家,同时使用 ArrayList 存储玩家列表,以便快速查找和遍历玩家。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/516958.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

FISCO BCOS(三十六)———Python Sdk window环境部署及应用开发

1、环境要求 Python环境:python 3.6.3, 3.7.3(最好是3.7.3,因为我是) FISCO BCOS节点:可以直接建链,可以是节点前置,也可以是一键部署2、安装python 2.1、python下载地址 https://www.python.org/downloads/release/python-373/2.2、环境变量配置 3、下载pip 3.1、pip下…

【企业信息化】第1集 世界排名第一的免费开源ERP: Odoo 16 CRM客户关系管理系统

文章目录 前言一、概览二、使用功能1.加快销售速度2.销售线索3.机会4.客户5.高效沟通6.报告7.集成 三、总结 前言 世界排名第一的免费开源ERP: Odoo 16 CRM客户关系管理系统。真正以客户为中心的CRM。 一、概览 获得准确预测 使用可操作数据&#xff0c;以做出更好的决定。 获…

BUUFCTF—[极客大挑战 2019]Upload 1

考点&#xff1a; 文件上传黑名单绕过内容绕过目录浏览 文件上传 1. 测试是黑名单还是白名单 上传图片1.png成功&#xff0c;上传1.php失败&#xff0c;1.pngs成功&#xff0c;说明是黑名单过滤。 所以要寻找黑名单外的php文件&#xff1a;php, php3, php5, php7, phtml都能…

Linux基础学习---4、文件权限类、搜索查找类、压缩和解压类

1、文件权限类 1.1 文件属性 Linux系统是一种典型的多用户系统&#xff0c;不同的系统处于不同的地位&#xff0c;拥有不同的权限。为了保护系统的安全性&#xff0c;Linux系统对不同的用户访问访问同一文件&#xff08;包括目录文件&#xff09;的权限做了不同的规定。在Lin…

港联证券|北交所行情活跃 近九成个股5月以来录得上涨

北交所A股昨日表现活跃&#xff0c;北证50指数上涨1.92%。数字人30cm涨停&#xff0c;云创数据涨18.38%&#xff0c;流金科技涨16.61%&#xff0c;广道数字、艾融软件、惠丰钻石等涨幅居前。 数字人昨日录得近16个月以来最大涨幅。近3个交易日&#xff0c;数字人累计获得融资净…

微服务架构下网关的技术选型

一、简介 当使用单体应用程序架构时&#xff0c;客户端&#xff08;Web 或移动端&#xff09;通过向后端应用程序发起一次 REST 调用来获取数据。负载均衡器将请求路由给 N 个相同的应用程序实例中的一个。然后应用程序会查询各种数据库表&#xff0c;并将响应返回给客户端。微…

ant-design实现树的穿梭框,穿梭后右侧是已选树(一)

主要内容: 基于ant-design树的穿梭框&#xff0c;实现穿梭后右侧是已选树&#xff0c;&#xff08;当前antd右侧只有一个层级&#xff09; 理想的树的穿梭框&#xff1a; 左边是完整的树&#xff0c;右边是已选的树&#xff0c;左边已选穿梭到右边左边已选的消失&#xff0c;右…

LeetCode-206. 反转链表

目录 双指针递归 题目来源 206. 反转链表 双指针 定义两个指针&#xff1a; pre 和 cur&#xff1b;pre 在前 cur 在后。 每次让 cur 的 next 指向 pre&#xff0c;实现一次局部反转 局部反转完成之后&#xff0c;pre 和 cur 同时往前移动一个位置 循环上述过程&#xff0c;直…

AWS S3 跨账号迁移

目录 **迁移架构&#xff1a;****具体实施&#xff1a;****1. 在目标账号创建策略&#xff08;S3MigrationPolicy&#xff09;和角色&#xff08;S3MigrationRole&#xff09;****2. 安装 aws cli&#xff0c;并配置$ aws configure&#xff0c;**[请参阅 AWS CLI 文档中的安装…

C++回调函数

回调函数 文章目录 回调函数一、函数指针二、回调函数应用Reference 一、函数指针 指针是一个变量&#xff0c;是用来指向内存地址。 一个程序运行时&#xff0c;所有和运行相关的东西都需要加载到内存当中&#xff0c;因此可以通过指针指向该内存。 函数是存放在内存代码区…

Otter CTF--Network(web网络1-3)

目录 一.题目 1.Birdmans Data 2.Look At Me 3.Otter Leak 一.题目 网址 OtterCTF 1.Birdmans Data 下载文件&#xff1a; .pcap文件 Wireshark打开&#xff1a; 既然是web 我们就从http分析&#xff1a; 追踪流 HTTP流&#xff1a; 发现两个密钥key&#xff1a; {"…

数据集 | 基于语音(Speech)/多模态(Multimodal)的情绪识别数据集,格式及下载

本文主要介绍了一些常用的语音&#x1f5e3;识别数据集&#xff0c;文件格式以及下载地址&#xff1a; 目录 1.IEMOCAP Emotion Speech Database(English) 2.Emo-DB Database(German) 文件命名 对象 3.Ryerson Audio-Visual Database of Emotional Speech and Song (Engli…

解密JS代码:一个有趣的故事

作为一名前端开发者&#xff0c;我们经常需要处理加密和解密的任务。近日&#xff0c;我遇到了一个有趣的故事和一个需要解密的JavaScript代码。让我和你分享一下这个故事以及我是如何解密这段代码的。 最近我收到了一个任务&#xff0c;要将一个网站上的一段JavaScript代码进…

torch_geometric获取datasets(解决连不了外网的问题)

文章目录 1. torch_geometric.data介绍2. 使用Planetoid下载Cora数据集的代码3. 解决程序运行的机器无法联网的问题3.1 尝试运行&#xff0c;查看数据集下载链接3.2 放置到对应文件夹下3.3 重新运行之前写的程序 4. 一点感慨 1. torch_geometric.data介绍 torch_geometric&…

进货商模式玩法解析:当老板、亲自进货、自己赚差价?

如今很多人都看到互联网的发展前景&#xff0c;有了创业的想法&#xff0c;但是资金、技术、市场等问题给他们带来了瓶颈。进货商模式的出现&#xff0c;为这些&#xff08;文章编辑ycy6221&#xff09;有创业想法&#xff0c;有梦想的人打破了这些限制&#xff0c;而且还可以实…

HTTP协议演进:为什么说HTTP/1.1的时代已经过去了

前言 欢迎来到今天的每日一题&#xff0c;每日一提。昨天聊到了&#xff0c;HTTP 是什么。有哪些组成部分。并且最后提到了 HTTP 的一些缺点&#xff0c;比如&#xff1a;性能较低&#xff0c;容易导致网络拥塞和延迟&#xff0c;不支持服务器推送等等。设计协议的大佬们&#…

ChatGPT实现安全漏洞检查

安全漏洞检查 几乎每一项新技术的出现&#xff0c;都会首先被运用在安全领域&#xff0c;ChatGPT 也不例外。在 ChatGPT 出现的第一时间&#xff0c;利用 ChatGPT 编写钓鱼邮件&#xff0c;进行社会工程学攻击&#xff0c;辅助分析代码漏洞等等就成为热门话题。其实技术只是工…

快速上手Vite 配置指南

&#x1f482; 个人网站:【紫陌】【笔记分享网】 &#x1f485; 想寻找共同学习交流、共同成长的伙伴&#xff0c;请点击【前端学习交流群】 文章最后有作者l联系方式&#xff08;备注进群&#xff09; 1.认识vite 什么是vite呢&#xff1f; 官方的定位&#xff1a;下一代前端…

《水经注地图服务》数据源说明

&#xff08;本文首发于“水经注GIS”公号&#xff0c;关注公号免费领取地图数据&#xff09; 《水经注地图服务》&#xff08;WeServer&#xff09;是一款可快速发布全国乃至全球海量卫星影像的地图发布服务产品&#xff0c;该产品完全遵循OGC相关协议标准&#xff0c;是一个…

老板给情人转166万,妻子起诉后追回,网友:这是被白嫖三年

作为一个已婚男人&#xff0c;戚某在婚姻关系中背离了对妻子的忠诚&#xff0c;与小自己14岁的女员工小汪发展出不正当的男女关系。而小汪&#xff0c;在明知对方已婚的情况下&#xff0c;仍然选择继续纠缠&#xff0c;最终付出了惨痛的代价。 据了解&#xff0c;戚某在上海经营…