上一篇文章我们介绍了Hashmap,今天来看一看List接口下最主要的两个类 : ArrayList和LinkedList

思维导图

List

ArrayList

链表与数组

从名字就可以看出来,ArrayList使用的是数组,LinkedList使用的是链表.

  • 数组在内存中是地址连续的,而链表是离散的,每个节点都需要记录其后继节点(双向链表同时需要记录前节点)
  • 数组只要知道了其中一个节点的地址,就能通过计算相对地址来访问其他节点,因此数组可以实现任意访问(ramdaomly access),适合读操作比较多的场景.
  • 访问链表中的任意节点都需要从头节点开始遍历,逐一获取其后续节点,因此链表的写操作比较慢.但是也由于地址不连续,在增删节点时不需要考虑位置,直接修改相关的next节点就行了,因此链表适合频繁增删的场景.

链表与数组各有优点和缺点,这也是我们系统设计的一个特点 : 没有完美的方案,有的只是根据业务场景做出取舍,要么空间换时间,要么牺牲效率来提高安全性.

数据结构

就是一个名叫elementData的数组,默认的初始长度是10;

 transient Object[] elementData; // non-private to simplify nested class access

 /**
  * Default initial capacity.
  */
 private static final int DEFAULT_CAPACITY = 10;

初始化

ArrayList有三个构造方法:

无参时设置为空数组,直到第一次往list里面add数据才会初始化为长度为10的数组.这里的相关逻辑有几层调用链,后面扩容的时候再详细展开

    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;//设置成空数组
    }

private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

参数为集合时将其转化为数组:

    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // defend against c.toArray (incorrectly) not returning Object[]
            // (see e.g. https://bugs.openjdk.java.net/browse/JDK-6260652)
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

当参数为一个整型,也就是指定数组长度时,新建一个指定长度的数组

    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;//当传入的是0,处理方法和无参构造的一致
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

Add

由于list的get,set都太简单,这里就直接跳过来分析add了.和map一样,add也可能触发扩容.

    private void add(E e, Object[] elementData, int s) {
        if (s == elementData.length)//扩容条件,当数组满了的时候进行扩容
            elementData = grow();
        elementData[s] = e;
        size = s + 1;
    }

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

    public void add(int index, E element) {
        rangeCheckForAdd(index);
        modCount++;
        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;
    }
  • add之前会检查数组当前的容量,看看需不需要扩容.
  • 由于list不像map那样复杂,一个位置只有一个元素,所以扩容条件只有一个: 数组已满
  • 由于数组是连续的,如果是在指定位置插入,那么就需要复制一个新的数组来操作了.

也就是说,如果数组已满,且你又要再指定位置插入一个数据,那么数组将被复制两次.

一个小坑

来看一段代码

  ArrayList<Integer> list=new ArrayList<>(10);
  list.set(5,4);

这段代码可以正常运行吗?

可能有人会说list的大小是10,那我10以内比如往5的位置set个值应该没问题吧.

实际上会报一个数组越界的异常.

不过这并不算真正的越界,我们的数组长度还是10.

    public E set(int index, E element) {
        Objects.checkIndex(index, size);//边界选用的是size而不是length
        E oldValue = elementData(index);
        elementData[index] = element;
        return oldValue;
    }

    @HotSpotIntrinsicCandidate
    public static <X extends RuntimeException>
    int checkIndex(int index, int length,
                   BiFunction<String, List<Integer>, X> oobef) {
        if (index < 0 || index >= length)
            throw outOfBoundsCheckIndex(oobef, index, length);
        return index;
    }

由于list里面判断边界用的是size(实际节点个数),而不是length(数组长度),所以这种情况会报一个"越界".

这种写法实际上是把list当成数组来用了.

官方认为虽然elementData是list的实现细节,但是它对外界是隐藏的,只能操作逻辑上的list而不是实际存在的elementData.

封装的一大意义,就是向用户屏蔽用户不需要的操作.否则,用户可能会有意无意地调用这些操作,这将成为软件工程中重要的bug来源.

如果list里面还没有元素,那么你只能add,不能get,set.

如果list的size是5,那么你就不能访问第五个元素之后的位置.

list的add还没走到后面的位置,你就不能提前去put或者set.

扩容

在list里面,扩容的函数是grow().

    private Object[] grow(int minCapacity) {
        return elementData = Arrays.copyOf(elementData,
                                           newCapacity(minCapacity));
    }

    private Object[] grow() {
        return grow(size + 1);//扩容后数组长度最少+1
    }

这里调用了Arrays.copyOf(),这个方法的作用就是将旧数组的内容复制到新的数组,新数组多出来的部分置为null.

里面是个native方法,源码就不贴了.

这里还调用了newCapacity(),这个方法用于判断新数组的长度.

    private int newCapacity(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);//新数组长度为原来的1.5倍,向下取整
        if (newCapacity - minCapacity <= 0) {
            if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)//初始化,数组为空,则长度设为10
                return Math.max(DEFAULT_CAPACITY, minCapacity);
            if (minCapacity < 0) // overflow
                throw new OutOfMemoryError();
            return minCapacity;
        }
        return (newCapacity - MAX_ARRAY_SIZE <= 0)
            ? newCapacity
            : hugeCapacity(minCapacity);
    }

这里关键的两条表达式:

  • newCapacity = oldCapacity + (oldCapacity » 1)
  • if (newCapacity - minCapacity <= 0)

什么情况下第二条表达式为true呢?

  • 当oldCapacity=0,minCapacity=0+1=1,newCapacity=0+0=0,那么newCapacity - minCapacity=-1 < 0.这对应的是数组为空的情况,也就是上面我们说的无参构造方法会走到这条逻辑,将数组初始化长度为10.
  • 当0<oldCapacity<4时,newCapacity - minCapacity依然小于0,这种情况扩容后数组长度+1.
  • 其余情况newCapacity - minCapacity>0,此时新数组长度=newCapacity,也就是原来的1.5倍(向下取整)

看到这里,也许你会默默总结 :

哦,原来扩容后,除了初始化外,数组的长度有两种情况,一种是+1,一种是+50%

其实当原长度=2或3时,扩容后它们虽然也是长度+1,但是1也是2和3的50%(向下取整).

所以看到没有,如果让我们来写这段逻辑,可能我们的思路是这样:

  • if 数组长度=0, then….

  • if 数组长度=1, then…

而JDK的大神们用一两条表达式就把各种情况清清楚楚地分隔开了.不禁膜拜……

缩容

ArrayList没有自动缩容的机制,不过它提供了一个方法把list的容量最小化(刚好装得下所有元素)

    public void trimToSize() {
        modCount++;
        if (size < elementData.length) {
            elementData = (size == 0)
              ? EMPTY_ELEMENTDATA
              : Arrays.copyOf(elementData, size);//同样是复制
        }
    }

Remove

remove

和add一样,remove一样需要通过复制数组来进行.

但有一点不一样,add不指定位置时,默认插入到末尾,此时不需要复制.

而remove不指定位置时,默认从头开始找,然后删除找到的一个元素.那么删除的元素位置不确定,所以都当成删除中间的节点来处理,因此不论哪种情况都需要复制数组.

也许有人会建议作者找到对应元素后判断一下是否末尾元素,如果是直接置null不就节省了一次复制的操作了?

我以为,在实际情况中,要删除的节点恰好位于末尾是小概率事件,为了这小概率事件而每次都额外判断一次恐怕划不来.

删除指定位置:

    public E remove(int index) {
        Objects.checkIndex(index, size);
        final Object[] es = elementData;

        @SuppressWarnings("unchecked") E oldValue = (E) es[index];
        fastRemove(es, index);

        return oldValue;
    }

根据**equals()**删除指定元素

    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)//null也可以删,都是删除找到的第一个
                        break found;
            } else {
                for (; i < size; i++)
                    if (o.equals(es[i]))
                        break found;
            }
            return false;
        }
        fastRemove(es, i);
        return true;
    }
    private void fastRemove(Object[] es, int i) {
        modCount++;
        final int newSize;
        if ((newSize = size - 1) > i)
            System.arraycopy(es, i + 1, es, i, newSize - i);//复制到新数组
        es[size = newSize] = null;//最后一个位置设为null
    }

简单概括下流程:

  • 先遍历去查找元素,元素是null则找null,元素非null则调用equals()来比较.
  • 删除找到的第一个元素,然后剩余元素复制到新的数组.
  • 新数组的最后一个位置设为null

深克隆/浅克隆

这个在Hashmap那篇文章忘了讲,在这里补回来.

先看下定义:

浅克隆

被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。换言之,浅复制仅仅复制所拷贝的对象,而不复制它所引用的对象。

深克隆

被复制对象的所有变量都含有与原来的对象相同的值,除去那些引用其他对象的变量。那些引用其他对象的变量将指向被复制过的新对象,而不再是原有的那些被引用的对象。换言之,深复制把要复制的对象所引用的对象都复制了一遍。

其实它们的区别也就是值传递和引用传递的区别.

浅克隆=引用传递 深克隆=值传递

在我印象中,JDK好像没有几个类是实现深克隆的,所以如果不想复制后的新对象与旧对象相互干扰,在复制之前要重写clone()方法.

ArrayList的克隆方法

    /**
     * Returns a shallow copy of this {@code ArrayList} instance.  (The
     * elements themselves are not copied.)
     *
     * @return a clone of this {@code ArrayList} instance
     */    
	public Object clone() {
        try {
            ArrayList<?> v = (ArrayList<?>) super.clone();
            v.elementData = Arrays.copyOf(elementData, size);
            v.modCount = 0;
            return v;
        } catch (CloneNotSupportedException e) {
            // this shouldn't happen, since we are Cloneable
            throw new InternalError(e);
        }
    }

注释写的很明白了,是浅复制(shallow copy).除了modCount置0之外,数组也是直接复制过去的.

如果改成深克隆的话,应该要遍历数组,依次调用每个元素的clone方法而不是直接复制.(要注意套娃的情况)

LinkedList

数据结构

LinkedList有一个内部类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;
        }
    }

很典型的双向链表了.

此外LinkedList内部也持有头尾双指针.

由于链表不存在扩容的概念,因此size是链表的长度也是实际元素的个数.

    transient int size = 0;

    /**
     * Pointer to first node.
     */
    transient Node<E> first;

    /**
     * Pointer to last node.
     */
    transient Node<E> last;

初始化

来看一下构造函数

    public LinkedList() {
    }

    public LinkedList(Collection<? extends E> c) {
        this();
        addAll(c);
    }

空的.

没错是空的,它不需要像ArrayList先构造出一个数组那样现去构造出first节点.

first是null的就让它暂时null吧,等到add第一个元素再去构造.

Add

  public boolean add(E e) {//插入一个节点,默认插入链尾
        linkLast(e);
        return true;
    }

    public void add(int index, E element) {//往特定位置插入
        checkPositionIndex(index);

        if (index == size)
            linkLast(element);
        else
            linkBefore(element, node(index));
    }

    public void addFirst(E e) {//往头部插入
        linkFirst(e);
    }

    public void addLast(E e) {往尾部插入
        linkLast(e);
    }

可以看到LinkedList支持往任意位置插入元素.

insert for LinkedList

根据linkFirst,linkBefore,linkLast,插入可分为3种情况:

  • 插入头部
  • 插入到某元素前面
  • 插入尾部
    private void linkFirst(E e) {
        final Node<E> f = first;
        final Node<E> newNode = new Node<>(null, e, f);//小细节,这里是新建一个node而不是直接赋值
        first = newNode;//new节点变为first节点
        if (f == null)//链表是空的,那么first节点和last节点都同时为new节点
            last = newNode;
        else
            f.prev = newNode;//原first节点的前驱变为new节点
        size++;
        modCount++;
    }
    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;//该节点的前驱为new节点
        if (pred == null)//该节点的前驱为null,也就是该节点为first节点
            first = newNode;
        else
            pred.next = newNode;//前驱的next指向新节点
        size++;
        modCount++;
    }
    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++;
    }

常规的链表操作了,唯一需要注意的是 : 插入的不是节点本身,而是它的复制.

应该说,这里用到的只是这个节点的值,而不需要知道它前驱节点以及后继节点的信息.

所以对链表任意操作并不会影响插入时参考过的那些节点.

Remove

    public E remove() {//默认删除头节点
        return removeFirst();
    }

    public E removeFirst() {
        final Node<E> f = first;
        if (f == null)//链表为空时抛出异常
            throw new NoSuchElementException();
        return unlinkFirst(f);
    }

    public E removeLast() {//删除尾节点
        final Node<E> l = last;
        if (l == null)//链表为空时抛出异常
            throw new NoSuchElementException();
        return unlinkLast(l);
    }

    public E remove(int index) {//删除指定位置的节点
        checkElementIndex(index);
        return unlink(node(index));//node(index)作用是返回index位置的节点
    }

    public boolean remove(Object o) {//删除找到的第一个指定的元素
        if (o == null) {
            for (Node<E> x = first; x != null; x = x.next) {
                if (x.item == null) {
                    unlink(x);
                    return true;
                }
            }
        } else {
            for (Node<E> x = first; x != null; x = x.next) {
                if (o.equals(x.item)) {
                    unlink(x);
                    return true;
                }
            }
        }
        return false;
    }

删除也是支持指定任意位置,任意元素的.

remove for LinkedList

删除同样分为三种情况:

  • 删除头节点
  • 删除尾节点
  • 删除中间节点
    private E unlinkFirst(Node<E> f) {
        // assert f == first && f != null;
        final E element = f.item;//记录返回值
        final Node<E> next = f.next;//记录后驱
        f.item = null;//释放节点的资源, help GC就很骚了....
        f.next = null; // help GC
        first = next;//first节点后移
        if (next == null)
            last = null;//若链表只有一个first节点,那么删除后last也为null
        else
            next.prev = null;//否则next的前驱置为null,这里也可以写成first.prev=null
        size--;
        modCount++;
        return element;
    }
    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;
    }
    E unlink(Node<E> x) {
        // assert x != null;
        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;
        }

        if (next == null) {
            last = prev;
        } else {
            next.prev = prev;
            x.next = null;
        }

        x.item = null;
        size--;
        modCount++;
        return element;
    }

链表的操作也很简单,就不写那么多注释了(为偷懒找个接口).

“二分查找”?

链表的存取很简单,本来想直接跳过的,不过这次看源码又新发现了一个小的优化点,就顺便提一下.

在上面remove操作时有个node(idnex),这个函数在get和set中也用到.

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

        if (index < (size >> 1)) {//判断index位于链表的前半部分还是后半部分
            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;
        }
    }

前面提到过,LinkedList是支持操作任意节点的.

而我们又知道,链表的节点地址是离散的,那么访问中间的节点一定要从first节点开始遍历吗?

由于是双向链表,JDK在访问Linkedlist前,会先用 if (index < (size >> 1))判断一下要访问的节点位于前半部分还是后半部分.

如果是前半部分,就从first节点向后遍历;

如果是后半部分,就从last节点向前遍历.

最好的情况下,这样的优化可以把时间从O(n)降低到O(1).

这个问题并不难,只要意识到这个问题都能回答上来.

问题只在于意识.

写这一部分是为了提醒自己在一些小细节上不要忘了优化的意识.

队列 & 双向队列 & 栈

LinkedList作为双向链表,可以同时作为队列/双向队列/栈来使用.

作为队列时:

  • peek : 返回头节点(并非删除)
  • poll : 返回并删除头节点(出队)
  • offer : 队尾新增节点(入队,等同于add)

作为双向队列时:

  • push : 队首新增节点(入栈)
  • pop : 返回并删除头节点(出栈)

还有很多api,像peekFirst,pollLast等等.不列举了.

api不用背的,用到的时候忘了,点进源码看下注释,一下就记起来了.

Clone

    /**
     * Returns a shallow copy of this {@code LinkedList}. (The elements
     * themselves are not cloned.)
     *
     * @return a shallow copy of this {@code LinkedList} instance
     */
    public Object clone() {
        LinkedList<E> clone = superClone();

        // Put clone into "virgin" state
        clone.first = clone.last = null;
        clone.size = 0;
        clone.modCount = 0;

        // Initialize clone with our elements
        for (Node<E> x = first; x != null; x = x.next)
            clone.add(x.item);

        return clone;
    }

和ArrayList一样,也是浅复制