Java面试题剖析(基础篇) | 第三篇: 深入理解Java中常见的集合Collection(ArrayList、LinkedList、Vector)

在日常开发中,我们常常需要一个容器来集中存放多个数据。从传统意义上讲,数组是一个很好的选择。但是数组存在一个明显的缺点,就是一旦在数组初始化时指定了这个数组长度,那么这个数组长度是不可变的。如果我们需要动态的去存储数据,java中的集合类就是一个很好的设计方案。

集合类主要负责保存、盛装数据,因此集合类也被称为容器类。今天我们主要介绍Collection。

先附上一张Java中集合关系图:

一、Collection下常用的集合

Collection是最基本的集合接口,它代表一组Object的集合,这些Object被称作Collection的元素。Collection是一个接口,用来定义集合规范。源码如下:

可以看到,Collection继承了Iterable接口,Iterable接口源码:

Iterable接口是迭代器接口,实现这个接口的类的对象允许使用foreach进行遍历。该接口里面只有一个iterator()方法,该方法返回一个当前集合的泛型迭代器,用于后续的遍历操作。

Collection常见的子集合接口是List和Set:

下面着重介绍实现了List接口的集合类的原理和区别(下篇博文介绍Set)。

1.1 实现List接口的集合类

这里介绍3个实现了List接口的集合类:ArrayList、LinkedList、Vector。

1.1.1 ArrayList类介绍

平常我们创建ArrayList一般都是这样声明:

List<String> arrayList = new ArrayList<>();

那我们就从构造方法开始,结合JDK源码,逐步分析ArrayList的实现原理。

ArrayList的构造方法有2个,一个是有参的,一个是无参的。其实都是一种实现方式:

当我们new ArrayList<>()时,它会调用有参构造方法,initialCapacity参数为10。通过有参构造方法的实现我们可以看出,创建一个ArrayList就相当于创建了一个数组,这个数组的长度我们可以在创建的时候声明,如果不声明,默认为10

所以说,ArrayList本质上是一个数组,但它是一个动态数组。它是如何实现动态的呢?我们看一下它的add方法:

/**
 * 向ArrayList末尾添加一个元素
 */
public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
        }
/**
 * 向ArrayList中插入一个元素
 */
public void add(int index, E element) {
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);  // Increments modCount!!
        System.arraycopy(elementData, index, elementData, index + 1,
        size - index);
        elementData[index] = element;
        size++;
        }
/**
 * 新增元素
 */
private void ensureCapacityInternal(int minCapacity) {
        modCount++;
        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
        grow(minCapacity);
        }
/**
 * 数组扩容
 */
private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
        }

add方法有2个,一个是向ArrayList末尾添加元素,一个是向指定位置插入元素,这两个方法都会调用ensureCapacityInternal方法,该方法的作用是判断添加或插入元素之后当前数组中的元素个数是否大于数组的长度,如果大于,那么数组就需要扩容,扩容时调用的是grow()方法。注意这一行代码:

int newCapacity = oldCapacity + (oldCapacity >> 1);

这行代码意思显而易见,如果ArrayList扩容,那将扩容1.5倍

ArrayList中向指定位置插入元素时调用了System.arraycopy(.....)方法,该方法原型如下:

public static void arraycopy(Object src,int srcPos,Object dest,int destPos,int length)
  • src:源数组; 
  • srcPos:源数组要复制的起始位置; 
  • dest:目的数组; 
  • destPos:目的数组放置的起始位置; 
  • length:复制的长度。 

也就是说,ArrayList中向指定位置插入元素时进行了一次数组的复制,将原index元素和index之后的元素复制到了新index元素之后。其实在向ArrayList中添加元素时,如果数组需要扩容,那么在grow()方法中调用了Arrays.copyOf(...)方法,该方法也是调用System.arraycopy(.....)方法来实现了数组的复制。在项目开发过程中,如果需要向ArrayList中频繁的插入和添加元素,那么就要考虑下数组复制过程中产生的效率问题。

再看下ArrayList的get和set方法:

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

        return elementData(index);
        }
public E set(int index, E element) {
        rangeCheck(index);

        E oldValue = elementData(index);
        elementData[index] = element;
        return oldValue;
        }

这两个方法完全就是数组的查询和更新,rangeCheck方法用来检查index是否大于当前数组中的元素个数,如果大于,抛出数组越界异常。

最后看一下ArrayList的两个remove方法,分别是按下标和元素来删除数据:

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

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

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                numMoved);
    elementData[--size] = null; // Let gc do its work

    return oldValue;
}
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;
}

remove(int index)方法首先检查index是否越界,然后通过下标找到该元素用于返回值,再将原index后面的元素复制到原index及index之后,这样就空出一位,将这一位赋值为null,等待gc回收。

remove(Object o)方法是通过遍历查找出符合条件的元素,然后执行fastRemove(index)方法进行删除。

private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                numMoved);
    elementData[--size] = null; // Let gc do its work
}

fastRemove(index)和remove(int index)方法相似,区别在于fastRemove没有校验index是否越界,因为index是遍历出来的,并不存在数组越界问题。

1.1.2 LinkedList类介绍

LinkedList的数据结构如下:

首先结合JDK源码看一下LinkedList的声明:

从LinkedList的声明我们能看出:

  • LinkedList 是一个继承于AbstractSequentialList的双向链表。
  • LinkedList 实现 List 接口,能对它进行遍历操作。
  • LinkedList 实现 Deque 接口,即能将LinkedList当作双端队列使用。
  • LinkedList 实现了Cloneable接口,即覆盖了函数clone(),能克隆。
  • LinkedList 实现java.io.Serializable接口,这意味着LinkedList支持序列化,能通过序列化去传输。

针对以上LinkedList的特性,我们还是先从构造方法和API入手,逐步介绍和分析。先来看一下构造方法:

public LinkedList() {
}

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

可以看到,在构造LinkedList时,并没有像创建ArrayList时那样给它分配一个初始容量,同时,它也不需要进行扩容操作。

再来看下它的add、set、get、remove方法。首先是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));
}
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++;
}

/**
 * Inserts element e before non-null Node succ.
 */
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++;
}
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;
    }
}

和ArrayList一样,LinkedList也是有2个add方法,一个是向末尾添加元素,一个是向指定位置添加元素。在add元素时会调用linkLast或者linkBefore方法,先来分析下linkLast方法(源码在上面):

linkLast方法中创建了一个Node对象newNode,Node是LinkedList中的内部类,它是LinkedList实现双向链表的关键。Node中prev是该节点的上一个节点,next是该节点的下一个节点,element是该节点所包含的值。在linkLast方法中,如果LinkedList是空的,那新创建的newNode就是第一个节点;如果非空,那就把newNode链接到最后一个节点的后面。

linkBefore方法也是同理,区别在于它需要先根据index找个对应的节点,然后把新创建的newNode链接到该节点之前。

综上,在LinkedList添加元素时,它不会像ArrayList一样需要考虑扩容、复制集合元素等操作,它只需要链接到对应的节点即可,结构的变动也只是限制在局部2个节点的改动。这是它在执行插入操作时相对于ArrayList的优势。

再看一下LinkedList的set方法:

public E set(int index, E element) {
    checkElementIndex(index);
    Node<E> x = node(index);
    E oldVal = x.item;
    x.item = element;
    return oldVal;
}
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;
    }
}

在set方法中,它首先去检查index是否越界,然后通过node(index)方法得到需要修改值的节点,再改变该节点的值。通过源码可以看到,node(index)方法中其实是一个指针的移动,从链表头或者尾移动到index处。值得一提的是,在这里开发者简单的运用了二分法,用于缩短指针移动的时间。

通过node(index)方法我们也看到了LinkedList在遍历查找元素时相对于ArrayList的弱势,它需要移动指针,而ArrayList只需要根据数组下标对应查找即可。

介绍完set方法后,get方法也不需要再赘述,它也是通过node(index)方法来寻找节点。源码如下:

public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

最后我们再看一下remove方法:

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;
}
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;

    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方法有2个,一个是根据元素值remove,一个是remove指定位置的节点。这两个方法中都调用了unlink()方法来执行remove操作。

unlink()方法中如果需要删除的节点B是表头,那么就让它的下一节点C作为表头;如果它是表尾,那么就让它的上一节点A作为表尾;如果它既不是表头也不是表尾,那么就将它的上一节点A链接到下一节点C上。这样就将该节点从链表中移除,同时它的pre、next、element都是null,等待gc回收即可。

基于LinkedList的双向链表结构,同时实现了 Deque 接口,所以LinkedList可以实现栈、队列或者双端队列。

  • 栈:我们知道,栈是一种先入后出的数据结构,LinkedList中的push()、pop()、peek()等方法可以实现栈。源码如下:
/**
 * 入栈。将元素放到表头
 * @param e
 */
public void push(E e) {
    addFirst(e);
}

/**
 * 出栈。将表头元素移除链表
 * @return
 */
public E pop() {
    return removeFirst();
}

/**
 * 查看栈头部元素,不修改栈,如果栈为空,返回null
 * @return
 */
public E peek() {
    final Node<E> f = first;
    return (f == null) ? null : f.item;
}
  • 队列:队列是一种先入先出的数据结构,它只允许在容器的头部进行删除操作,而在表的尾部进行插入操作。进行插入操作的端称为队尾,进行删除操作的端称为队头。LinkedList中的offer()、poll()等方法可以实现队列。源码如下:
/**
 * add方法中调用的是linkLast方法,作用是向表尾添加元素。相当于入队
 * @param e
 * @return
 */
public boolean offer(E e) {
    return add(e);
}

/**
 * 调用unlinkFirst方法,作用是将表头元素移除链表。相当于入队
 * @return
 */
public E poll() {
    final Node<E> f = first;
    return (f == null) ? null : unlinkFirst(f);
}
  • 双端队列:Deque是一种同时具有队列和栈的性质的数据结构。双端队列中的元素添加、获取、移除等操作都可以在表的两端进行。LinkedList中的removeFirst()、removeLast()、addFirst()、addLast()等方法可以实现双端队列。源码如下:
/**
 * unlinkFirst方法用于将表头元素移除链表
 * @return
 */
public E removeFirst() {
    final Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return unlinkFirst(f);
}

/**
 * unlinkLast方法用于将表尾元素移除链表
 * @return
 */
public E removeLast() {
    final Node<E> l = last;
    if (l == null)
        throw new NoSuchElementException();
    return unlinkLast(l);
}

/**
 * linkFirst方法用于将元素添加到表头
 * @param e
 */
public void addFirst(E e) {
    linkFirst(e);
}

/**
 * linkLast方法用于将元素添加到表尾
 * @param e
 */
public void addLast(E e) {
    linkLast(e);
}

1.1.3 Vector类介绍

Vector在日常开发中并不是经常被用到的一个类,在这里简单介绍一下它的实现原理。还是先从类的声明开始介绍:

Vector的声明和ArrayList是完全相同的。接下来我们再看一下它的构造方法:

public Vector(int initialCapacity, int capacityIncrement) {
    super();
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal Capacity: "+
                initialCapacity);
    this.elementData = new Object[initialCapacity];
    this.capacityIncrement = capacityIncrement;
}
public Vector(int initialCapacity) {
    this(initialCapacity, 0);
}
public Vector() {
    this(10);
}
public Vector(Collection<? extends E> c) {
    elementData = c.toArray();
    elementCount = elementData.length;
    // c.toArray might (incorrectly) not return Object[] (see 6260652)
    if (elementData.getClass() != Object[].class)
        elementData = Arrays.copyOf(elementData, elementCount, Object[].class);
}

可以看到Vector有4个构造方法,initialCapacity参数代表初始容量,如果不传,默认为10;capacityIncrement参数代表增量,在下文介绍add方法的时候会阐述它的作用。最终,构造Vector其实就是创建了一个数组,这个和构造ArrayList是一致的

下面再看一下Vector的add、set、get、remove方法。首先是add方法:

public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}
public void add(int index, E element) {
    insertElementAt(element, index);
}
private void ensureCapacityHelper(int minCapacity) {
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}
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);
}
public synchronized void insertElementAt(E obj, int index) {
    modCount++;
    if (index > elementCount) {
        throw new ArrayIndexOutOfBoundsException(index
                + " > " + elementCount);
    }
    ensureCapacityHelper(elementCount + 1);
    System.arraycopy(elementData, index, elementData, index + 1, elementCount - index);
    elementData[index] = obj;
    elementCount++;
}

add方法有2个,一个是添加,一个是插入,但它们的实现原理相同。add和insertElementAt方法前面添加了synchronized修饰符,所以add方法是同步方法,也就是线程安全的。add方法中调用了ensureCapacityHelper方法,该方法中判断是否进行扩容操作,如果需要扩容,那么就执行grow方法来扩容。grow方法中和ArrayList存在相似的地方,即扩容之后进行数组的复制,区别在于扩容多少。ArrayList扩容后是原容量的1.5倍,Vector中扩容机制是如果capacityIncrement,即上文提到的增量大于0,那么新容量等于原容量+增量;如果capacityIncrement不大于0,那么扩容后是原容量的2倍。

Vector中的set、get、remove方法中的实现和ArrayList相似,无非就是些数组越界检查、数组复制、数组元素赋值、数组元素删除等操作,这里不再赘述。区别在于Vector的这几个方法前面都用了synchronized来修饰,是线程安全的。

1.1.4 总结

前面结合源码分析了ArrayList、LinkedList、Vector的实现原理,这里简单总结下这几个集合类的异同:

ArrayList和LinkedList的区别及优缺点:

  1. ArrayList是实现了基于动态数组的数据结构,LinkedList是基于链表结构;
  2. 对于随机访问的get和set方法,ArrayList要优于LinkedList,因为LinkedList要移动指针;
  3. 对于新增和删除操作add和remove,LinkedList比较占优势,因为ArrayList要移动数据;
  4. ArrayList的空间浪费主要体现在List列表的结尾预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗空间;
  5. LinkedList并未实现RandomAccess接口,所以它不支持高效的随机访问。

ArrayList和Vector的区别及优缺点:

  1. Vector的方法都是同步的(Synchronized),是线程安全的(thread-safe),而ArrayList的方法不是。由于线程的同步必然要影响性能,因此,ArrayList的性能比Vector好; 
  2. 当Vector或ArrayList中的元素超过它的初始大小时,Vector会将它的容量加上增量capacityIncrement或者翻倍,而ArrayList只增加50%的大小。

以上介绍了Java中常见的集合Collection(ArrayList、LinkedList、Vector),同时,也是Java面试中常见的知识点。

欢迎在评论区留言,我会尽快回复~

如有任何问题,也可在公众号下留言,工程师们会逐一答疑。公众号二维码:

                                  

猜你喜欢

转载自blog.csdn.net/fanguoddd/article/details/88298886