Java中主要的List结构——概述

Java
263
0
0
2023-06-18

0. 概述

典型的数据结构中,对于“表”结构的定义是:在一维空间下元素按照某种逻辑结构进行线性连接排列的数据结构(一对一)。 java 中集合定义中所包括的数组表(ArrayList)、链表(LinkedList)、各种队列(Queue/Deque)、栈(Stack)等都满足这样的定义。本文及后续的几篇文章中将介绍Java集合结构中关于List接口、Queue接口、Set接口下的重要实现类。注意,关于java.util.concurrent包下对于List接口、Queue接口和Set接口实现类的介绍,将在后续专门的文章进行介绍。

1. Java中List性质集合概述

Java中主要的List结构——概述

上图中展示了Java中的java.util.List接口所涉及的部分重要接口和抽象类,以及java.util.List接口在java.util包中的具体实现类。其中以黄色表示的类就是本文将要介绍的java.util包中关于List接口的重要实现类,他们分别是java.util.ArrayList、java.util.LinkedList、java.util. Vector 和java.util.Stack。其中Vector和Stack这两个类是继承关系(从上图中就可以看出),他们从JDK1.0开始就被提供出来供开发人员使用,后来又被性能和设计都更好的其它类替换。例如从名字上就可以看出其功能特点是LIFO性质(后进先出)的Stack类,在其自身的文档中(JDK1.7+)已建议开发者优先使用性能更好的ArrayDeque作为替代方案(关于ArrayDeque本专题的后续文章中进行详细介绍)。

但是本专题依然会介绍java.util.Vector类和java.util.Stack类,因为本专题主要是分析Java源代码的设计思想,以便读者将这些设计思想应用到实际的工作中,在本专题的后续文章中还会继续讨论java中的java.util.Set接口、java.util.Deque接口的构建体系。

2.java集合List定义中的重要接口意义

要理解java.util包中关于java.util.List接口的重要实现类,就首先要搞清楚其上层和下层涉及的主要接口定义和它们定义的功能范围,它们是:java.lang.Iterable接口、java.util. Collection接口 、java.util.AbstractList抽象类和java.util.AbstractSequentialList抽象类:

2.1. java.lang.Iterable接口

Java中主要的List结构——概述

由上图可知,本专题第一部分将介绍的List、Set、Queue性质的集合接口其上层都需要继承java.lang.Iterable接口。根据该接口上自带的注释描述,实现该接口的类可以使用“for each”循环操作语句进行操作处理。但实际上该接口还提供了两个操作方法(JDK 1.8+), forEach (Consumer<? super T> action) 方法和spliterator() 方法。forEach(Consumer<? super T> action) 方法的一般使用方式示例如下:

 // 这里创建一个LinkedList,并且使用forEach方法,“消费”其中每一个要素
new LinkedList<>().forEach(item -> {
  // ...... 这里对每个item元素进行消费
});

// 再举一个例子,这里的Lists是 google common工具包提供的一个List性质集合相关的处理包
Lists.newArrayList("value","value2","value3","value4").forEach(item -> {
  // ...... 这里对每个item元素进行消费
}); 

forEach中的Consumer接口定义在java.util.function包下,这个包是JDK1.8中提供的,里面包括了大量函数式编程功能,java.util.function.Consumer接口就是其中之一:表示消费某个对象。

2.2. java.util.Spliterator接口

java.lang.Iterable接口中的另一个方法spliterator(),实际上它是“并行 迭代器 ”的定义接口。要说明这个在JDK1.8中提供的“并行迭代器”接口,就要先大致介绍在JDK1.2版本中提供的一个“顺序迭代器” java.util.Iterator(请注意Iterator接口和Iterable接口在字面上的区别)接口。

所谓“顺序迭代器”是可以将集合中的元素基于一定的顺序规则,一个接一个的进行遍历处理。其处理过程基于单核单线程;而“并行迭代器”可以将集合中的元素进行拆解后把他们同时交给多个线程进行处理——也就是说基于多核多线程处理。实际上其内部处理原理涉及到Java同样在JDK1.8开始提供的Fork/Join框架。

2.3. java.util.Collection接口

该接口是一个非常关键的接口,如果读者仔细观察java.util包中的源码结构,就会发现该接口并没有一个直接的实现类。凡是实现了该接口的下级类或者接口,都属于Java Collections Framework(Java集合框架)的一部分。

凡是实现了java.util.Collection接口的操作类,代表着这个类中可以按照某种逻辑结构和物理结构,“线性关联”的存储着一组元素的集合。这种线性关联的逻辑结构可能是链表(例如:LinkedList),也可能是固定长度的数组(例如:Vector);可能向外界的输出的结果是有序的(例如:ArrayList),也可能是无序的(例如:HashSet);可能是保证了多线程下的操作安全性的(例如:CopyOnWriteArrayList),也可能是不保证多线程下的操作安全性的(例如:ArrayDeque);

2.4. java.util.AbstractList抽象类

读者一定要知道,在Java中根据List性质的集合在各个维度上表现出来的工作特点,这些List结合可以被分成三种类型:是否支持随机访问的特点进行分类、按照是否具有可修改权限进行分类、按照大小是否可变进行分类

Java中List性质的集合,根据是否支持随机访问的特点进行分类的话,当然就包括两种类型:支持随机访问(读)的集合和不支持随机访问(读)的集合 。所谓支持随机访问集合,就是指集合提供相关功能可以对List集合中任意位置的元素进行时间复杂度不改变的定位操作。

请注意 Java中为List定义的“随机访问”的意义和磁盘IO上的“随机读”是有区别的(也有相似性),虽然两者都是在说“可以在某个指定的独立位置读取数据”这个事情,但是由于机械磁盘“旋转”的定位方式或者由于固态磁盘的垃圾标记/回收机制,所以磁盘IO读写中的“随机读”性能是要显著慢于磁盘IO读写中的“顺序读”的;List中定义的“随机访问”需要从算法的“时间复杂度”层面考虑,例如使用数组结构作为List集合基本结构时,其找到一个“指定”位置的时间复杂度为常量O(1)——因为可以直接定位到指定的内存起始位置,并通过偏移量进行最终定位。所以List性质的集合中定义的支持“随机访问”的集合结构,在数据读取性能上远远优于那些不支持“随机访问”的List集合——后续内容介绍ArrayList和LinkedList时,还会详细讲解。

另外,如果将List集合按照是否具有可修改权限进行分类,那么List集合分为可修改集合和不可修改集合 。所谓可修改集合是指操作者可以在集合指定的 索引 位置指定一个存储值;所谓不可修改集合 既是 操作者只能获取集合指定索引位置的存储值,但是并不能对这个索引位置的值进行替换,使用者也可以获取当前集合的大小,且这个大小的值一定是不可改变的。

最后,如果将List性质的集合按照大小是否可变进行分类,那么List集合分为大小可变集合和大小不可变集合 ,所谓大小不可变集合,既是说一旦这个集合完成了实例化,那么大小就一直固定下来不再变化,而大小可变集合的定义则刚好相反。

针对这三个维度的不同类型定义,开发人员就可以定义出不同操作特性的List集合。 为了保证具有不同分类特点的List集合提供的操作方法符合规范性,也为了减少开发人员针对这些不同分类的List集合的开发工作量,还为了向使用者屏蔽这些分类定义的细节差异,Java为List性质的集合提供了java.util.AbstractList抽象类

这样保证了各种具体的List集合的实现类中只需要按照自身情况重写java.util.AbstractList抽象类中的不同方法即可。例如,set(int) 方法其工作特点一定是替换指定索引位的元素值,如果当前List性质的集合不支持修改,则一定会抛出UnsupportedOperationException异常;再例如,具有不可修改性质的List集合,开发人员只需要重写java.util.AbstractList抽象类中的 get(int) 和 size() 方法即可;如果开发人员自行定义一个支持可变大小性质的集合,则只需要重写对add(int , E) 方法和 remove(int) 方法的实现;最后再举例,如果开发人员不需要实现支持随机访问的List集合,则可以优先继承java.util.AbstractSequentialList抽象类。

2.5. java.util.RandomAccess接口

java.util.RandomAccess接口是一个标识接口,所谓标识接口是Java中用来定义拥有某一种操作特性、功能特性的方式。Java中有很多标识接口,例如:java.lang.Cloneable接口、java.io.Serializable接口。

上文已经提到,List性质的集合中专门有一组集合实现类是支持“随机访问”特性的,包括java.util.ArrayList、java.util.Vector和java.util.concurrent.CopyOnWriteArrayList集合。java.util.RandomAccess标识接口就是为了向调用者表示这些List性质的集合实现类支持集合元素的随机访问。如下图所示:

Java中主要的List结构——概述

从上图可以看出,List性质的集合java.util.ArrayList、java.util.Vector和java.util.concurrent.CopyOnWriteArrayList,都实现了这个java.util.RandomAccess标识接口,表示自己支持随机访问(读)操作。实现java.util.RandomAccess标识接口的还有很多第三方类库,例如上图中举例就是阿里巴巴开源的JSON分析组件中的JSONArray类。这些实现了java.util.RandomAccess标识接口的List集合在使用时也会被区别对待,如下所示:

 /**
 * Replaces all of the elements of the specified list with the specified
 * element. <p>
 * This method runs in linear time.
 * @param  <T> the class of the objects in the list
 * @param  list the list to be filled with the specified element.
 * @param  obj The element with which to fill the specified list.
 * @throws UnsupportedOperationException if the specified list or its
 *         list-iterator does not support the <tt>set</tt> operation.
 */public static <T> void fill(List<? super T> list, T obj) {
  int size = list.size();
  // 如果当前集合的大小规模小于FILL_THRESHOLD (),或者当前List集合支持“随机访问”
  // 那么优先使用索引定位的方式替换集合中的每个位置的对象引用
  if (size < FILL_THRESHOLD || list instanceof RandomAccess) {
    for (int i=; i<size; i++)
      list.set(i, obj);
  }
  // 否则使用  ListIterator 顺序迭代器一次寻找集合的每一个位置,并替换其中的对象引用
  else {
    ListIterator<? super T> itr = list.listIterator();
    for (int i=; i<size; i++) {
      itr.next();
      itr.set(obj);
    }
  }
} 

如上示例代码来源于java.util.Collections类的fill()方法,该方法主要用于向一个List性质集合填充默认的Object对象。在这个方法中如果当前给定的List性质的集合如果支持RandomAccess随机访问特性,则优先使用for()循环的方式定位并填充集合中的每一个位置;如果当前给定的List性质集合不支持“随机访问”,则是用ListIterator迭代器顺序定位和填充集合中的每一个位置。

为什么会出现这种处理逻辑呢?我们来看看在List集合默认的上层抽象类java.util.AbstractList中的list.listIterator()方法返回的ListIterator迭代器是如何实现next()方法的。

 public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
  // ......
  // Collections类的fill()方法,就是调用的该方法
  public ListIterator<E> listIterator() {
    return listIterator();
  }
  public ListIterator<E> listIterator(final int index) {
    rangeCheckForAdd(index);
    return new ListItr(index);
  }
  // ......
  // AbstractList类中并不会实现get()方法,而是将该方法的实现交给具体的实现类。
  // 也就是说不同的实现类中会有不同的get()方法的实现过程。
  abstract public E get(int index);
  // ......

  // ListItr类是Itr的子类,next()方法就是在后者中进行定义的
  private class Itr implements Iterator<E> { 
    // ......
    // next方法的调用过程在这里
    public E next() { 
      checkForComodification();
      try { 
        // 关于cursor变量和lastRet 变量在迭代器中的意义
        // 在后文会进行介绍,这里我们主要关注本方法内容中的get()方法。
        int i = cursor;
        E next = get(i);
        lastRet = i;
        cursor = i +;
        return next;
      } catch (IndexOutOfBoundsException e) { 
        checkForComodification();
        throw new NoSuchElementException();
      } 
    }
    // ......
  }
} 

在AbstractList.Itr类的next()方法中,我们主要关注其中的get()方法。并且在上面的代码片段上已经说明,不同实现原理下的具体List集合类对于get()方法的实现是不一样的,那么我们来看一下两个典型的List集合ArrayList和LinkedList对于get()方法的实现。

  • 首先来看一下LinkedList中对于get()方法的实现:
 public E get(int index) {
  // 后续文章会说明checkElementIndex()方法,在本文中的内容中,它并不重要
  checkElementIndex(index);
  return node(index).item;
}

Node<E> node(int index) {
  // 如果给定的index小于当前集合大小的一半,那么从连表的头部开始寻找
  // 否则就从连表的尾部开始寻找
  if (index < (size >>)) {
    Node<E> x = first;
    for (int i =; i < index; i++)
      x = x.next;
    return x;
  } else {
    Node<E> x = last;
    for (int i = size -; i > index; i--)
      x = x.prev;
    return x;
  }
} 

由于LinkedList是一个双向链表,要寻找链表中的某一个位置上的元素,就只能从头部或者从尾部一个一个的找。如下图所示:

这样我们就可以复盘java.util.Collections类的fill()方法中,如何进行LinkedList中的元素填充了,如下图所示:

  • 然后我们再来看一下ArrayList中对于get()方法的实现:
 // ArrayLit类中的elementData变量就是这个集合的数组形式表示
transient Object[] elementData;

public E get(int index) {
  // rangeCheck方法后文会进行讲解,但和这里讲解的内容关联不大
  rangeCheck(index);
  // 通过elementData方法,直接定位数组中的元素
  // 保证了对“随机访问”特性的支持,对算法复杂度O()的支持
  return elementData(index);
}

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

由于ArrayList本质上是一个数组,要寻找到数组中的某一个位置上的元素并不用挨个元素意义进行遍历。JVM会根据对象在内存中的起始位置和数组位置的偏移量直接找到这个元素。按照这样的原理,我们同样可以复盘java.util.Collections类的fill()方法中,如何进行ArrayList中的元素填充了,如下图所示:

以上示例的分析中,本文将支持“随机访问”和不支持“随机访问”的具体List集合在访问性能上的工作差异做了详细标识,实际上典型的ArrayList和LinkedList的性能差别还不仅仅在于此处,后续文章还会做更详细说明。另外,在本文第1小节给出的List集成体系简图中,还出现了java.util.Queue接口和java.util.Deque接口,这两个接口代表Java集合体系中另外一块和List集合体系平行的集合体系,在后续文章中也将进行详细介绍。