Java集合(上)List篇

677 阅读12分钟

我们都知道,在Java的世界里“万物皆对象”。Java集合(Collection),有时也可称为容器(Container),是把具有相同类型的多个元素组织起来的一种对象。集合这一概念在现实生活中的例子也比比皆是。例如,文件夹是一打文件的集合,扑克是一堆纸牌的集合,电话号码本是名字与电话号码的对应关系的集合。那么你可能会问,同样是把相同类型的多个元素组织起来,不是已经有了数组(Arrays)了吗?为什么还需要集合呢?随着学习的深入我们最后会解答此问题。

Java集合框架主要有两大类集合:Collection 和 Map[1]

上图是集合框架的一个简图。矩形虚线表示接口(interface),矩形实线表示类。空心箭头的虚线表示类或接口的实现。例如,类ArrayList实现了List接口,List接口实现了Collection接口。实心箭头的虚线表示某个类的对象可以生成箭头所指向的类的对象。例如,任意的Map对象可以生成Collection对象,任意的Collection对象可以生成Iterator对象。右下脚圆角矩形中的是工具类:Collections(工具类) 和 Arrays(数组类)。加粗的黑框所表示的类:ArrayList、LinkedList、HashSet、HashMap是使用频率比较高的几个类。

List


List是一个有序集合(ordered collection)或序列(sequence),增加到List中的元素是有先后顺序的。可以通过两种方式来获取元素:使用迭代器(iterator),或者使用整数索引(index)。使用索引的方式也叫随机访问(random access),而使用迭代器来访问时,必须要顺序地来访问元素。

主要有两种常用的List:

  • ArrayList:优点为随机访问,缺点为添加删除元素效率不高
  • LinkedList:优点为快速地添加或移除元素,缺点为不能实现随机访问

ArrayList


ArrayList的底层数据结构是数组[2]。类中的Object类型的数组成员变量elementData保存指定类型的数组元素。

add(E e)方法将元素e添加到list的尾部,如果list当前的容量已满,那么会先对list进行扩容,然后再添加。

add(int index ,E e)方法将元素添加到list指定的索引位置,首先检查索引范围是否越界,若否,则添加;否则,抛出IndexOutOfBoundsException异常

remove(int index)方法先判断索引范围是否越界,若否,则将index索引位置的元素移除。

值得注意的是,在list中间添加或移除元素时会导致它发生结构性改变(structural modification)。如图3,为了保持随机访问的特性,需要批量移动元素,但是这样的操作很低效。所以在需要频繁插入或删除元素的场合,使用LinkedList会更合适。

get(int index)方法获取数组中index下标对应的元素,正是由于底层为数组,所以它具备随机访问的特性。

public class ArrayList{
    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];
    }
}

数组由一段地址连续的内存空间构成,根据数组下标地址计算公式:

a[k]_address = base_address + k * type_size

其中base_address为数组起始地址,k为索引值。type_size表示元素类型所占的空间大小。例如,int类型的数据,type_size的大小就为4个字节(byte),可以迅速计算出要访问的元素的位置。

set(int index, E element) 方法为指定索引的数组元素赋值并返回原值。

public class ArrayList{
    public E set(int index, E element) {
            rangeCheck(index);
            E oldValue = elementData(index);
            elementData[index] = element;
            return oldValue;
    }
}

迭代器


在介绍LinkedList之前,我们先来看下迭代器。所有实现了Collection接口的类都可以创建一个迭代器。其根本原因是Collection接口实现了Iterable接口。

所有实现了Iterable接口的类必须重写以下方法来返回一个实现了Iterator接口的对象

public interface Iterable<T> {
    Iterator<T> iterator();
}

Iterator接口包含4个方法:

public interface Iterator<E> {
	boolean hasNext();
	E next();
	default void remove() {};
	default void forEachRemaining(Consumer<? super E> action) {};
}

next方法可以访问集合中的下一个元素,如果反复调用它的话,可以遍历整个集合。当到达元素末尾时再调用next方法会抛出一个NoSuchElementException异常。因此,一般我们在调用next之前会调用hasNext方法来进行判断。

hasnext方法会检测是否存在下一个元素,若是,则返回true;否则,返回false。它们常见的用法如下:

ArrayList<String> a = new ArrayList<String>();
Iterator<String> iter = a.iterator();
while(iter.hasNext){
    String element = iter.next();
    ...
}

我们上面提到Collection接口扩展了Iterable接口,任何实现了Iterable接口的对象都可以使用“for each”循环。所以对于标准类库中的任何集合,可以使用“for each”循环来遍历元素。所以上边的代码可以更加简洁地表示成如下:

for (String element : a){
    do something with element
}

值得注意的是迭代器的位置,Java迭代器应该是位于两个元素之间的位置,当调用next时,迭代器就越过下一个元素,并返回刚刚越过的那个元素的引用[3].

remove方法会删除上次调用next方法时返回的元素。要删除某个元素前必须先越过此元素,在删除元素的时候next和remove必须成对出现。

Iterator<String> it = a.iterator;
it.next;//跨越一个元素
it.remove();//移除跨越的元素

不能够连续的使用remove方法来连续移除元素,在调用remove之前必须调用next,否则会抛出IllegalStateException异常。

it.remove;
it.remove;//错误!

正确方式:

it.remove;
it.next();
it.remove();

下面,我们来剖析ArrayList的内部类迭代器Itr。来探讨下为什么移除元素时next、remove必须要同时使用,且不能连续使用remove。

private class Itr implements Iterator<E> {
    int cursor;       // 下一个元素的索引
    int lastRet = -1; // 已经返回的元素的索引,若没有则值为-1
    int expectedModCount = modCount;
    ...
    ...
    ...
    public E next() {
        checkForComodification();
        int i = cursor;
        if (i >= size)
            throw new NoSuchElementException();
        Object[] elementData = ArrayList.this.elementData;
        if (i >= elementData.length)
            throw new ConcurrentModificationException();
        cursor = i + 1;
        return (E) elementData[lastRet = i];
    }
    public void remove() {
        if (lastRet < 0)
            throw new IllegalStateException();
        checkForComodification();
        try {
            ArrayList.this.remove(lastRet);
            cursor = lastRet;
            lastRet = -1;
            expectedModCount = modCount;
        } catch (IndexOutOfBoundsException ex) {
            throw new ConcurrentModificationException();
        }
    }
    ...
    ...
    ...
}

在上面代码的next方法中由中间变量i维持cursor、lastRet值相差1的状态,返回索引lastRet对应的数组元素。与此同时调用remove方法时会移除索引lastRet对应的数组元素并将lastRet的值置为-1。所以若再一次调用remove方法会触发lastRet小于0的条件,抛出IllegalStateException异常。

LinkedList


前面提到要在List中实现高效地增删元素可以使用LinkedList,它的底层数据结构是链表。链表由一串节点串联而成,每个节点会保存元素值和下一个节点的引用。它是一种线性的数据结构,与数组连续的内存空间不同,每个节点的内存地址不是连续的。

在中间新增元素时,首先找到要插入元素的前一个节点,然后将新增节点F的next指向C节点,最后将B的next指向F。具体的实现代码如下:

q.next = p.next;
p.next = q;

删除元素时,首先找到待删除元素的前一个节点,然后将其next指向下下个节点,具体代码如下:

p.next = p.next.next;

上图提到的链表都是单链表,在java中所有的链表实际上都是双向链接的(doubly linked),每个节点除了保存后一个节点的引用外,还保存前一个节点的引用。见下图:

比起单向链表,双向链表的结构可以便捷地从某一节点往前寻找节点,提高了查找的效率。不过却使得插入和删除元素的操作稍微又复杂了一些。插入过程如下图:

在A、C间插入元素B,首先需要将B的next指向C,然后将C的previous指向B,再将A的next指向B,最后将B的previous指向A,实现代码如下:

q.next = p.next;
p.next.previous = q;
p.next = q;
q.previous = p;

不能像下边这样调转代码的先后执行顺序,下面的执行会造成被插入节点的next和previous都指向自己。

p.next = q;
q.previous = p;
q.next = p.next;
p.next.previous = q;

删除过程如下图,先将A元素的next指向C元素,再将C元素的previous指向A元素

实现代码如下:

p.next = p.next.next;
p.next.next.previous = p;

上面我们简单回顾了链表添加、移除元素的底层操作,值得庆幸的是Java中的LinkedList为我们封装了这些细节,我们可以直接方便地使用add、remove方法来实现元素的添加、删除操作。接下来我们一起来看看相关的方法。 add(E e)方法可以将元素添加到list的尾部。

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

void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);//创建Node对象,最为list最后一个节点
    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;
    }
}

add(int index,E element)方法将元素插入到索引指定的位置。此方法与前述ArrayList中的add方法相比,它不需要执行批量移动数组元素的低效操作,但是在获取指定索引位置的node对象时,需要依次从首部或尾部元素依次遍历,不具备随机访问的特性。相关代码如下:

public void add(int index, E element) {
        checkPositionIndex(index);

        if (index == size)
            linkLast(element);
        else
            linkBefore(element, node(index));
}
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;
    }
}
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++;
}

remove()方法会从list中移除首元素并返回节点内的元素值

public E remove() {
    return removeFirst();
}
public E removeFirst() {
    final Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return unlinkFirst(f);
}
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;
    f.next = null; // 垃圾回收
    first = next;
    if (next == null)
        last = null;
    else
        next.prev = null;//首元素没有前驱节点
    size--;
    modCount++;
    return element;
}

get(int index)方法用来获取特定索引位置的元素,这个方法的效率不高,如果发现需要频繁地执行这个方法,那么说明有可能对于所要解决的问题使用了错误的数据结构。不应该使用让人误解的随机访问方法来访问链表,如下代码:

LinkedList<String> list = new LinkedList<String>();
for(int i = 0; i < list.size(); i++){
    list.get(i);
}
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;//获取节点元素
}
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;
    }
}

其执行效率极低,与前面我们提到add(int index ,E element)方法时分析的一样,每一次要找到指定索引位置的元素都需要从两头依次往中间遍历,根本不做任何缓存位置信息的操作。所以使用链表的唯一理由是尽可能地减少在list中间插入或删除元素所付出的时间或空间代价,而如果要对list进行随机访问,就要使用数组或ArrayList。

分析完常用的两种list的要点,我们来解答下开篇时提到的问题:集合和数组有什么区别呢? 争对数组类型,很多语言都提供了集合类,例如Java中的ArrayList、C++ STL中的vector、Python和C#中的list等。以Java语言的ArrayList为例它主要有两个优势:

  • 封装了数组操作的细节,比如插入删除元素时对数据的搬移。
  • 动态扩容,不需要关心底层的扩容操作,每次存储空间不够时它都会扩容为原来的1.5倍大小。(如下代码所示)

值得注意的是,扩容操作所进行的内存申请和数据搬移耗时较长。所以,在确定需要存储的数据量的情况下,最好在初始化时设置ArrayList的大小,以避免频繁地扩容操作。

private void grow(int minCapacity) {//ArrayList扩容调用方法
    // 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);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);//扩容
}

在比较注重性能或操作比较简单的场合,更适合使用数组,原因如下:

  • ArrayList无法存储原始类型(primitive type),比如int、long、float等,需要封装为Integer、Long类,而自动装箱(Autoboxing)、拆箱(Unboxing)则有一定的性能消耗,故其不适合使用于特别关注性能的场合。
  • 当对数据的操作比较简单用不到ArrayList提供的大部分方法并且数据大小事先已知时,就可以使用数组。

今天就到这里,下篇文章将一起来探讨Set和Map。若有表述不准确的地方,欢迎指正。

参考文献:


  1. Eckerl Bruce.Thinking in Java[M].陈昊鹏译.北京:机械工业出版社,2019:chapter17 ↩︎

  2. ArrayList.docs.oracle.com.2014.8 ↩︎

  3. Horstmann Cay S.Java核心技术卷[M].周立新等译.北京:机械工业出版社,2016.8:p347-p349 ↩︎