我们都知道,在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),每个节点除了保存后一个节点的引用外,还保存前一个节点的引用。见下图:
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。若有表述不准确的地方,欢迎指正。
参考文献: