JAVA常用数据容器——List(LinkedList、ArrayList)

因为一些失误,在这篇文章使用的是JDK11的代码进行分析的。JDK11和JDK8相比原理并没有太多改变,但是代码倒是简洁不少。

LinkedList

经典的双链表结构, 适用于乱序插入, 删除. 指定序列操作则性能不如ArrayList, 这也是其数据结构决定的

关于链表可以查看链表、栈、队列

java实现原理

public class LinkedList<E> extends AbstractSequentialList<E> implements List<E>, Deque<E>, Cloneable, Serializable {
    transient int size;
    transient LinkedList.Node<E> first;
    transient LinkedList.Node<E> last;
    private static final long serialVersionUID = 876323262645176354L;

    public LinkedList() {
        this.size = 0;
    }
}

可以看到JDK中实现LinkedList的代码中维护了三个参数分别对应

  1. List的长度
  2. 头部节点
  3. 尾部节点

通过维持头尾节点我们可以从这两个节点中的一个进行遍历从而查询出所有的数据。

LinkedList.Node

为其内部类

    private static class Node<E> {
        E item;
        LinkedList.Node<E> next;
        LinkedList.Node<E> prev;

        Node(LinkedList.Node<E> prev, E element, LinkedList.Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

其提供了有参的构造函数,可以传入当前元素和下一元素构造新的节点,或者传递下一节点之间完成节点的关联。

所以这里可以看到,JDK中实现的链表实际上是双向的,既可以从前向后也可以反向。

基础操作

新增修改
JDK中LinkedList新增元素的代码
    public boolean add(E e) {
        this.linkLast(e);
        return true;
    }
    
    void linkLast(E e) {
        LinkedList.Node<E> l = this.last;
        LinkedList.Node<E> newNode = new LinkedList.Node(l, e, (LinkedList.Node)null);
        this.last = newNode;
        if (l == null) {
            this.first = newNode;
        } else {
            l.next = newNode;
        }

        ++this.size;
        ++this.modCount;
    }

从上面代码我们可以分析出来,LinkedList新增元素会使用addFirst方法,默认保存在尾部。

  1. 首先根据新的元素构建一个新的Node。
  2. 然后将最后一个node指向为新的node
  3. 如果此时旧的node为空,则新的集合为空,新的node同时为开始和结束节点
  4. 如果此时旧的node不为空,则旧的node下一节点为新的node
  5. 同时更新size和modCount值
指定位置插入元素

根据之前链表的定义我们知道链表是支持在指定位置插入元素的。java同样提供了此方法

    public void add(int index, E element) {
        this.checkPositionIndex(index);
        if (index == this.size) {
            this.linkLast(element);
        } else {
            this.linkBefore(element, this.node(index));
        }
    }

在源码逻辑中,当index为集合长度的时候会使用之前的方法插入最后节点,否则会使用linkBefore方法。 此法是讲此节点添加至指定节点之前的位置。

    void linkBefore(E e, LinkedList.Node<E> succ) {
        LinkedList.Node<E> pred = succ.prev;
        LinkedList.Node<E> newNode = new LinkedList.Node(pred, e, succ);
        succ.prev = newNode;
        if (pred == null) {
            this.first = newNode;
        } else {
            pred.next = newNode;
        }

        ++this.size;
        ++this.modCount;
    }

主要修改了当前节点的前置节点内容

删除

其默认的移除策略是移除头部节点

    public E remove() {
        return this.removeFirst();
    }
    
        
    private E unlinkFirst(LinkedList.Node<E> f) {
        E element = f.item;
        LinkedList.Node<E> next = f.next;
        f.item = null;
        f.next = null;
        this.first = next;
        if (next == null) {
            this.last = null;
        } else {
            next.prev = null;
        }

        --this.size;
        ++this.modCount;
        return element;
    }

其最终使用的unlinkFirst的方法。主要进行的操作:

  1. 将头部节点修改为被移除节点的后续节点
  2. 修改List长度size,同时modCount数据++
查找
根据索引获得元素
    public E get(int index) {
        this.checkElementIndex(index);
        return this.node(index).item;
    }
    
    LinkedList.Node<E> node(int index) {
        LinkedList.Node x;
        int i;
        if (index < this.size >> 1) {
            x = this.first;

            for(i = 0; i < index; ++i) {
                x = x.next;
            }

            return x;
        } else {
            x = this.last;

            for(i = this.size - 1; i > index; --i) {
                x = x.prev;
            }

            return x;
        }
    }

上面首先验证索引是否超过List长度,然后再进行查询。在这里可以看到JAVA做了个优化。 ndex < this.size >> 1等价于ndex < this.size/2 在这里判断这个index是接近于头部还是接近于尾部,然后开始从尾部或者从头部循环获取指定索引的数据。

关于LinkedList

关于LinkedList我们看add的代码可以发现,在逻辑中,需要先构建一个节点NODE,然后将前后关系在进行维护起来,同时假如是从前或者尾部插入数据还需要修改内部维护的首尾节点的关系。但是NODE本身也会维护前后节点关系。在这些关系建立起来的过程中全程是没有加锁的。所以可以很明显的知道,LinkedList是没有实现线程安全的。

整体来说LinkedList是一个非常简单的数据容器。里面并没有太复杂的方法。而且因为链表结构并不涉及长度超出容量以及扩容等问题。

ArrayList

底层就是一个数组, 因此按序查找快,乱序插入,删除因为涉及到后面元素移位所以性能慢.

java实现原理

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, Serializable {
    private static final long serialVersionUID = 8683452581122892189L;
    private static final int DEFAULT_CAPACITY = 10;
    // 被用于空实例的共享空数组实例
    private static final Object[] EMPTY_ELEMENTDATA = new Object[0];
    // 被用于默认大小的空实例的共享数组实例。其与EMPTY_ELEMENTDATA的区别是:当我们向数组中添加第一个元素时,知道数组该扩充多少。
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = new Object[0];
    // 保存了添加到ArrayList中的元素。ArrayList的容量是该Object[]类型数组的长度
    transient Object[] elementData;
    // 数据的长度
    private int size;
    private static final int MAX_ARRAY_SIZE = 2147483639;
    
}

基础操作

新增

ArrayList提供了add(E e)add(int index, E element)方法新增数据。

add(E e)

    private void add(E e, Object[] elementData, int s) {
        if (s == elementData.length) {
        // 数据扩容
            elementData = this.grow();
        }

        elementData[s] = e;
        this.size = s + 1;
    }

    public boolean add(E e) {
        ++this.modCount;
        this.add(e, this.elementData, this.size);
        return true;
    }

直接添加元素逻辑比较简单,直接想List尾部添加数据,当前索引就是插入时集合长度。

但是在**add(int index, E element)**中因为涉及到部分节点索引的移动。就不可如此简单的添加。

    public void add(int index, E element) {
        this.rangeCheckForAdd(index);
        ++this.modCount;
        int s;
        Object[] elementData;
        if ((s = this.size) == (elementData = this.elementData).length) {
            elementData = this.grow();
        }

        System.arraycopy(elementData, index, elementData, index + 1, s - index);
        elementData[index] = element;
        this.size = s + 1;
    }

在逻辑中需要对数据元素进行拷贝,需要将index之后的元素向后拷贝一步,然后将index的元素指向要添加的元素。

容器扩容

在新增数据的时候,我们会发现,当判断长度错过最大长度的时候,会执行this.grow()方法,此方法就是容器扩容的时候进行的方法。

    private Object[] grow() {
        return this.grow(this.size + 1);
    }
    
    private Object[] grow(int minCapacity) {
        return this.elementData = Arrays.copyOf(this.elementData, this.newCapacity(minCapacity));
    }
    
    private int newCapacity(int minCapacity) {
        // minCapacity 需要扩容的最小长度
        int oldCapacity = this.elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity <= 0) {
            if (this.elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
                return Math.max(10, minCapacity);
            } else if (minCapacity < 0) {
                throw new OutOfMemoryError();
            } else {
                return minCapacity;
            }
        } else {
            return newCapacity - 2147483639 <= 0 ? newCapacity : hugeCapacity(minCapacity);
        }
    }
    

这里面有几个需要注意的:

首选,扩容的长度oldCapacity + (oldCapacity >> 1)。可以看到每次扩容的长度为之前容器长度的一半

其次,ArrayList并没有为容器设置长度,当新的元素添加的时候它会执行grow方法。但是原长度为0扩容后依旧为0。此时通过(this.elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)的判断来确定List是否为空集合,如果是则从10或者最低扩容长度中取一个最大值返回。

删除
    public E remove(int index) {
        Objects.checkIndex(index, this.size);
        Object[] es = this.elementData;
        E oldValue = es[index];
        this.fastRemove(es, index);
        return oldValue;
    }

    private void fastRemove(Object[] es, int i) {
        ++this.modCount;
        int newSize;
        if ((newSize = this.size - 1) > i) {
            System.arraycopy(es, i + 1, es, i, newSize - i);
        }

        es[this.size = newSize] = null;
    }

在删除操作的时候JAVA做了一些优化,但同时也是一个不知道其原理而出现使用问题的地方。当JAVA发现移除的元素不是最后一个元素的时候,它会将被移除的元素转移至最后一位然后进行移除。这样情况可以描述为当有5个元素的List中你移除第3个元素。此时索引为2的元素会被迁移至最后一位然后被移除,此时索引为2的位置依旧有元素,但是索引为4的位置元素却没有了(往前移动了)。

查找

相比LinkedList查询需要判断头尾。ArrayList就比较简单了,因为是基于数据保存数据的,所以提供索引后,只需要索引就可以获得结果。

    public E get(int index) {
        Objects.checkIndex(index, this.size);
        return this.elementData(index);
    }

关于ArrayList

关于ArrayList同样没有太过复杂的内容,但是对于面试中容器的扩容、以及移除元素时候因为内部的优化而产生一些看起来是正确实际上是错误的操作。这两块内容会是很多公司考察的内容。会报错的操作。

    public static void main(String[] args) {
        ArrayList arrayList = new ArrayList();
        arrayList.add("1");
        arrayList.add("2");
        arrayList.add("3");
        arrayList.add("4");
        arrayList.add("5");
        arrayList.remove(2);
        arrayList.remove(4);
    }

同样的ArrayList在多线程操作的过程中同样是不安全的,它在进行数据扩容后才会进行赋值,但是在此之前需要赋值的索引却早早被确定了。在多线程的时候扩容和对指定索引进行赋值的操作,都会是并发错误的地方。


个人水平有限,上面的内容可能存在没有描述清楚或者错误的地方,假如开发同学发现了,请及时告知,我会第一时间修改相关内容。假如我的这篇内容对你有任何帮助的话,麻烦给我点一个赞。你的点赞就是我前进的动力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大·风

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值