你真的了解ArrayList和LinkedList么?

本文深入探讨了ArrayList和LinkedList的数据结构、操作原理及线程安全性,对比了两者在查询、增删操作上的性能差异,同时介绍了几种线程安全的List实现。

在面试的过程中, 经常会有面试官问道, ArrayList是什么,ArrayList和LinkedList有什么区别? 相信每个人都知道,ArrayList的底层是数组,LinkedList的底层是链表, LinkedList增删快,ArrayList查询快;它们两个都是线程不安全的; 而随着学习的深入,我们不能只知其然而不知其所以然; 我们要常常要问自己为什么. 为什么ArrayList查询快但是增删慢, 为什么ArrayList线程不安全?
带着这些问题, 我们从源码中一个一个的寻找出答案;
先从ArrayList的构造方法看起:

    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

    transient Object[] elementData; 

    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

可以看到elementData 是一个Object类型的数组,它就是ArrayList中实际保存元素的地方;
当我们调用默认的空参构造方法时, 会将elementData 初始化成一个空数组,当传入一个数值型参数时,会初始化一个长度等于该值的数组, 这个地方就有人会有疑问了, 空数组怎么保存数据?不急, 我们接着往下看;
创建完ArrayList对象之后,我们自然是要调用add()方法添加参数了;因此, 我们来看add()方法:

ArrayList.add()

    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        //设置元素到数组中
        elementData[size++] = e;
        return true;
    }

    private void ensureCapacityInternal(int minCapacity) {
        //首次添加元素时,默认集合容量为10
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }

        ensureExplicitCapacity(minCapacity);
    }

private static final int DEFAULT_CAPACITY = 10;

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        //如果元素数量大于集合长度,则扩容
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);

	//扩容
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;//旧数组长度
        int newCapacity = oldCapacity + (oldCapacity >> 1);//容量扩大为原来的1.5倍; 右移一位相当于缩小一半
        //如果元素数量大于新容量,则将新容量设为元素数量
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        //将旧数组的元素复制到新数组中
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
    }


可以看到,在第一次添加元素时,如果数组为空, 则会默认初始化一个长度为10的数组; 当要新增的元素数量要大于数组长度时, ArrayList会重新创建一个长度是旧数组1.5倍的新数组,然后调用Arrays.copyOf(elementData, newCapacity)方法将旧数组的数据复制到新数组中;该过程通俗点讲,就是扩容; 扩容成功后,再将要添加的元素添加到新数组中; 这就是add()方法的整体流程; 从这里我们也能看出ArrayList能够动态扩容的原理了, 其实它就是在元素数量即将大于数组长度时,再创建新的数组来实现扩容的;

ArrayList.get(i)

    public E get(int index) {
        rangeCheck(index);

        return elementData(index);
    }

    private void rangeCheck(int index) {
        if (index >= size)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }

    E elementData(int index) {
        return (E) elementData[index];
    }

get()方法很简单, 先判断一下下标是否越界, 然后直接通过数组的索引获取对应的值

ArrayList.remove(int index)

    public E remove(int index) {
        rangeCheck(index);

        modCount++;
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
        //将index+1位置开始到末尾的元素向前移动一位,最后一位赋值为null,GC进行回收
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }

从代码中可以看到,remove中最重要的一部分就是System.arraycopy(elementData, index+1, elementData, index,numMoved);
该方法就是将index位置的后面的元素都向前移一位,从而达到了删除的作用;
如下一个数组, 假如我们要把元素c删掉:
在这里插入图片描述
则会将c元素后面的d,e,f元素都向前移一位,最终会达到这样的效果:

在这里插入图片描述

ArrayList.remove(Object o)

    public boolean remove(Object o) {
        if (o == null) {
            for (int index = 0; index < size; index++)
                if (elementData[index] == null) {
                    fastRemove(index);
                    return true;
                }
        } else {
            for (int index = 0; index < size; index++)
                if (o.equals(elementData[index])) {
                    fastRemove(index);
                    return true;
                }
        }
        return false;
    }

    private void fastRemove(int index) {
        modCount++;
        int numMoved = size - index - 1;//需要移动的元素数量
        if (numMoved > 0)
            //将index+1位置开始到末尾的元素向前移动一位,最后一位赋值为null,GC进行回收
            System.arraycopy(elementData, index+1, elementData, index, numMoved);
        elementData[--size] = null; // clear to let GC do its work
    }

remove(Object o)也是同理, 先判断元素是否存在,根据元素获取到对应的索引, 然后执行"删除"操作;
看了两个remove方法的源码,咱们来看看一道关于remove方法的面试题;
在这里插入图片描述
该程序输出多少呢?
有些人可能有疑问,调的是哪个remove方法呢?两个方法的执行结果是不一样的. 是[1,3]还是[2,3]呢?
答案是[1,3];
因为我们传入的参数1是一个int类型的参数, 因此实际调的是remove(int index)方法, 所以答案是[1,3];
那如果将题目改成这样呢?
在这里插入图片描述
显然, 答案就是[2,3]了, 因为包装类型Integer是一个对象, 所以实际调的是remove(Object o)方法, 所以答案自然就是[1,3]了
你答对了么?

ArrayList为什么线程不安全?

从add()方法以及remove()方法的源码中可以看到; 他们分别都有一个elementData[size++] = e和elementData[–size] = null的操作;
size是ArrayList类中的共享变量,代表ArrayList的长度;我们都知道size++和–size是一个非原子性的操作; 在多线程并发的时候, 会出现读到相同size值的情况; 比如原先size是0 ;有两个线程同时调用add方法给ArrayList添加元素, 他们两个读取到的size值都是0, 这时, 就会出现后面添加的元素被覆盖的情况,因为执行了两次elementData[0]=e;elementData[–size]也是同理;

看完了ArrayList,咱们接着来看看LinkedList

LinkedList()

    public LinkedList() {
    }

可以看到,LinkedList()默认初始化时,没有做任何操作

LinkedList.add()

    public boolean add(E e) {
        linkLast(e);
        return true;
    }

    void linkLast(E e) {
        final Node<E> l = last;
        //新创建一个节点,并将其前置指针指向原先的尾结点last
        final Node<E> newNode = new Node<>(l, e, null);
        //将新创建的节点设为尾结点
        last = newNode;
        //初始化,将新创建的节点设为首节点
        if (l == null)
            first = newNode;
        else
            //将原先的尾节点的后置指针指向新的尾结点(即新创建的节点)
            l.next = newNode;
        size++;
        modCount++;
    }

    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类就是双向链表的节点, 节点类中包含有指向前置节点的属性prev和指向后置节点的元素next, 还有实际保存的数据的元素item;
咱们通过图解来看LinkedList添加元素的过程;
首次添加元素, 创建一个头结点, 头结点的前置节点prev和后置节点next都指向null;
在这里插入图片描述
再次添加元素, 新建一个节点a, 其前置节点prev指向头结点并设为last节点, 再将头结点的next指向它
在这里插入图片描述
再次添加元素, 新建一个节点b,其前置节点prev指向a并设为last节点, 再将a的next指向它
在这里插入图片描述
以此类推;这就是LinkedList添加元素的流程

从这里有些人可能有些疑问, 为什么要用双向链表而不用单链表,单链表也能实现这样添加元素的操作吧.
没事,我们接着往下看LinkedList的get方法:

LinkedList.get(int index)

    public E get(int index) {
        //检查index是否溢出
        checkElementIndex(index);
        return node(index).item;
    }

    Node<E> node(int index) {
        //判断index是否小于size的一半,如果小于,
        // 则从头结点开始往链表后面找,如果大于则从尾结点往前面找
        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;
        }
    }

get方法很简单, 判断index是否小于size的二分之一, size>>1的意思是将size的二进制右移一位,相当于将size变为原来的二分之一;
如果小于,则从头结点开始往链表后面找,如果大于则从尾结点往前面找:
在这里插入图片描述
如图所示,size为5,当index=2时,小于5的二分之一, 则从first节点开始通过next往后, 直到查到b节点; 同理,当index>size/2时, 就从last节点通过prev一直往前遍历,直到找到index;
那么, 如果是单链表的时候, 是什么样子的呢?
在这里插入图片描述
如图,如果采用单链表的话, 要查询的时候只能通过first节点往后遍历查找数据, 当index值很大时, 例如要查找c节点,那么要遍历3次, 而双向链表遍历会从last节点开始往前找,只需要遍历1次; 从这里,你是否看出来为什么使用的是双向链表而不是单链表了么?其实就是为了加快查询的效率

LinkedList.remove(int index)

    public E remove(int index) {
        checkElementIndex(index);
        return unlink(node(index));
    }

    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;
        //prev为null说明当前节点是头结点
        if (prev == null) {
            //将当前节点的后置节点设为新的头结点
            first = next;
        } else {
            //将前置节点的后置节点指向当前线程的后置节点
            prev.next = next;
            //当前线程的前置节点设为null
            x.prev = null;
        }
        //当前节点是尾结点
        if (next == null) {
            //将当前节点的前置节点设为新的尾结点
            last = prev;
        } else {
            //将后置节点的前置节点指向当前线程的前置节点
            next.prev = prev;
            //当前线程的后置节点设为null
            x.next = null;
        }
        //清除当前节点
        x.item = null;
        
        size--;
        modCount++;
        return element;
    }

直接看注释的说明可能有点乱, 咱们结合图解一起说明;
比如当前,咱们要删除b节点:
在这里插入图片描述
第一步,先将b的prev清除, 将a的next指向c
在这里插入图片描述
第二步, 将b的next清除, 将c的prev指向a
在这里插入图片描述
第三步,清除b,删除成功
在这里插入图片描述
有两个特殊情况就是删除头结点和尾节点的时候;
比如现在再删除头结点first:
第一步, 将a设为新的first
在这里插入图片描述
第二步, next指向null, a(当前为first)的prev指向null
在这里插入图片描述
第三部 清除旧的first, 删除完成
在这里插入图片描述
删除尾结点同理

LinkedList为什么线程不安全?

咱们回到add()方法的源码:
在这里插入图片描述
在这里插入图片描述
结合图和代码我们可以看到, 现在, 线程1要插入b节点, 线程2要插入a节点,;在线程1执行到代码中的第2步之前,已经将b的prev指向了last,但是还未将b更新为新的last, 此时, 线程2插入a节点,获取到的last还是旧的last,因此,会将a的prev指向旧last; 最后, 会造成上图中的情况. 这自然是有问题的.

ArrayList和LinkedList的区别

  1. ArrayList底层是一个Object类型的数组, LinkedList底层是一个双向链表
  2. ArrayList查询效率比LinkedList快.为什么? 从两者的源码中就能看出, ArrayList可以通过索引,直接获取到索引对应的值;而 LinkedList需要从头结点或者尾结点向index进行遍历, 自然要比ArrayList查询慢了;
  3. LinkedList增删效率比ArrayList快. 为什么? 因为ArrayList在元素数量要大于集合容量时,会有一个扩容的操作,扩容会创建一个新的长度是原来1.5倍的数组, 再将旧数组的数据复制到新数组中; 在删除时,需要将删除的元素之后元素都向前移一位; 而LinkedList在添加元素时,只需要创建一个新的节点添加到链表的尾部, 在删除时,只需要执行一次查找的操作,再删除对应的节点; 综合起来,增删要比ArrayList快不少

ArrayList和LinkedList的相同点

  1. 都线程不安全
  2. 都属于List
  3. 都能存null值

有线程安全的List么

1. CopyOnWriteArrayList

CopyOnWrite: 顾名思义.写时复制;

    public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }

    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        //采用lock锁
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            //每次添加元素的时候都会新创建一个数组对象
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

    public E remove(int index) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            E oldValue = get(elements, index);
            int numMoved = len - index - 1;
            if (numMoved == 0)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                Object[] newElements = new Object[len - 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index + 1, newElements, index,
                                 numMoved);
                setArray(newElements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

    public E get(int index) {
        return get(getArray(), index);
    }

    private E get(Object[] a, int index) {
        return (E) a[index];
    }

从代码中也可以看到, CopyOnWriteArrayList在每次add新元素的时候,都会创建一个新的数组数组对象, 而且采用了 ReentrantLock锁加锁保证了增删元素时的线程安全; 但是我们可以看到, 其get方法是没有加锁的, 也因此, get方法并不能获取到实时add的数据;
因为get方法并没有加锁, 因此在并发环境下可能会存在以下三种情况:
1、如果写操作未完成,那么直接读取原数组的数据;
2、如果写操作完成,但是引用还未指向新数组,那么也是读取原数组数据;
3、如果写操作完成,并且引用已经指向了新的数组,那么直接从新数组中读取数据。

使用CopyOnWriteArrayList要注意的一些问题:

  • 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList
    能做到最终一致性,但是还是没法满足实时性要求
  • CopyOnWriteArrayList 合适读多写少的场景,慎用 ,因为没法保证CopyOnWriteArrayList
    到底要放置多少数据,万一数据稍微有点多,每次add/set都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。

2. Vector

    public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }

    private void ensureCapacityHelper(int minCapacity) {
        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

    public synchronized E get(int index) {
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);

        return elementData(index);

    public boolean remove(Object o) {
        return removeElement(o);
    }

    public synchronized boolean removeElement(Object obj) {
        modCount++;
        int i = indexOf(obj);
        if (i >= 0) {
            removeElementAt(i);
            return true;
        }
        return false;
    }
    }

可以看到, Vector的的实现方式和ArrayList基本上是一样的, 它所有的方法都采用了synchronized 关键字修饰, 从而保证了线程安全
那么, Vector除了线程安全,和ArrayList还有其他的区别么;
我们看Vector的构造方法:

    public Vector(int initialCapacity, int capacityIncrement) {
        super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        this.elementData = new Object[initialCapacity];
        this.capacityIncrement = capacityIncrement;
    }

可以看到,Vector比ArrayList多了一个capacityIncrement 变量, 这个变量是用来设置每次扩容的数量的, 那如果没设置该值时, 默认的扩容数量是多少呢?

我们来看Vector的扩容方法grow():

    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        //扩容后的数组是原来的两倍
        int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                         capacityIncrement : oldCapacity);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

可以看到,Vector扩容后的数组是原来的两倍, 而ArrayList扩容时原来的1.5倍

3. Collections.SynchronizedList

        final List<E> list;

        SynchronizedList(List<E> list) {
            super(list);
            this.list = list;
        }
        SynchronizedList(List<E> list, Object mutex) {
            super(list, mutex);
            this.list = list;
        }

        public void add(int index, E element) {
            synchronized (mutex) {list.add(index, element);}
        }

        public E get(int index) {
            synchronized (mutex) {return list.get(index);}
        }

        public E remove(int index) {
            synchronized (mutex) {return list.remove(index);}
        }

可以看到, Collections.SynchronizedList在初始化时要传入一个集合对象, 而它就是在原先集合的对应方法上都加上了synchronized 修饰,从而保证了线程安全

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值